Idempotency
How to retry requests safely: idempotencyKey on transfers and other duplicate-protection mechanisms.
Network failures make retries unavoidable. V3 Custody protects you from accidental duplicates. First and foremost, transfers accept an idempotencyKey, so retrying a request never duplicates a payment.
idempotencyKey on transfers
POST /api/v1/vaults/{code}/transfers accepts an idempotencyKey. Retrying the request with the same key returns the transfer that was already created and doesn't repeat the broadcast: the response contains the existing operation (replay).
# the first request creates the transfer; a retry with the same key returns the same one
curl -X POST "[BASE_URL]/api/v1/vaults/acme-otc/transfers" \
-H "Authorization: Bearer $V3_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"fromAddressId": "cmr3f41z20001psp7phmyapw3",
"toAddress": "TQ2m...p4rX",
"token": "usdt",
"amount": "1350.00",
"idempotencyKey": "payout-2026-03-14-001"
}'
const body = {
fromAddressId: "cmr3f41z20001psp7phmyapw3",
toAddress: "TQ2m...p4rX",
token: "usdt",
amount: "1350.00",
idempotencyKey: "payout-2026-03-14-001",
};
// safe to retry: same key → same transfer
await fetch("[BASE_URL]/api/v1/vaults/acme-otc/transfers", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.V3_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
body = {
"fromAddressId": "cmr3f41z20001psp7phmyapw3",
"toAddress": "TQ2m...p4rX",
"token": "usdt",
"amount": "1350.00",
"idempotencyKey": "payout-2026-03-14-001",
}
# safe to retry: same key → same transfer
requests.post(
"[BASE_URL]/api/v1/vaults/acme-otc/transfers",
headers={"Authorization": f"Bearer {os.environ['V3_TOKEN']}"},
json=body,
)
The key is stored together with the created transfer: a second request with the same key short-circuits to the existing operation instead of creating and broadcasting it again.
Recommendations
- Always pass an
idempotencyKeyon transfers. - Make the key a stable identifier of the business operation (an invoice or payout ID), not a random value generated on each retry. Otherwise, a retry creates a new payment.
- One key = one payment; a new payment needs a new key.
Other safe-retry mechanisms
idempotencyKey applies to transfers. Other write operations have their own protection against duplicates and race conditions:
| Operation | Mechanism | Behavior on retry or race |
|---|---|---|
transfer (POST /transfers) | idempotencyKey | same key → same operation (replay) |
address grant (POST /shares) | idempotent per subject | updates or reactivates, never duplicates |
| final approval of a request | optimistic lock | target changed → REJECTED (stale) |
| address creation | pathIndex selection retry | concurrent requests can't claim the same path |
For details on the approval request optimistic lock, see Approvals.
Common mistakes
| Symptom | Cause |
|---|---|
| a retry duplicated the payment | the key was random on each retry instead of stable |
| "same key, different payment" | the key was reused for a new operation; one key = one payment |
| a retry without a key was duplicated | idempotencyKey wasn't passed |
request REJECTED (stale) | the target changed after the request was created; this is the optimistic lock, not a network failure |
| grant "created twice" | it wasn't: POST /shares is idempotent, so the second call updated the same grant |