API documentation

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.

  1. submit requires idempotencyKey. Reusing the same key returns the original job. New key ⇒ new order.
  2. Reads are naturally idempotent. status, result, verify, and capabilities are safe to retry without ceremony.
  3. refund is Stripe-keyed by (apiKey, jobId, amount). Same arguments ⇒ same refund. No accidental double refunds.
  4. Retry on retryable: true only. 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).

ScenarioOutcome
Same key + same idempotencyKey, network failure mid-call → retryReturns 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 → retryReturns IDEMPOTENCY_CONFLICT (409). The original order is unaffected — you supplied conflicting follow-up data.
Same key + DIFFERENT idempotencyKeyCreates a new order. Each unique key minted by you maps to at most one order.
Different key + same idempotencyKeyCreates a new order. Idempotency is scoped to the API key — distinct keys are distinct namespaces.
How to choose an idempotencyKey
Make it a value your code can reproduce on retry:
  • 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.

ScopeLimitWhat you see when you cross it
Per API key (rolling 60s window)300 requestsHTTP 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.

Mostly you won't hit it
300 req/min is overkill for the typical agent loop (a quote + submit + a handful of polls + a result is < 10 requests per order). The limit primarily catches runaway loops. If you're building something with sustained higher throughput, email hello@verdacert.com and we'll raise it for your key.

Payment retries

A separate concern — the off-session charge can fail in three distinct ways. Each one has a clear recovery.

ErrorWhat happenedHow to recover
NO_PAYMENT_METHODNo 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_FAILEDStripe declined the off-session charge (insufficient funds, expired card, hard decline).Account holder updates the card; retry submit with the same idempotencyKey.
PAYMENT_REQUIRES_ACTIONCard 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-id as 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 status with exponential backoff — 30s, 60s, 120s, capped at 5 min. Stop on ready, delivered, failed, cancelled, or refunded.

Where to next

Get instant quotePricing