Idempotency
Idempotency in BillerAPI has two sides. On your outbound API calls to BillerAPI, send an Idempotency-Key header so retries do not create duplicates. On inbound webhooks from BillerAPI to your server, dedupe on the envelope’s id so duplicate deliveries do not double-process.
Outbound: Idempotency-Key on POST
Include an Idempotency-Key header with a unique value (UUID v4 recommended) on POST requests. If the same key is sent again within 24 hours, the API returns the original response instead of processing the request a second time.
This means you can safely retry failed requests due to network timeouts or connection errors without worrying about duplicate side effects.
Generating Idempotency Keys
Use a UUID v4 for each unique operation. Do not reuse keys across different operations — each distinct action should have its own key.
Generate a UUID v4
import { randomUUID } from 'crypto';
const idempotencyKey = randomUUID();
// e.g., '550e8400-e29b-41d4-a716-446655440000'Supported Endpoints
The following POST endpoints support idempotency keys:
| Endpoint | Description |
|---|---|
| POST /link/token/create | Create a new link token for the Connect flow. |
| POST /bills/sync/trigger | Trigger a bill sync for a connected account. |
| POST /request-to-link/create | Create a biller-initiated link request. |
Full Example
Here is a complete example showing how to use an idempotency key with retry logic:
Idempotent request with retry
import { randomUUID } from 'crypto';
async function createLinkToken(userId, billerId) {
// Generate key once — reuse on retries for the SAME operation
const idempotencyKey = randomUUID();
for (let attempt = 0; attempt < 3; attempt++) {
try {
const response = await fetch(
'https://sandbox.api.billerapi.com/link/token/create',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-ID': process.env.BILLERAPI_CLIENT_ID,
'X-Client-Secret': process.env.BILLERAPI_CLIENT_SECRET,
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
client_id: process.env.BILLERAPI_CLIENT_ID,
client_user_id: userId,
biller_id: billerId,
consents: ['bills'],
}),
}
);
if (response.ok) {
return await response.json();
}
// Don't retry client errors (except 429)
if (response.status < 500 && response.status !== 429) {
throw new Error(`Client error: ${response.status}`);
}
} catch (err) {
if (attempt === 2) throw err;
}
// Exponential backoff before retry
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
}
}Inbound: dedupe webhooks by event.id
Webhook delivery is at-least-once. Network failures, your endpoint returning a non-2xx, or an SQS visibility-timeout redelivery on our side can all cause the same webhook to arrive twice. Every webhook envelope carries a unique id (prefixed evt_); use it as your dedupe key.
Persist the id atomically with whatever side effect your handler performs (database row, downstream API call, queue enqueue). On every delivery, check if you have already seen the id: if yes, return 200 OK without re-processing; if no, process and record the id in the same transaction. Keep dedupe records for at least 7 days.
Webhook handler with envelope dedupe
// Pseudo-code: replace store calls with your DB / Redis / etc.
async function handleWebhook(req, res) {
const envelope = req.body; // already signature-verified upstream
// Check for prior delivery with the same envelope id.
const seen = await store.get(`webhook:${envelope.id}`);
if (seen) {
// Idempotent replay — return 200 so we are not retried.
return res.status(200).end();
}
// Atomic: persist the dedupe record + the projection in one transaction
// so a crash between them does not leak duplicate side effects.
await store.transaction(async (tx) => {
await tx.put(`webhook:${envelope.id}`, { ts: Date.now() }, { ttl: 7 * 86400 });
await applyProjection(tx, envelope);
});
res.status(200).end();
}BillerAPI keeps internal dedupe records for 7 days; a redelivered event.id outside that window is rare but possible. Matching the 7-day TTL on your side keeps both ends consistent.
Important Notes
- Idempotency keys expire after 24 hours. After that, the same key will be treated as a new request.
- Use the same key for retries of the same operation. Generate a new key for each distinct operation.
- If you send the same key with a different request body, the API returns a
409 Conflicterror. - GET, PUT, and DELETE requests are naturally idempotent and do not require the header.
- For inbound webhooks, the dedupe key is the envelope’s
id, notrequest.idempotency_key. The latter echoes the originating client’s POST key (when there was one) and is for tracing, not dedupe.
Related
- Error Handling — error response format and retry behavior
- Rate Limits — rate limits and backoff strategies
- API Reference: Link Sessions — link token creation endpoint