API documentation

Troubleshooting

Keyed by what you observe, not by error code. If you have an error code, see /docs/errors. This page is for “something is wrong, where do I look first?”

Before you start

Two pieces of information unlock every support ticket. Grab them before you read anything else here.

  • The x-vc-request-id response header from the failing call. Every response has it (success and error). Log it next to your own request id so you can hand it to support.
  • The code + recoveryHint from the error envelope. Even if the recovery hint reads like an LLM cue, it usually names the exact thing to fix.
# Surface both at once
curl -i https://verdacert.com/api/v1/quote \
  -H "Authorization: Bearer vc_sandbox_…" \
  -H "Content-Type: application/json" \
  -d '{ "sourceLanguage": "fa", "useCase":"uscis", "pageCount":2, "speedTier":"standard" }'
# →  x-vc-request-id: 4f8c1b2a-3d6e-…
#    { "error": { "code": "VALIDATION_ERROR", "recoveryHint": "…", "field": "speedTier" } }

submit() returns 4xx and no order is created

The order row is only written on a successful call, so a 4xx is recoverable — fix the input and retry with the same idempotencyKey.

“DOCUMENT_UNREADABLE” — we can't fetch your document URL

Walk through this in order:

  1. Open the URL incognito. If you need to sign in, the URL is not publicly reachable; replace it with a signed S3 / R2 URL.
  2. Confirm the source serves PDF / JPEG / PNG / HEIC / HEIF / TIFF / WebP. We store whatever Content-Type the origin returns (defaulting to application/pdf when unset), but downstream OCR + reviewer tools expect one of those file types.
  3. Confirm size < 25MB.
  4. If the URL serves through a CDN, double-check that the CDN is reachable from US East — we fetch from Vercel Functions in iad1.

"QUOTE_EXPIRED" or "INVALID_QUOTE"

Quotes are valid for 24 hours. If you cache them longer (or forget to refresh between agent runs), re-call /quote with the same inputs and submit with the fresh quoteId. echoedInput must match the new quote.

"IDEMPOTENCY_CONFLICT" — you reused a key with different inputs

Your previous successful submit used this idempotencyKey but with different data. Either:

  • Generate a new key for the new submission, or
  • Call /status/<jobId>against the previous job — that's probably the one you wanted anyway.

“VALIDATION_ERROR” — server didn't like a field

Read error.fieldfirst. It's a dotted path:

  • documents.0.url → first document URL is malformed
  • sourceLanguage → not in capabilities.supportedSourceLanguages
  • echoedInput → you forgot to echo back the exact inputs you passed to /quote

Sandbox order looks "stuck" at created/paid

The sandbox state machine is poll-driven by design — it advances atomically when /status is called and observes that wall clock has moved past the next threshold.

Check, in order:

  1. Did you actually call /status/<jobId>? If you're only listening for webhooks and webhooks never fired, status is still created.
  2. How much wall-clock time elapsed since submit? Full translation reaches readyin ~2 minutes; Review-and-Certify in ~90 seconds. Before then you'll see intermediate states.
  3. Is the x-vc-request-id header consistent across pollers? Two pollers racing is fine — exactly one wins each transition.
Poll, don't wait
A common confusion: you might add a setTimeout then call /result directly. /result will return JOB_NOT_READY until at least one /status call has flipped the row. Either poll /status first, or subscribe to webhooks (the webhook fires from the same code path that updates the row).

Webhook never fires

The order moves through states but your receiver sees nothing.

Diagnostic flow:

  1. Open /portal/api Recent deliveries. Is the event there at all?
    • Not there → no webhookUrl is configured for the key (or you used a per-call webhookUrl on submit and forgot it).
    • Status: delivered → we got a 2xx; the problem is downstream of your acceptance.
    • Status: pending / abandoned → receiver returned 4xx/5xx or timed out (5s budget). Read lastError / lastStatusCode.
  2. Is your receiver reachable from the public internet? Run curl -X POST <your-url> -d '{}' from a different network. Behind a corporate firewall or localhost? Use ngrok / Cloudflare Tunnel.
  3. Are you respecting the 5s budget? Acknowledge with 2xx first, then process async — receivers that hold the connection while doing heavy work get marked as timeouts.

Webhook signature won't verify

HMAC mismatches almost always come from one of these three places.

  1. You parsed the body as JSON before verifying. HMAC runs over the exact bytes that arrived. Some frameworks (Express + express.json(), Flask) consume the stream and re-serialize, which changes whitespace. Capture the raw body before any middleware touches it.
  2. You forgot the <t>. prefix. The signed payload is <timestamp>.<rawBody>, with a literal dot. HMAC-SHA256(secret, rawBody) alone won't match.
  3. Hex vs base64. The header value v1=… is hex-encoded SHA-256.
# Quick verifier — sanity-check from a shell
secret='your-signing-secret'
t=1716480000
body='{"id":"evt_abc","type":"order.ready", ... }'
printf '%s.%s' "$t" "$body" | openssl dgst -sha256 -hmac "$secret"
# → SHA2-256(stdin)= 4d7a5c…   (compare to v1= in the header)

Live submit returns 402

Three flavors. Each one has a specific fix.

"NO_PAYMENT_METHOD"

The b2b account has no default payment method. Operator opens the portal and completes the SetupIntent flow. After that, retry submit with the same idempotencyKey— no order was created yet, so you won't double up.

"PAYMENT_FAILED"

Stripe declined the charge (insufficient funds, expired card, hard decline). Account holder updates the card via the same SetupIntent flow. Retry with the same key.

"PAYMENT_REQUIRES_ACTION" (3DS)

Card requires Strong Customer Authentication. An agent cannot complete off-session 3DS on the end-user's behalf. The flow is:

  1. Surface Stripe's next_action URL to the end-user.
  2. End-user completes 3DS in a browser.
  3. Retry submit with the same idempotencyKey.

Every call returns 401

Five likely culprits.

  1. Wrong header form. Must be Authorization: Bearer vc_… — no scheme prefix mismatch, no URL-encoding of the token, no leading Token .
  2. Token has whitespace from a copy-paste. Inspect with cat -A or equivalent.
  3. Sandbox vs live mismatch. Sandbox tokens start vc_sandbox_; live start vc_live_.
  4. Token was revoked in the portal (you'll see REVOKED_KEY specifically, not INVALID_AUTH).
  5. Environment variable didn't load. Print the prefix (never the full token) at boot: console.log(process.env.VERDACERT_API_KEY?.slice(0, 12)).

Certificate verify fails or returns valid: false

Two distinct failure modes; the response tells you which.

  • revokedAt is set → the certificate was revoked. revokedReason says why.
  • revokedAt is null but valid: false→ the JWS signature didn't verify against our active JWKS. This implies tampering with the certificate row at rest (extremely rare; contact support).
Offline verify failing?
If your offline check fails but the online /verify endpoint returns valid, your local JWKS cache is stale. Refresh from /.well-known/jwks.json and retry. We rotate keys periodically with a grace window; the kid in the JWS header tells you which key to look up.

429 RATE_LIMITED

Per-key, rolling 60s window. The Retry-After header tells you when to retry.

If you're hitting this regularly:

  • Batch documents in submit. documents[] accepts up to 50 entries — one job per submit instead of one per document drops request count linearly.
  • Cache capabilities.It's stable for ~1h on your side — there's no need to call it before every quote.
  • Back off polling. 30s base + exponential growth is plenty for sandbox; live is even slower-moving. Better yet, use webhooks.
  • Need higher limits? Email hello@verdacert.com with your key id and we'll raise the ceiling.

Where to next

Get instant quotePricing