General · Platform basics
Idempotency
Retry requests safely with idempotency keys in Fincore, Direct Debit, Gift Cards and Billpay.
A network timeout does not tell you whether your request ran. An idempotency key lets you retry without paying, charging or buying twice. Each product implements it differently.
Compare products
| Product | Field | Required | Operations | Key format | Expiry |
|---|---|---|---|---|---|
| Fincore | Idempotency-Key header |
No, but always send it in production | Create Money Out | UUID v5 | 24 hours |
| Direct Debit | Idempotency-Key header |
No | Create charge | UUID | 24 hours |
| Gift Cards | idempotency_key in the body |
Yes | Purchase a gift card | String | Not documented |
| Billpay (v1) | idempotency_key |
Optional on payments and top-ups, required on verify payment | Create payment, Create top-up, Verify payment | String | Not documented |
What happens when you retry
| Scenario | Fincore | Direct Debit | Gift Cards |
|---|---|---|---|
| No key | Processed normally, without retry protection | Processed normally | Rejected. The key is required. |
| Invalid key format | Rejected (not a UUID) | 400 Bad Request |
— |
| Same key, same body | Original response returned | Original charge returned | Rejected with DUPLICATED_PAYMENT_ERROR |
| Same key, different body | 409 conflict: the key does not match the body |
422 Unprocessable Entity |
Rejected with DUPLICATED_PAYMENT_ERROR |
| Same key while the first request is still running | 409 conflict |
409 Conflict. Wait and retry. |
— |
| Same key after the first request failed | The same error is returned until the key expires | — | — |
| Same key after it expires | Processed as a new request | Creates a new charge | — |
Gift Cards does not return the original purchase on a repeated key. It rejects the request instead.
Fincore only applies the key to Create Money Out. Other Fincore operations, including Penny Validation, ignore the header.
Fincore: generate the key from the request
Fincore does not accept any UUID. It recomputes the key from the request it receives and rejects the request with 409 if your key is different. Compute it like this:
Idempotency-Key = UUIDv5(NAMESPACE, client_id + "money_out" + SHA256_hex(canonical_json(body)))| Input | Rule |
|---|---|
NAMESPACE |
A UUID for each environment. Monato gives it to you during onboarding, one for staging and another for production. It keeps keys from different environments apart. |
client_id |
The client_id in the request body. |
"money_out" |
The operation name, exactly as written. |
SHA256_hex(...) |
SHA-256 of the canonical body, as 64 lowercase hexadecimal characters. |
Join client_id, money_out and the hash with no separators.
Build canonical_json(body) from the same body you send:
- Sort object keys alphabetically at every level.
- Use no whitespace:
,between items and:between key and value. - Escape every non-ASCII character as
\uXXXXwith lowercase hex (for exampleñbecomes\u00f1). JavaScript’sJSON.stringifydoes not do this, so escape them yourself. - Include only the fields you send with a value. Empty fields, such as
"client_reference": "", are dropped before the hash is computed. - Keep strings as strings, for example
"amount": "250.00".
import hashlib, json, uuid
NAMESPACE = uuid.UUID("00000000-0000-0000-0000-000000000000") # use the namespace Monato gives you
def idempotency_key(body: dict) -> str:
canonical = json.dumps(body, sort_keys=True, separators=(",", ":"))
body_hash = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
return str(uuid.uuid5(NAMESPACE, body["client_id"] + "money_out" + body_hash))import { createHash } from 'node:crypto';
const NAMESPACE = '00000000-0000-0000-0000-000000000000'; // use the namespace Monato gives you
function canonicalJson(value) {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
if (value && typeof value === 'object') {
return `{${Object.keys(value).sort().map((k) => `${canonicalJson(k)}:${canonicalJson(value[k])}`).join(',')}}`;
}
return JSON.stringify(value).replace(/[\u0080-\uffff]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
}
function uuidV5(namespace, name) {
const ns = Buffer.from(namespace.replace(/-/g, ''), 'hex');
const hash = createHash('sha1').update(Buffer.concat([ns, Buffer.from(name, 'utf8')])).digest();
hash[6] = (hash[6] & 0x0f) | 0x50;
hash[8] = (hash[8] & 0x3f) | 0x80;
const hex = hash.subarray(0, 16).toString('hex');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
export function idempotencyKey(body) {
const bodyHash = createHash('sha256').update(canonicalJson(body), 'utf8').digest('hex');
return uuidV5(NAMESPACE, body.client_id + 'money_out' + bodyHash);
}Check your implementation with this test vector. With the example namespace 00000000-0000-0000-0000-000000000000, this body must produce 9b382639-c68a-563c-9f3a-bda225eb2512:
{
"client_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"source_instrument_id": "709448c3-7cbf-454d-a87e-feb23801269a",
"destination_instrument_id": "dd7f8d89-94dd-43ca-871b-720fde378b52",
"transaction_request": {
"external_reference": "1234567",
"description": "Pago a proveedor Peña",
"amount": "250.00",
"currency": "MXN"
}
}Idempotency-Key: 9b382639-c68a-563c-9f3a-bda225eb2512Fincore keeps the result for 24 hours, including failures. If the first request failed, for example because of insufficient balance, retrying with the same key returns the same error. After you fix the cause, change the body (for example external_reference), which gives a new key.
A conflict returns HTTP 409 with UNIQUE_VIOLATION. The key does not match the body, or the namespace is wrong:
{
"code": 6,
"message": "API Error",
"details": [
{
"reason": "UNIQUE_VIOLATION",
"domain": "CORE",
"metadata": {
"error_detail": "Idempotency key does not match the request payload or the namespace may be incorrect",
"http_code": "409"
}
}
]
}The first request with the same key is still running. Wait and retry with the same key and body:
{
"code": 6,
"message": "API Error",
"details": [
{
"reason": "UNIQUE_VIOLATION",
"domain": "CORE",
"metadata": {
"error_detail": "Operation money_out in progress",
"http_code": "409"
}
}
]
}Direct Debit: send a UUID
Send any UUID in the Idempotency-Key header of POST /charges. Keys are scoped to your organization.
curl -X POST https://stg.directdebit.monato.com/charges \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-H "Idempotency-Key: 00000000-0000-4000-8000-000000000000" \
-d '{ ... }'Lottery
Lottery has no idempotency key, but each ticket purchase must use a unique payer_reference.