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-idresponse 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+recoveryHintfrom 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:
- Open the URL incognito. If you need to sign in, the URL is not publicly reachable; replace it with a signed S3 / R2 URL.
- Confirm the source serves PDF / JPEG / PNG / HEIC / HEIF / TIFF / WebP. We store whatever
Content-Typethe origin returns (defaulting toapplication/pdfwhen unset), but downstream OCR + reviewer tools expect one of those file types. - Confirm size < 25MB.
- 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 malformedsourceLanguage→ not incapabilities.supportedSourceLanguagesechoedInput→ 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:
- Did you actually call
/status/<jobId>? If you're only listening for webhooks and webhooks never fired, status is stillcreated. - How much wall-clock time elapsed since
submit? Full translation reachesreadyin ~2 minutes; Review-and-Certify in ~90 seconds. Before then you'll see intermediate states. - Is the
x-vc-request-idheader consistent across pollers? Two pollers racing is fine — exactly one wins each transition.
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:
- Open /portal/api → Recent deliveries. Is the event there at all?
- Not there → no
webhookUrlis configured for the key (or you used a per-callwebhookUrlonsubmitand 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.
- Not there → no
- Is your receiver reachable from the public internet? Run
curl -X POST <your-url> -d '{}'from a different network. Behind a corporate firewall orlocalhost? Use ngrok / Cloudflare Tunnel. - 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.
- 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. - You forgot the
<t>.prefix. The signed payload is<timestamp>.<rawBody>, with a literal dot.HMAC-SHA256(secret, rawBody)alone won't match. - 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:
- Surface Stripe's
next_actionURL to the end-user. - End-user completes 3DS in a browser.
- Retry
submitwith the sameidempotencyKey.
Every call returns 401
Five likely culprits.
- Wrong header form. Must be
Authorization: Bearer vc_…— no scheme prefix mismatch, no URL-encoding of the token, no leadingToken. - Token has whitespace from a copy-paste. Inspect with
cat -Aor equivalent. - Sandbox vs live mismatch. Sandbox tokens start
vc_sandbox_; live startvc_live_. - Token was revoked in the portal (you'll see
REVOKED_KEYspecifically, notINVALID_AUTH). - 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.
revokedAtis set → the certificate was revoked.revokedReasonsays why.revokedAtis null butvalid: false→ the JWS signature didn't verify against our active JWKS. This implies tampering with the certificate row at rest (extremely rare; contact support).
/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 persubmitinstead 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 everyquote. - 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.
