Idempotency

Idempotency lets you safely retry a request without performing the operation twice. If a request is interrupted — a network timeout, a dropped connection — you often can't tell whether the server processed it before the failure. Sending an idempotency key lets the server recognise the repeat and refuse to run the operation a second time.

How to use it

Send an optional Idempotency-Key header on any write request:

curl -X POST https://business.mamopay.com/manage_api/v1/payouts \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 0f8c3a52-6d1e-4b7a-9c44-2f1d5e8b7a10" \
  -d '{ ... }'

A key can only be used once within a retention window (about 7 days) per account and per endpoint. The first request with a given key runs normally. If you send the same key again to the same endpoint, the server rejects the repeat with HTTP 409 Conflict and error code IDEMPOTENCY_KEY_NOT_UNIQUE — it does not create a second operation. After the retention window the stored key is pruned, so the same key becomes reusable and the operation will run again.

Two details worth knowing:

  • A duplicate key is rejected, not replayed. The 409 tells you the original request already succeeded or is still in flight; it does not return the original response body. To retrieve the outcome of the first request, reconcile it (see Reconcile before you retry).
  • A failed first attempt releases the key. If the first request returns an error, the key is freed so you can retry it. This means a 409 only ever happens when the first attempt succeeded or has not yet finished.

Duplicate-key response

HTTP/1.1 409 Conflict
Content-Type: application/json

{
  "messages": ["A request with this Idempotency-Key has already been processed or is still in progress."],
  "error_code": "IDEMPOTENCY_KEY_NOT_UNIQUE"
}

The original response body is not returned again. To get the resource created by the first request, look it up with the endpoint's list or detail API (see Reconcile before you retry below).

Scope and retention

  • Scope: a key is unique per account, HTTP method and request path. The same key can be used on a different endpoint, and the path includes any identifier in it, so PATCH /links/MB-LINK-A and PATCH /links/MB-LINK-B don't share keys. A deprecated alias path, such as /disbursements for /payouts, is a separate path too.
  • Retention: keys are kept for at least 7 days. After that, the same key is accepted again as a new request.

Which endpoints support it

The Idempotency-Key header is accepted on every write endpoint of the Business API. Behaviour is identical across them — same key twice returns 409 IDEMPOTENCY_KEY_NOT_UNIQUE — with one exception for international payouts (see the note below the table).

AreaEndpointsNotes
PayoutsPOST /payouts
POST /disbursements (deprecated alias)
POST /international_payouts
Recommended. A payout moves real money. For international payouts, see the note below.
PaymentsPOST /payments
POST /payments/{id}/refunds
POST /payments/{id}/captures
POST /payments/{id}/reverses
POST /payments/shopify
The same routes under the deprecated /charges alias
Recommended for payments, refunds and captures, which move money.
Payment linksPOST /links
PATCH / PUT /links/{id}
DELETE /links/{id}
See the warning below — external_id is not an idempotency key.
InvoicesPOST /invoices
SubscriptionsPOST /subscriptions
DELETE /subscriptions/{id}
PUT /subscriptions/{id}/cancel
DELETE /subscriptions/{id}/subscribers/{subscriber_id}
PATCH /subscriptions/{id}/subscribers/{subscriber_id}/change_customer_subscription
RecipientsPOST /recipients
PATCH / PUT /recipients/{id}
DELETE /recipients/{id}
The same routes under /accounts/recipients
WebhooksPOST /webhooks
PATCH / PUT /webhooks/{id}
DELETE /webhooks/{id}
CardsPOST /vcc_cards
POST /vcc_cards/{id}/show
POST /cards/{id}/reroutes
POST /cards/authorize_payment
Partner cardsPOST /partner_cards
PATCH / PUT /partner_cards/{id}
PATCH /partner_cards/{id}/cancel
ExpensesPOST /expense_transactions (search)
PATCH / PUT /expense_transactions/{id}
POST /expense_transactions/{id}/receipts
DELETE /expense_transactions/{id}/receipts/{receipt_id}
POST /unmatched_transaction_receipts
PATCH / PUT /expenses/{id}
POST /expense_transactions is a search, but it follows the same rule: a second search with the same key returns 409. Don't send the header on searches.
Wallet tokensPOST /apple_pay/tokens
POST /google_pay/tokens
MarketplacePOST /marketplace_connections

Note — international payouts have a second dedup layer. Besides the global idempotency check, POST /international_payouts also matches on the payout it already stored for a given key. While the idempotency row still exists the global 409 shadows this, so you see the same behaviour as every other endpoint. But once that row has been pruned (after the retention window above) while the payout itself persists, a retry with the same key can take a different path: it may replay the existing payout (returning 201 Created with that payout's body) or, on a race against the database's uniqueness constraint, return 422 with error code RECORD_NOT_UNIQUE — instead of 409. Reconcile before retrying (see below) rather than relying on the exact status. Previously the header was required on this endpoint and a retry always returned the original payout instead of a 409.

The key is always optional. For money-moving operations (payouts, captures, refunds) sending one is strongly recommended so an interrupted call can be retried safely.

external_id on POST /links is not an idempotency key

When you create a payment link you can pass an optional external_id. This is stored as plain metadata only — it has no uniqueness constraint and no deduplication behaviour. Two POST /links calls with the same external_id create two independent links (and therefore two independent charges), each with its own id/slug.

If you want link creation to be idempotent, send an Idempotency-Key header. Do not rely on external_id to prevent duplicates.

# These two calls create TWO separate links, not one.
curl -X POST .../manage_api/v1/links -d '{ "external_id": "ORDER-123", ... }'
curl -X POST .../manage_api/v1/links -d '{ "external_id": "ORDER-123", ... }'

# Use an Idempotency-Key to make the retry safe:
curl -X POST .../manage_api/v1/links \
  -H "Idempotency-Key: link-ORDER-123" \
  -d '{ "external_id": "ORDER-123", ... }'

Recommended integration

  1. Generate a new key, such as a UUID v4, for each logical operation (for example "pay seller X for order Y"), and store it with the operation before you call Mamo.
  2. On a timeout or connection error, retry with the same key.
  3. Handle the response:
    • 2xx: the operation was processed.
    • 409 IDEMPOTENCY_KEY_NOT_UNIQUE: the operation was already processed, or is still in progress. Don't retry with a new key — follow Reconcile before you retry below.
    • Any other error (4xx or 5xx): nothing was processed. Fix the request if needed and retry with the same key.

Reconcile before you retry

Because a duplicate key returns 409 rather than the original response, and because you may not have sent a key at all, the safe recovery pattern after a timeout or ambiguous failure is reconcile first, then retry only if nothing was created:

  1. Look up whether the operation already exists using a GET by id or a list with filters:
    • Payouts: fetch the payout by id, or list your payouts and match your own reference.
    • Captures / refunds: fetch the payment by identifier and inspect its captured/refunded state before issuing another one.
    • Links: list your links and match on the external_id you sent (it is stored on the link), so you can tell whether a link already exists.
  2. If it exists, use that record — do not resend.
  3. If it does not exist, retry. Reuse the same Idempotency-Key for the same logical operation so a race still collapses to a single result; use a new key only for a genuinely new operation.

Choosing a good key

  • Derive it from something you already hold — an order id, invoice number, or a UUID from your own system — so the same logical operation always maps to the same key.
  • Reuse the same key when retrying the same operation. Use a new key for a genuinely new one.
  • If you deliberately want a retry to count as a new operation, change the key — for example by appending an attempt counter: payout-INV-9876-attempt-2.