heruAPI

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

ResultRetry?
Network error or timeout, no responseYes, with the same key. You cannot know whether the first request ran.
5xxYes, with the same key and exponential backoff.
429Yes, after the seconds in Retry-After.
401Once, after requesting a new token.
Other 4xxNo. 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`);
}
© 2026 Kuarto Inc. (heru)heru.appQuestions about your integration? Contact your heru account team.

On this page