Idempotency
Distributed systems lose connections. A request that creates a payment might succeed on our side but fail to deliver the response to your client, leaving you unsure whether to retry. Idempotency keys solve this: send the same key with the retry, and we return the original result instead of doing the work twice.
This page covers what's idempotent, what's not, and how to use the keys correctly.
TL;DR
For any mutating request (POST/PATCH/DELETE), include:
Idempotency-Key: <your-unique-key>
If you send the same key twice within 24 hours, you get the same response both times. If the parameters change between calls with the same key, you get 409 idempotency_mismatch.
When to use idempotency keys
Always for:
- Creating payments, refunds, invoices, subscriptions
- Anything that moves money
- Anything you wouldn't want to happen twice
Optional for:
- Read-only requests (
GET) — they're naturally idempotent - Soft updates where double-execution is harmless
Don't use idempotency keys for GET. We ignore the header.
How keys work
When you POST with an idempotency key, Plugipay:
- First request: Processes normally, stores
(key, request hash, response)in our cache. - Subsequent requests with the same key:
- Same parameters → we return the cached response without re-running the operation.
- Different parameters → we return
409 idempotency_mismatchwith the offending field.
The cache is per-workspace, scoped to the key value. Two different workspaces can use the same key string without conflict.
How to generate keys
A good idempotency key is:
- Unique per intended operation. Each "I want to make a refund happen" gets one key.
- Stable across retries. If I retry the same operation, I use the same key.
- Generated client-side. Don't ask the server for a key.
Patterns we've seen work well:
Per-business-operation
For business operations that map cleanly to a single Plugipay action:
refund-{paymentId}-{caseTicketNumber}
checkout-session-for-order-{orderId}
charge-monthly-{customerId}-{billingPeriod}
The key is derived from your business identifiers, so retrying the operation produces the same key.
Per-API-request
For automation scripts that fire many requests:
script-{scriptName}-{runDate}-{stepIndex}
job-payouts-2026-05-12-step-3
Random UUIDs
If you don't have stable identifiers and just want safe retries:
import { randomUUID } from 'node:crypto';
const key = randomUUID();
// Save it locally before sending
db.savePendingRequest(key, params);
// Send (may retry with the same key on failure)
await plugipay.refunds.create(params, { idempotencyKey: key });
The trick: save the key locally before sending, so a process crash mid-request doesn't lose it. On restart, re-load and re-send with the same key.
What's compared on retry
The cache key is just the Idempotency-Key value. The "are the parameters the same?" check compares:
- HTTP method (POST/PATCH/DELETE)
- Path (
/v1/refunds) - Request body (canonical JSON form)
If any differ, you get a 409 with an explanation of what changed.
Headers other than method/path don't count. Adding or removing X-Plugipay-On-Behalf-Of between requests with the same key still hits the cache — we don't make this part of the dedup signature. Use distinct keys if you're routing to different merchants.
What happens during the first request
If you fire two requests with the same key at the same time (before the first one completes), the second one waits. Plugipay serializes idempotent operations per-key — only one body executes; the other gets the same response when it's done.
Timeout for this wait: 60 seconds. If the first request takes longer, the second one returns 409 idempotency_in_progress and you should retry the retry.
Cache duration
Idempotency entries live for 24 hours after the original request. After that:
- The cache entry expires.
- A new request with the same key starts fresh — it'll execute normally.
24 hours covers:
- Immediate retries (most common case)
- Re-runs of a daily job
- Customer-initiated retries within a session
For longer windows (e.g., monthly job idempotency), make the key encode the time bucket: monthly-charge-{customerId}-2026-05.
Worked examples
Safe retry with the SDK
const result = await retry(async () => {
return plugipay.refunds.create(
{ paymentId: 'pay_xxx', reason: 'duplicate' },
{ idempotencyKey: `refund-pay_xxx-case-12345` }
);
}, { retries: 3, backoff: 'exponential' });
If the first attempt fails with a network error, the retry sends the same key. Plugipay either:
- Already processed it: returns the cached response.
- Didn't get it: processes now.
Either way, exactly one refund happens.
Bulk operations from a script
plugipay payments list --status succeeded --since 1h --output ids | \
while read -r payment_id; do
plugipay refunds create \
--payment-id "$payment_id" \
--reason fraudulent \
--idempotency-key "incident-2026-05-12-${payment_id}"
done
Re-running this script after a partial failure doesn't double-refund — the keys are deterministic from payment_id.
Subscription monthly charges
If you bill subscriptions yourself rather than letting Plugipay drive it:
for customer in customers_to_charge:
plugipay.payments.create(
customer_id=customer.id,
amount=customer.plan.amount,
currency=customer.plan.currency,
idempotency_key=f"monthly-charge-{customer.id}-2026-05"
)
Running this twice in May doesn't double-charge.
Common errors
409 idempotency_mismatch
You re-used a key with different parameters:
{
"error": {
"code": "IDEMPOTENCY_MISMATCH",
"message": "Request parameters don't match the original",
"field": "amount"
}
}
Fix: use a new key for the new operation, or align the parameters with the original.
409 idempotency_in_progress
You fired two concurrent requests with the same key, and the first one is still running. Wait briefly and retry, or accept that the original will succeed.
400 invalid_idempotency_key
Your key is malformed:
- Empty string
- Longer than 255 characters
- Contains control characters or null bytes
Use ASCII-printable strings up to 255 chars.
Idempotency vs duplicate detection
Idempotency is opt-in per request. Without a key, we don't deduplicate — calling POST /v1/refunds twice creates two refunds.
This is deliberate. Some operations should not be deduplicated (e.g., creating two genuinely different refunds on the same payment for partial amounts). Idempotency keys let you opt in case-by-case.
Idempotency vs caching
Idempotency is for mutations. Caching (i.e., returning fast for repeated reads) is something else — we don't apply request-level caching to GETs; if you want to cache reads, do it client-side (e.g., HTTP cache headers, application-level memoization).
Best practices
- Generate keys client-side and persist them before sending. A key generated in-flight and lost on crash defeats the purpose.
- Use deterministic keys when possible. Derived from business identifiers, they make retries automatic. Random keys only work when paired with persistence.
- Don't reuse keys across distinct operations. Each "I want this thing to happen" is one key.
- Use idempotency keys for every state-changing batch operation in scripts. It's the difference between "safe to re-run" and "needs careful manual cleanup."
Next
- Errors — how to handle the response codes idempotency interacts with.
- Authentication — idempotency keys are part of the signed request.
- Rate limits — idempotent retries don't count against your limit if they hit cache.