Skip to content
MultigateAPI
Support Staff

Concepts

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).

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

[
  { "errors": { "message": "Unique request ID already exist" } }
]
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.

Step by step

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.

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.

Send it in the body

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

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.

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.

Per operation

Operation Key A duplicate returns Look it up with
Create a transfer, Validate a transfer uniqueRequestId, up to 255 characters, unique across all of Multigate (prefix it, for example acme_po_…) 400 "Unique request ID already exist" Find transfers ?uniqueRequestId=…
Initiate a remittance uniqueRequestId 400 "Unique request ID already exist" Find transfers ?uniqueRequestId=…
Refund an inflow 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 ?uniqueRequestId=…
Commit a bulk transfer 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, or Find transfers ?batchId=…
Create an account uniqueRequestId, unique per account holder (you or one customer) 400 "uniqueRequestId has already been used" List accounts: each account returns its uniqueRequestId
Create a receivable uniqueRequestId, a UUID 400 "A receivable with this uniqueRequestId already exists" List receivables
Lock a rate 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 with the quote ID or your reference

Retrying safely

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).
  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. db and toTransferBody stand for your own database and request mapping. callMultigate sends your API key from MG_API_KEY.

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 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.
Send one request at a time per key

Create a transfer 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, Refund an inflow) 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.