Idempotency & rate limits
Networks fail. Retries are unavoidable. Verdacert is designed so naïve retries are safe — by default. This page explains the rules so you can lean on them.
The four rules
If you internalize these, you can stop worrying about retry safety.
submitrequiresidempotencyKey. Reusing the same key returns the original job. New key ⇒ new order.- Reads are naturally idempotent.
status,result,verify, andcapabilitiesare safe to retry without ceremony. refundis Stripe-keyed by(apiKey, jobId, amount). Same arguments ⇒ same refund. No accidental double refunds.- Retry on
retryable: trueonly. See /docs/errors. Retrying a 4xx validation error just rate-limits you.
The idempotencyKey contract
The most important field on the API. Choose carefully.
submit({ idempotencyKey }) is required (8–128 characters). Verdacert stores it on the order row alongside the API key id; the database has a partial unique index on (apiKeyId, idempotencyKey).
| Scenario | Outcome |
|---|---|
| Same key + same idempotencyKey, network failure mid-call → retry | Returns the original job. Pre-existence check is the fast path; ON CONFLICT DO NOTHING + re-select is the race fallback. |
| Same key + same idempotencyKey, DIFFERENT inputs → retry | Returns IDEMPOTENCY_CONFLICT (409). The original order is unaffected — you supplied conflicting follow-up data. |
| Same key + DIFFERENT idempotencyKey | Creates a new order. Each unique key minted by you maps to at most one order. |
| Different key + same idempotencyKey | Creates a new order. Idempotency is scoped to the API key — distinct keys are distinct namespaces. |
- Best: hash of inputs, e.g.
sha256(userId + caseId + documentSha). - Good: your own DB primary key for the order intent (
order_intent.id), guaranteed unique per attempt. - OK: a UUID generated oncebefore the first attempt and reused on retry. Don't generate fresh UUIDs in your retry loop — that defeats the guarantee.
- Bad:timestamps, request ids that change per attempt, random values that aren't persisted.
Recommended retry strategy
Cap-bounded exponential backoff with jitter. Respect retryAfterSeconds when present.
TypeScript template
async function withRetry<T>(
fn: () => Promise<T>,
{ maxAttempts = 5, baseMs = 500, capMs = 30_000 } = {},
): Promise<T> {
let attempt = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
try {
return await fn();
} catch (err: any) {
attempt += 1;
const body = err?.body;
if (!body?.retryable || attempt >= maxAttempts) throw err;
const hinted = (body.retryAfterSeconds ?? 0) * 1000;
const expo = Math.min(capMs, baseMs * 2 ** (attempt - 1));
const jitter = Math.random() * 250;
await new Promise((r) => setTimeout(r, Math.max(hinted, expo) + jitter));
}
}
}
// Usage — idempotencyKey is shared across attempts:
const idempotencyKey = "order_" + sha256(`${userId}.${docHash}`);
const job = await withRetry(() =>
fetch("https://verdacert.com/api/v1/submit", { /* … */ }).then((r) => r.json()),
);Rate limits
Generous but real. The default protects the platform; if you need more, ask.
| Scope | Limit | What you see when you cross it |
|---|---|---|
| Per API key (rolling 60s window) | 300 requests | HTTP 429, code: RATE_LIMITED, retryAfterSeconds: 60, and a Retry-After header. |
The counter is DB-backed (queries api_usage by (apiKeyId, createdAt > now − 60s)) so every serverless instance sees the same number — distributed rate limit, no Redis required.
Payment retries
A separate concern — the off-session charge can fail in three distinct ways. Each one has a clear recovery.
| Error | What happened | How to recover |
|---|---|---|
NO_PAYMENT_METHOD | No default payment method on the b2b account. | Operator adds a card via the SetupIntent flow in the portal. Retry submit with the same idempotencyKey. |
PAYMENT_FAILED | Stripe declined the off-session charge (insufficient funds, expired card, hard decline). | Account holder updates the card; retry submit with the same idempotencyKey. |
PAYMENT_REQUIRES_ACTION | Card requires 3DS authentication. | Surface Stripe's next_actionto the end-user, have them complete 3DS, then retry. An agent can't complete off-session 3DS on behalf of the end-user. |
In all three cases, no order is created — the database row is only committed after a successful charge — so the original idempotencyKeyremains “unused” and a retry will land cleanly on the same key.
Webhooks + polling: how they interact
At-least-once delivery means duplicates are possible. Dedup, dedup, dedup.
- Webhook event ids are unique per logical event. Use
x-verdacert-event-idas your dedup key. - Polling status() races with webhooks. The sandbox state machine fires the webhook on the first poll that observes a transition; under live, the lifecycle event in our system fires it. Either way, your receiver should tolerate both signals arriving — usually polling sees-the-new-state, then the webhook lands seconds later.
- If you don't use webhooks: poll
statuswith exponential backoff — 30s, 60s, 120s, capped at 5 min. Stop onready,delivered,failed,cancelled, orrefunded.
