Resilient Integration Guide

Building a payment system that doesn't lose money is harder than it sounds. Network timeouts happen. Webhooks get delayed. Servers crash. This guide teaches you how to handle all of it without accidentally creating duplicate charges or missing payments.

The core idea is simple: never assume an HTTP response tells you the final truth. Your payment might have succeeded on our servers even if you get a timeout. Your order status should come from the actual payment state, not from how fast the network was.


The Three Pillars of Resilience

Idempotency: Safe Retries and Duplicate Protection

An idempotent request is one where you can safely send it multiple times and always get the same result. No duplicate charges. No two intents created. This matters because network issues are common. Your request might succeed on our servers but your connection times out. Idempotency keys let you recover from that without worry.

How it works:

Include an X-Idempotency-Key header in your payment creation requests. This should be a UUID (a unique identifier you generate). MoneyHash uses this key to track the request. If you send the same request twice with the same key, you get the same response back.

Use your order ID or a hash of your order data to generate the key. Make it deterministic so you can safely retry without creating a new key.

What to send:

POST /api/v1/payment/
X-Idempotency-Key: <UUID>
X-Api-Key: <your-api-key>

{
  "merchant_reference": "order-0001",
  "custom_fields": {...},
  ...other payment data...
}

What the X-Api-Key header is for:

The X-Api-Key header is mandatory for each request. The X-Idempotency-Key is used in combination with the X-Api-Key to uniquely identify the request.

What happens if your request times out:

The payment might have been created on our servers even though you got a timeout. Here's what to do:

  1. Retry the exact same request with the same X-Idempotency-Key
  2. You'll get back either the intent that was created, or an error if it truly failed
  3. Never create a new intent with a different key after a timeout. That's how you end up with duplicate payments.

Handling duplicate requests:

If you resend a request with the same X-Idempotency-Key and X-Api-Key combination within 24 hours, MoneyHash returns the same response you got the first time. This is true even if the original request encountered failures. You'll get consistent results.

Invalid keys:

The X-Idempotency-Key must be a valid UUID (V4 format). If it's not, you'll get a 400 Bad Request error with a message indicating the UUID is invalid.

Which endpoints support idempotency:

  • Create Payment Intent
  • Close an Intent
  • Capture a Transaction
  • Void a Transaction
  • Charge Card Token
  • Issue a Refund

For a full reference on idempotency behavior, see Programmatic Access.

This diagram shows what happens when you create a payment. Send the request with your idempotency key. Either you get a response back (store the intent ID) or you time out. If you time out, retry with the same key or wait for a webhook. Either way, you won't create a duplicate.


Webhooks: Process Payment Updates Asynchronously

MoneyHash sends you webhook updates whenever something happens to a payment. A customer completes 3DS verification. A payment is captured. A refund goes through. These updates are your source of truth for what actually happened.

But webhooks don't always arrive in order. They might arrive late. They might arrive twice. Your system needs to handle all of this gracefully.

Verify every webhook:

Check the X-MoneyHash-Signature header on every webhook you receive. This proves it came from MoneyHash and wasn't tampered with. Reject any webhook without a valid signature.

Deduplicate webhooks:

Transaction webhooks include either a status_id or operation_id. Before you process a webhook, check if you've already processed using the status_id or operation_id (whichever is present). If you have, return 200 OK and stop. This prevents you from processing the same event twice if MoneyHash retries the webhook delivery.

Find your order:

The webhook contains the intent_id. When you created the payment and got back the intent ID, store it with your order in your database. When a webhook arrives, use the intent_id from the webhook to look up your local order. That's how you know which customer's payment this update is for.

If the webhook arrives before you've created your local order (rare timing issue), store the entire webhook payload temporarily. Then once you create the order locally, you can match it using the intent_id and process the stored webhook.

Extract the payment status:

Once you have the order, look at payment_status.status in the webhook. This field tells you what actually happened to the payment. Use this to decide what to do next.

For the full list of webhook events and best practices for handling them, see Webhook Types.

This diagram shows the webhook flow. Verify, deduplicate, find your order, and handle orphans.


Status Classification: What Each Status Means

The payment_status.status field can be one of several values. Each one tells you something different and requires a different action from you.

CAPTURED means the full payment was successfully taken from the customer. Mark the order as paid and fulfill it.

AUTHORIZED means the payment was verified and the funds are on hold, but not yet captured. This usually happens with authorize-only flows. Wait for a separate capture event, or initiate the capture yourself if that's how your integration works.

REFUNDED means money was sent back to the customer. Check the payment_status.balances field to see refund amounts in case of partial refunds, and update your ledger accordingly.

VOIDED means an authorization was released without capturing it. The customer was never charged.

AUTHORIZE_ATTEMPT_PENDING means the customer is still going through 3DS verification or a bank redirect. Do not fail the order. The payment is not complete yet, but it's also not failed. Keep waiting for the next update.

AUTHORIZE_ATTEMPT_FAILED is trickier. This means one attempt to authorize the payment failed, but that doesn't necessarily mean the order should be failed. Check intent.status to decide what to do.

If intent.status is UNPROCESSED, it means we can still try again. The payment failed at this particular payment provider, but MoneyHash might try another provider automatically, or the customer might retry. Keep the order open and allow a retry.

If intent.status is PROCESSED, CLOSED, or EXPIRED, it means we're done trying. No more retries are possible. Mark the order as failed and tell the customer why.

For the full reference of all status values and error codes, see Payment Status Codes.

This diagram shows the decision tree for every payment status. Follow it to know what action to take based on what the payment status actually is.


Real Example: What Actually Happens

Let's walk through a real scenario so you see how all three pillars work together.

A customer tries to pay with a credit card that requires 3DS verification. Here's the sequence of events:

Your system:

  1. You create a payment with an idempotency key and your order ID
  2. MoneyHash returns an intent ID immediately
  3. You store the intent_id with your order in your database

First webhook arrives: payment_status.status is AUTHORIZE_ATTEMPT_PENDING

Your system receives this and sees that the authorization is pending. You deduplicate by status ID (it's the first one, so you process it). You look up your order using the intent_id from the webhook. You see that authorization is pending, so you mark the order as "awaiting customer action" and wait.

Customer completes 3DS in their browser

Second webhook arrives: payment_status.status is CAPTURED

Your system receives this. It's a new status ID, so you process it. You look up your order using the intent_id. You see that payment is captured, so you mark the order as paid and start fulfillment.

That's it. Two webhooks, two status changes, and your order went from pending to paid.

Now imagine a network hiccup:

Same scenario, but this time your HTTP response times out when creating the payment. You don't get the intent ID back. Your code should do this:

  1. Mark the order state as UNKNOWN_RECONCILING
  2. Either retry the same POST request with the same idempotency key to get the intent ID, OR wait for a webhook (it will come, and it will contain the intent ID)
  3. Once you have the intent ID, store it with your order and continue as normal

The payment was already created on MoneyHash's side. Your timeout didn't stop it. The webhook will still arrive and tell you when it's done.


Edge Cases and What to Do

A webhook arrives for an order you don't have yet +
Store the entire webhook payload temporarily (save the intent_id, merchant_reference, and all webhook data). Return 200 OK to MoneyHash so it doesn't retry. Then, as soon as you create the order in your system, match it using the intent_id from the stored webhook and process it normally. This prevents you from losing payment updates due to timing issues.
Webhooks arrive out of order +
This can happen if one webhook is delayed. Your system should deduplicate and handle them correctly anyway. If you process them out of order, the order status might be temporarily wrong, but once you process all of them, the final status will be correct. Always use the latest status_id for the final state.
A webhook never arrives +
Your system should poll the payment status every 30 seconds or so. Call GET Intent with the intent_id to ask MoneyHash what the current status is. If it's still pending, keep polling. If it's terminal (payment completed or failed), process it as if you'd received the webhook.
You get a timeout on the create payment request +
Don't panic. Don't create a new intent with a new idempotency key. Either retry the same request with the same idempotency key, or wait for a webhook. If you're retrying, use the exact same idempotency key. You'll get the same intent back if it was already created.
Payment status is AUTHORIZE_ATTEMPT_FAILED but you're not sure if you can retry +
Always check intent.status. If it's UNPROCESSED, retries are allowed. If it's terminal, they're not. Don't guess based on the decline reason alone.

Common Mistakes to Avoid

Assuming HTTP response = final truth +
A timeout or error response doesn't mean the payment failed. It means you don't know what happened. Go get the status.
Processing the same webhook twice +
Always deduplicate by status ID before doing anything.
Failing an order on the first AUTHORIZE_ATTEMPT_FAILED +
Check the intent status first. If it's still open, retries are possible.
Creating a new intent after a timeout +
This can cause duplicate charges if the original request succeeded. Retry with the same idempotency key instead.
Ignoring AUTHORIZE_ATTEMPT_PENDING +
This is not a failure state. The customer is still in 3DS or a bank redirect. Wait for the next update.
Using active_transaction.status instead of payment_status.status for decisions +
The active transaction might be in a misleading state. Always use payment_status.status for classification.
Not handling orphan webhooks +
A webhook arriving before the order exists is rare but happens. Store it and process it later.
Only storing the latest webhook +
Store all webhooks so you can replay them in order if needed for debugging or reconciliation.
Polling only once then giving up +
Keep polling with exponential backoff until the payment reaches a terminal state or expires. Some payments take time.
Not having an idempotency key strategy +
Make it deterministic. For example, use your merchant reference + a timestamp or hash it into the key. This way you can safely retry.

How to Build This

Step 1: Idempotency

Every time you call create payment, include a unique, deterministic X-Idempotency-Key. Use something like your order ID or a hash of order + timestamp. Store this with your order so you can retry safely.

Step 2: Webhook Handler

Create an HTTP endpoint that receives webhooks. For each webhook:

  1. Verify the signature
  2. Check if you've already processed this status ID
  3. Use the intent_id from the webhook to look up your order
  4. If the order doesn't exist, store the webhook for later
  5. If it exists, classify by payment_status.status and update your order state
  6. Mark the status ID as processed
  7. Return 200 OK

Step 3: Classification Logic

After receiving a webhook, use the payment_status.status to decide what to do. Create a simple lookup table or if/else chain:

  • CAPTURED → mark paid, fulfill
  • AUTHORIZED → mark authorized, wait for capture
  • AUTHORIZE_ATTEMPT_PENDING → mark waiting, don't fail
  • AUTHORIZE_ATTEMPT_FAILED + intent UNPROCESSED → allow retry
  • AUTHORIZE_ATTEMPT_FAILED + intent terminal → fail the order
  • REFUNDED → process refund
  • VOIDED → update ledger

Step 4: Polling Fallback

Every few seconds, check for orders that haven't reached a terminal state yet. For each one, call GET Intent with the intent_id to refresh the status. If it's changed, rerun the classification logic. Keep polling until the payment completes or expires.


Quick Reference

StatusMeansWhat to do
CAPTUREDPayment succeededMark paid, fulfill order
AUTHORIZEDPayment verified, not capturedWait for capture or trigger it
REFUNDEDMoney sent backProcess refund in your ledger
VOIDEDAuthorization releasedUpdate ledger
AUTHORIZE_ATTEMPT_PENDINGCustomer in 3DS/redirectWait, do not fail
AUTHORIZE_ATTEMPT_FAILED (intent UNPROCESSED)One provider failed, retries possibleAllow customer/system retry
AUTHORIZE_ATTEMPT_FAILED (intent terminal)No more retriesFail the order
SituationWhat to do
HTTP response timeoutRetry same request with same idempotency key OR wait for webhook
Webhook arrives for unknown orderStore it temporarily, match using intent_id when order is created
Webhook arrives twiceDeduplicate by status ID, process only once
Webhook never arrivesPoll GET Intent with intent_id every 30s until status is terminal
Not sure if payment can be retriedCheck intent.status. If UNPROCESSED, retries are possible

Related Pages

For more details on specific topics:


Did this page help you?