Idempotency and retries
Retry any request safely, without creating a second validation, invoice or charge.
Idempotency keys
A POST requires an Idempotency-Key header of up to 255 characters, except the few that take none. heru stores the result of the first request with that key for 24 hours:
- A retry with the same key and the same body returns the original response. Nothing runs twice.
- The same key with a different body answers
409. A key names one operation, not one endpoint. - After 24 hours the key is forgotten and a new request with it runs again.
Operations without a key
POST /oauth/token, POST /merchants/{id}/console-link, POST /webhooks/{id}/secret, POST /webhooks/{id}/test and POST /batches/{id}/pause, /resume and /cancel take no key, and the reference of each operation says so. The last three are safe to repeat: they answer with the batch and change nothing the second time.
Choosing a key
Build the key from your own data, so a retry after a crash produces the same key. Good keys name the thing being done: FV-2026-004413, INV-2026-000213, or your card transaction id. A random UUID also works, as long as you store it before sending the request.
When to retry
| Result | Retry? |
|---|---|
| Network error or timeout, no response | Yes, with the same key. You cannot know whether the first request ran. |
5xx | Yes, with the same key and exponential backoff. |
429 | Yes, after the seconds in Retry-After. |
401 | Once, after requesting a new token. |
Other 4xx | No. Fix the request first. |
Invoices and retention certificates are stamped while you wait. When the PAC or the SAT do not answer they return 503 and nothing is created: retry after Retry-After with the same key. Asynchronous operations are different: when a fiscal validation cannot reach the SAT, heru retries for you and sends a follow-up webhook, so you do not resend the request.
A retry helper
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function postWithRetry(url, body, { token, key, attempts = 5 }) {
for (let i = 0; i < attempts; i++) {
let res;
try {
res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Idempotency-Key': key, // the same key on every attempt
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
} catch {
await sleep(2 ** i * 500 + Math.random() * 250);
continue;
}
if (res.status === 429) {
await sleep(Number(res.headers.get('Retry-After') ?? 1) * 1000);
continue;
}
if (res.status >= 500) {
await sleep(2 ** i * 500 + Math.random() * 250);
continue;
}
return res; // 2xx, or a 4xx you need to handle
}
throw new Error(`gave up after ${attempts} attempts`);
}