# Idempotency

Networks fail. A request can time out after Multigate has already created a transfer. **Idempotency** lets you retry safely: you send the request again with the same key, Multigate recognises it, and money doesn't move a second time.

## The idempotency key: `uniqueRequestId`

Transfers, remittances and refunds take a `uniqueRequestId` in the body: your own ID for that one business operation. Opening an account and creating a receivable take one too. Conversions and bulk payouts use other keys (see [Per operation](#per-operation)).

When Multigate sees a `uniqueRequestId` it already has, it refuses the request with `400` and creates nothing:

```json
[
  { "errors": { "message": "Unique request ID already exist" } }
]
```

:::info[A duplicate answer doesn't return the original]
The refusal tells you the operation already exists; it doesn't repeat the original response. Fetch the original with the lookup in the [table below](#per-operation).
:::

## Step by step

<Steps>
<Step title="Generate the key once, when you create the payment">

Create the key when the payment is created **in your system**, not when you call the API. Use your own prefix plus a UUID, for example `acme_po_6f1c2e8a-9b0d-4f7e-8a21-5c6d7e8f9a0b`. Transfer, remittance, refund and receivable keys must be unique across **every** business on Multigate, so a short ID like `1001` may already be taken. The limit is 255 characters.

</Step>
<Step title="Store it before you call">

Save the key in the same database row as the payment, with a status such as `sending`, and commit **before** you send the request. If your process crashes mid-request, you still know what to look up.

</Step>
<Step title="Send it in the body">

Put it in the request body as `uniqueRequestId`, alongside the other fields.

</Step>
<Step title="Reuse it for every retry of this payment">

Retry code reads the key from your database. It never generates a new one. A retry with the same key is refused if the first attempt landed, instead of creating a second transfer.

</Step>
<Step title="Use a new key only for a new operation">

A new payment gets a new key. So does a new attempt after the earlier one ended `failed` or `rejected`: a failed transfer or account keeps its key, and reusing it is refused.

</Step>
</Steps>

## Per operation

| Operation | Key | A duplicate returns | Look it up with |
|---|---|---|---|
| [Create a transfer](api:createSingleTransfer), [Validate a transfer](api:validateTransfer) | `uniqueRequestId`, up to 255 characters, unique across **all of Multigate** (prefix it, for example `acme_po_…`) | `400` "Unique request ID already exist" | [Find transfers](api:listTransfers) `?uniqueRequestId=…` |
| [Initiate a remittance](api:createRemittance) | `uniqueRequestId` | `400` "Unique request ID already exist" | [Find transfers](api:listTransfers) `?uniqueRequestId=…` |
| [Refund an inflow](api:refundInflow) | `uniqueRequestId`, up to 255 characters | `400` "Unique request ID already exist". A second refund of the same inflow, even with a new ID, gets `409` "Refund currently processing" or "Refund completed already". | [Find transfers](api:listTransfers) `?uniqueRequestId=…` |
| [Commit a bulk transfer](api:commitBulkTransfer) | `previewId`: each preview can be committed once | `400` "Invalid transaction (general response for invalid transactions)", or "Unique request ID already exist" if two commits race | [Get a bulk transfer](api:getBulkTransferBatch), or [Find transfers](api:listTransfers) `?batchId=…` |
| [Create an account](api:createAccount) | `uniqueRequestId`, unique per account holder (you or one customer) | `400` "uniqueRequestId has already been used" | [List accounts](api:listAccounts): each account returns its `uniqueRequestId` |
| [Create a receivable](api:createReceivable) | `uniqueRequestId`, a UUID | `400` "A receivable with this uniqueRequestId already exists" | [List receivables](api:listReceivables) |
| [Lock a rate](api:lockFxRate) | The quote `id`: each quote can be converted once. Optional `reference`, unique across Multigate. | `409` "Rate already locked" or `409` "Duplicate reference" | [Get a conversion](api:getFxTrade) with the quote ID or your `reference` |

## Retrying safely

```mermaid
sequenceDiagram
    autonumber
    participant You as Your backend
    participant DB as Your database
    participant MG as Multigate
    You->>DB: Save payment with uniqueRequestId = acme_po_9f2a (status: sending)
    You->>MG: POST create transfer (uniqueRequestId acme_po_9f2a)
    MG--xYou: Timeout, no response
    You->>MG: GET find transfers ?uniqueRequestId=acme_po_9f2a
    alt Found
        MG-->>You: The transfer and its status
        You->>DB: Store transactionId and status
    else Not found, even after waiting a minute
        MG-->>You: Empty list
        You->>MG: POST create transfer again (same uniqueRequestId acme_po_9f2a)
        MG-->>You: 200 created, or "already exist" if the first one landed meanwhile
    end
```

1. **Timeout, `5xx` or dropped connection:** the outcome is unknown. Don't create a new key.
2. **Look it up** with the operation's lookup (table above). For a transfer, also ask for `<key>-1`, in case a large NGN transfer was split (see [Special cases](#special-cases)).
3. **Found:** the first request worked. Store its `transactionId` and track it.
4. **Not found:** wait at least a minute (the gateway gives up after 60 seconds, but the first attempt can still be running), then look again.
5. **Still not found:** retry with the **same** key.
6. **"Already exist" on the retry:** the first request landed in the meantime. Look it up again.

## A complete example

This Node.js example sends a payout and recovers from an unknown outcome. It uses `callMultigate` and `Outcome` from the [error handler](02-errors.md#a-complete-error-handler). `db` and `toTransferBody` stand for your own database and request mapping. `callMultigate` sends your API key from `MG_API_KEY`.

```javascript
import { randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
import { callMultigate, Outcome } from './multigate.js'; // the handler from the Errors page

const BASE_URL = process.env.MG_BASE_URL; // https://api-gateway-uat.mmacs.io/platform in the sandbox
const LOOKUP_DELAY_MS = 60_000;
const MAX_SENDS = 2; // the first attempt plus one retry with the same key

// Step 1 and 2: generate the key with the payout, and store it before calling Multigate.
export async function createPayout(db, details) {
  return db.payouts.insert({ ...details, uniqueRequestId: `acme_po_${randomUUID()}`, status: 'sending' });
}

// Steps 3 and 4: send it. A retry calls this again with the same stored payout, so the same key.
export async function sendPayout(db, payout, sends = 1) {
  const result = await callOrThrow(`${BASE_URL}/payment-service/api/v1/transfer/single-transfer`, {
    method: 'POST',
    body: toTransferBody(payout), // includes payout.uniqueRequestId
  });
  if (result.outcome === Outcome.SUCCESS) {
    return db.payouts.update(payout.id, { status: 'sent', transactionIds: [result.body.transactionId] });
  }
  if (result.outcome === Outcome.REFUSED && !isDuplicate(result)) {
    return db.payouts.update(payout.id, { status: 'refused', errors: result.errors }); // nothing happened
  }
  return recover(db, payout, sends); // unknown outcome, or "already exist"
}

// After a timeout, a 5xx or a duplicate: look it up, wait, look again, then retry with the same key.
async function recover(db, payout, sends) {
  let transfers = await findTransfers(payout.uniqueRequestId);
  if (transfers.length === 0) {
    await sleep(LOOKUP_DELAY_MS); // the first attempt may still be running
    transfers = await findTransfers(payout.uniqueRequestId);
  }
  if (transfers.length > 0) {
    return db.payouts.update(payout.id, { status: 'sent', transactionIds: transfers.map((t) => t.transactionId) });
  }
  if (sends >= MAX_SENDS) {
    return db.payouts.update(payout.id, { status: 'needs_review' }); // a person checks it, or a job retries later
  }
  return sendPayout(db, payout, sends + 1);
}

async function findTransfers(uniqueRequestId) {
  const url = new URL(`${BASE_URL}/payment-service/api/v1/transaction/report`);
  // "-1" is the first part of a split NGN transfer. Your original key isn't stored when a transfer is split.
  url.searchParams.set('uniqueRequestId', `${uniqueRequestId},${uniqueRequestId}-1`);
  const result = await callOrThrow(url.toString(), {});
  if (result.outcome !== Outcome.SUCCESS) {
    throw new Error(`Transfer lookup failed with HTTP ${result.status}; try again later`);
  }
  return result.body.data;
}

async function callOrThrow(url, options) {
  const result = await callMultigate(url, options);
  if (result.outcome === Outcome.CHECK_API_KEY) {
    // A 401 means nothing happened. The payout stays 'sending', so it is sent again once the key is fixed.
    throw new Error('Multigate refused the API key; check MG_API_KEY');
  }
  return result;
}

function isDuplicate(result) {
  return result.errors.some((error) => error.message === 'Unique request ID already exist');
}
```

If a duplicate is refused but the lookup finds nothing, another business on Multigate used that key first: nothing of yours was created. The example marks it `needs_review`; send the payout again with a new key.

## When a `4xx` comes back

Any other `4xx` (insufficient balance, invalid account and so on) means the request was refused and **nothing was created**. Fix the problem and send the corrected request. You can keep the same `uniqueRequestId` or use a new one. [Errors](02-errors.md#what-to-do-for-each-status) lists the few `4xx` responses where something did happen.

## Special cases

- **Split NGN transfers.** A large NGN transfer is split into parts. Each part gets your key plus `-1`, `-2`, …. Look up the parts with `?uniqueRequestId=acme_po_9f2a-1,acme_po_9f2a-2`. Your original key stays reserved, so a retry with it is refused; never change the amount on a retry.
- **Validate a transfer** rejects a key that was already used, but doesn't reserve it. Validate with the same `uniqueRequestId` you will create the transfer with, then call create once.
- **A failed operation keeps its key.** A transfer or account that ended `failed` still holds its `uniqueRequestId`. Send the new attempt with a new key.

:::warning[Send one request at a time per key]
[Create a transfer](api:createSingleTransfer) is safe against parallel duplicates: when two requests with the same `uniqueRequestId` arrive at the same moment, one creates the transfer and the other gets `400` "Unique request ID already exist". The other operations ([Initiate a remittance](api:createRemittance), [Refund an inflow](api:refundInflow)) still check for a duplicate with a lookup before they save, designed for sequential retries: two identical requests at the same moment can both be accepted. For those, retry only after the previous attempt has answered or timed out, and make sure only one worker handles each payment (for example by locking its database row).
:::

## Webhooks have their own key

Webhooks you receive carry an `Idempotency-Key` header. That one protects **you** from processing an event twice. See [Webhooks overview](../03-webhooks-and-emails/01-overview.md#handle-events-safely).
