Guarantees

Idempotency

Networks drop, jobs retry, admins double-click. An idempotency key turns any of that into exactly one payment.

How it works

Send an Idempotency-Key with a create request. FomoPay stores the key with the payment, unique per project, forever. When the same key comes back:

RequestResult
Same key, same requestThe original payment, HTTP 200, idempotent_replayed: true and the header Idempotent-Replayed: true. Nothing new is created or paid — whatever status the original has now is what you see.
Same key, different request409 DUPLICATE_PAYMENT with details.payment_id of the original. Nothing is created.
New keyA new payment.
// Your job runner may run this twice. FomoPay pays once.
await fp.rewards.create({
  recipient: "@lyra",
  amount: "500",
  asset: "USDG",
  reason: "FomoRun #42",
  idempotencyKey: "FOMORUN_42_WINNER",
});
the second call
HTTP/1.1 200 OK
Idempotent-Replayed: true

{ "object": "payment", "payment_id": "pay_8k2m4q7r9t1v3x5z6b0c", "status": "CONFIRMED", "idempotent_replayed": true, … }
same key, amount changed
HTTP/1.1 409 Conflict

{
  "error": {
    "code": "DUPLICATE_PAYMENT",
    "message": "This idempotency key was already used for a different payment.",
    "request_id": "req_…",
    "details": { "payment_id": "pay_8k2m4q7r9t1v3x5z6b0c" }
  }
}

What counts as “the same request”

FomoPay hashes a normalized form of the request: the recipient handle (case-insensitive, with or without @), the amount as a number ("100" equals "100.0"), the asset, memo, reason, metadata, vault, visibility and source. Header order, JSON key order and whitespace don't matter. Risk acknowledgements (confirmations) are not part of it, so you can retry a 428 CONFIRMATION_REQUIRED with the same key.

Where keys apply

EndpointKeyNotes
POST /v1/rewardsRequiredHeader or idempotency_key in the body (if both, they must match). 1–255 printable characters, no spaces.
POST /v1/paymentsRecommendedHeader or body field. The SDK always sends one.
POST /v1/payments/bulkRecommendedSame key + same rows → the same batch.

A request that fails validation or is refused (unknown recipient, vault short of funds, over a limit) creates nothing, so the key stays unused — fix the cause and retry with the same key.

Choosing keys

  • Derive the key from the thing you're paying for, not from the attempt: FOMORUN_42_WINNER, BOUNTY_8812, REFERRALS_2026_W41. Then every code path that tries to pay round 42's winner — retries, a second worker, a redeploy mid-payout — converges on one payment.
  • Never use a random key per attempt for business events. It protects one HTTP call, not your process.
  • Keys are permanent per project. A key used once is bound to that payment for good — it can't be reused for a different payout later.

The SDK and retries

Text
// What the SDK does on a dropped connection (you don't write this):
// attempt 1  POST /v1/rewards  Idempotency-Key: FOMORUN_42_WINNER   → socket reset (did it land?)
// attempt 2  POST /v1/rewards  Idempotency-Key: FOMORUN_42_WINNER   → 200, idempotent_replayed: true
//            same payment_id — paid exactly once

The SDK retries 429, 5xx, timeouts and network errors with exponential backoff (500 ms, 1 s, 2 s … capped at 8 s, ±25 % jitter, honouring Retry-After) and always reuses the same key. send, payments.create and bulk.* generate a key if you don't pass one, so the SDK's own retries are always safe; requests without a key (like creating a webhook) are only retried on 429, which the API rejects before doing any work.

Auto-generated keys don't survive a restart

If your process crashes between “call FomoPay” and “record that I called FomoPay”, a fresh process will generate a fresh key. For anything that matters, pass your own deterministic idempotencyKey.