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 unique 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 unique it already has, it refuses the request with 400 and creates nothing:
[
{ "errors": { "message": "Unique request ID already exist" } }
]
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
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_. 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.
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.
Put it in the request body as unique, alongside the other fields.
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.
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 | unique, up to 255 characters, unique across all of Multigate (prefix it, for example acme_po_…) |
400 "Unique request ID already exist" |
Find transfers ?unique |
| Initiate a remittance | unique |
400 "Unique request ID already exist" |
Find transfers ?unique |
| Refund an inflow | unique, 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 ?unique |
| 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 | unique, unique per account holder (you or one customer) |
400 "uniqueRequestId has already been used" |
List accounts: each account returns its unique |
| Create a receivable | unique, 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
- Timeout,
5xxor dropped connection: the outcome is unknown. Don't create a new key. - 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). - Found: the first request worked. Store its
transactionIdand track it. - 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.
- Still not found: retry with the same key.
- "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 to 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 unique 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?unique. Your original key stays reserved, so a retry with it is refused; never change the amount on a retry.Request Id=acme_ po_ 9f2a-1,acme_ po_ 9f2a-2 - Validate a transfer rejects a key that was already used, but doesn't reserve it. Validate with the same
uniqueyou will create the transfer with, then call create once.Request Id - A failed operation keeps its key. A transfer or account that ended
failedstill holds itsunique. Send the new attempt with a new key.Request Id
Create a transfer is safe against parallel duplicates: when two requests with the same unique 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.