Error reference
Every Verdacert error returns the same envelope, with a stable machine-readable code, a human message, and a recoveryHint tuned for an LLM consumer. code values are part of the API contract and will not change.
Error envelope
Every non-2xx response (REST, MCP, and the AI SDK tool surface) has this shape.
{
"error": {
"code": "QUOTE_EXPIRED",
"message": "Quote has expired (quotes are valid for 24 hours).",
"recoveryHint": "Call quote() again with the same inputs to get a fresh quoteId.",
"retryable": false,
"retryAfterSeconds": 60,
"field": "documents"
}
}| Field | Type | Notes |
|---|---|---|
| code | string | Stable machine-readable code. Branch on this. |
| message | string | Human-readable; safe to show to operators or surface in agent UX. |
| recoveryHint | string | Written for an LLM consumer: a one-liner the model can act on. |
| retryable | boolean | true ⇒ same call can succeed on retry (e.g. RATE_LIMITED, INTERNAL_ERROR). false ⇒ change inputs first. |
| retryAfterSeconds | integer (optional) | Suggested back-off when retryable. |
| field | string (optional) | For VALIDATION_ERROR: the offending field path (e.g. documents.0.url). |
Programmatic retry strategy
A safe default: retry when
retryable === true with capped exponential backoff, starting from retryAfterSeconds if present (else ~1s). Never retry a VALIDATION_ERRORor other 4xx without changing the input — you'll just rate-limit yourself.Auth & rate limits
Apply to every endpoint. RATE_LIMITED is the only retryable one.
| Code | HTTP | When | Recovery |
|---|---|---|---|
MISSING_AUTH | 401 | Authorization header absent. | Include Authorization: Bearer vc_…. |
INVALID_AUTH | 401 | Token doesn't match any active key. | Confirm the token isn't URL-encoded or truncated. Sandbox keys start vc_sandbox_; live keys start vc_live_. |
REVOKED_KEY | 401 | Token was revoked in the portal. | Mint a fresh key in /portal/api. |
RATE_LIMITED | 429 | More than 300 requests in the last 60s on this key. | Back off for the seconds returned in retryAfterSeconds (and the standard Retry-After header). Reduce concurrency; batch documents into a single submit. |
Input validation
4xx — your inputs need to change before a retry will succeed.
| Code | HTTP | When | Recovery |
|---|---|---|---|
VALIDATION_ERROR | 400 | Generic input validation failure. | Inspect error.field and the human message. Use capabilities to ground enum values. |
UNSUPPORTED_LANGUAGE | 400 | sourceLanguage isn't in the supported list. | Call capabilities to fetch the current supportedSourceLanguages. |
UNSUPPORTED_USE_CASE | 400 | useCase isn't in the supported list. | Call capabilities; pick an allowed useCase. |
ADDON_UNAVAILABLE | 400 | Add-on is currently gated off (e.g. apostille while we're scaling that pipeline). | Check capabilities.addons[].available before quoting. |
DRAFT_REQUIRED_FOR_REVIEW_AND_CERTIFY | 400 | Submitted with productLine: review_and_certify but no draftTranslation. | Supply the agent-generated draft URL, or switch to full_translation. |
REVIEW_AND_CERTIFY_NOT_YET_AVAILABLE | 400 | R&C requested but not yet GA on your tenant. | Use full_translation for now; R&C ships in the next release. |
DOCUMENT_TOO_LARGE | 413 | Any document exceeds 25MB. | Split or compress the file before submitting. |
DOCUMENT_UNREADABLE | 400 | A document URL couldn't be fetched or parsed. | Ensure each documents[].urlis reachable from the public internet, returns PDF/JPEG/PNG, and is < 25MB. Signed S3 / R2 URLs work; URLs behind login don't. |
Quote flow
Specific to quote and submit. The quote ⇄ submit binding is what makes stateless quoting honest.
| Code | HTTP | When | Recovery |
|---|---|---|---|
INVALID_QUOTE | 400 | quoteId doesn't parse / isn't recognized. | Call quote again to get a fresh id, then submit. |
QUOTE_EXPIRED | 410 | quoteId is older than the 24h TTL. | Re-call quote with the same inputs. |
QUOTE_MISMATCH | 400 | quoteId belongs to a different API key. | Quotes are scoped to the key that minted them. Call quote with the current key first. |
Order & certificate lifecycle
Returned by submit, status, result, refund, verify.
| Code | HTTP | When | Recovery |
|---|---|---|---|
JOB_NOT_FOUND | 404 | jobId doesn't exist OR belongs to a different key. | Verify the jobId returned by submit. Jobs are scoped to the key that created them. |
JOB_NOT_READY | 409 | result called before the job reached ready / delivered. | Poll status until ready, or subscribe to the order.ready webhook. |
JOB_ALREADY_DELIVERED | 409 | Operation invalid for already-delivered orders. | Verify your assumptions on order state; contact support for special cases. |
IDEMPOTENCY_CONFLICT | 409 | A previous submit used this idempotencyKey with different inputs. | Pick a fresh idempotencyKey for the new submission, or call status with the original jobId. |
CERTIFICATE_NOT_FOUND | 404 | verify() with an unknown certificateId. | Confirm the certificateId from getResult(). |
CERTIFICATE_REVOKED | — | Returned implicitly via valid: false + revokedAt on verify(). | Not retryable — the certificate has been revoked. Re-issue. |
Billing & payments
Live keys only — sandbox never charges. Idempotency on submit makes these safe to retry once the underlying issue is fixed.
| Code | HTTP | When | Recovery |
|---|---|---|---|
NO_PAYMENT_METHOD | 402 | Live submit with no default payment method on file. | Have an operator complete the SetupIntent flow in the portal. Then retry submit with the same idempotencyKey. |
PAYMENT_FAILED | 402 | Stripe declined the off-session charge. | Account holder updates their card via the SetupIntent flow; retry with the same idempotencyKey. |
PAYMENT_REQUIRES_ACTION | 402 | Card requires 3DS — agent can't complete this off-session. | Surface Stripe's next_actionto the end-user, have them complete 3DS, then retry. Off-session 3DS cannot be performed by the agent on the end-user's behalf. |
SPEND_LIMIT_EXCEEDED | 402 | Per-day or per-month spend cap on this key was hit. | Raise the cap in the portal, or wait for the rolling window to reset. |
Server-side (5xx)
Our fault.
| Code | HTTP | When | Recovery |
|---|---|---|---|
INTERNAL_ERROR | 500 | We didn't anticipate this. The team has been paged. | Retry with exponential backoff. If persistent, share the x-vc-request-id with support@verdacert.com. |
UPSTREAM_UNAVAILABLE | 503 | A dependent service is degraded (Stripe, AI provider, R2). | Retry with backoff. Watch verdacert.com/status for live status. |
