Errors
The API uses standard HTTP status codes plus a machine-readable code field in the body so callers can branch programmatically.
Response shape
Every error response carries this shape:
{
"error": "Human-readable message",
"code": "MACHINE_READABLE_CODE",
"...": "additional context per code"
}
The error field is for logs and operator eyes. The code field is what your error-handling code branches on. Some codes carry extra fields specific to the error (e.g. INSUFFICIENT_BALANCE carries priceMicros and topUpUrl).
Common codes by category
Auth
UNAUTHORIZED(401) — no auth header present, or the header didn’t resolve to a customer.TOKEN_INVALID(401) — the API key or wallet JWT signature didn’t verify.INVALID_SIGNATURE(401) — for x402, the recovered EIP-3009 signing address didn’t match the claimedfrom.INVALID_NONCE/CHALLENGE_EXPIRED/NONCE_ALREADY_USED(400/401) — wallet-JWT challenge/verify path; the nonce flow expects a single-use, TTL-bounded nonce.INVALID_WALLET_ADDRESS(400) — wallet address parameter wasn’t well-formed.MISSING_FIELDS(400) — the verify body was missing required fields.
Billing
-
INSUFFICIENT_BALANCE(402) — the wallet doesn’t have enough funds for the call. Body carries:{ "error": "Insufficient balance", "code": "INSUFFICIENT_BALANCE", "priceUsd": "0.10", "priceMicros": 100000, "balanceUsd": "0.05", "balanceMicros": 50000, "topUpUrl": "https://app.verafirma.com/...", "retryable": false }retryable: falsemeans re-issuing the same call won’t succeed; the customer needs to top up first. For x402 callers, the top-up is an on-chain transfer; for API-key callers, the top-up is the Stripe portal link intopUpUrl.
Validation
MISSING_PDF(400) — multipart upload had nopdfpart.MISSING_PAYLOAD(400) — multipart upload had nopayloadpart.INVALID_JSON(400) —payloadwas not valid JSON.VALIDATION_ERROR(400) — payload parsed, but a field failed validation. Body carries the offending field path underdetails.OUT_OF_SCOPE_V1(400) — request exceeded the per-envelope cap (>10 signers OR >10 documents).
Envelope state
ENVELOPE_NOT_FOUND(404) — no envelope with that id is owned by the calling customer. The wrapper scopes lookups to the authenticated customer; an envelope that exists for someone else returns 404, not 403.CANNOT_DELETE_COMPLETED(400) — DELETE on aCOMPLETEDenvelope is rejected; completed envelopes are terminal.ENVELOPE_ALREADY_COMPLETE(400) — resend on aCOMPLETEDenvelope.ENVELOPE_CANCELLED(400) — resend on aCANCELLEDenvelope.ALL_SIGNED(400) — resend when every recipient has already signed (no NOT_SIGNED recipients to chase).ENVELOPE_NOT_COMPLETE(400) — download attempted on a non-COMPLETEDenvelope.DOWNLOAD_FAILED(502) — the underlying signing engine returned an unexpected response on the PDF stream.
Templates
TEMPLATE_NOT_FOUND(404).INVALID_TEMPLATE_ID(400) — template id wasn’t a UUID.RECIPIENT_NOT_FOUND(404) —from-templaterecipient mapping referenced a recipient role that doesn’t exist on the template.INVALID_TEMPLATE_RECIPIENT_ID(400).
Fields
FIELD_CREATION_FAILED(502) — fields-add call to the engine failed.INVALID_FIELD_ID(400) — field id wasn’t a valid identifier.DELETE_FAILED(502) — field-delete call to the engine failed.
Webhooks
INVALID_WEBHOOK_URL(400) — URL wasn’t well-formed, or was on the deny-list (private IP ranges, the API’s own origin, etc.).WEBHOOK_NOT_FOUND(404).
Upstream
DOCUMENSO_UNAVAILABLE(503) — the underlying signing engine is reachable but errored. Retry with exponential backoff against the same idempotency key.RELAYSTATION_UNAVAILABLE(503) — billing facilitator errored. Same retry guidance.SEND_FAILED(502) —POST /v1/envelopes/{id}/sendfailed at the engine layer.UPDATE_FAILED(502) — template update failed at the engine layer.
Idempotency and retries
Billable operations require an Idempotency-Key header. A retry with the same key returns the original response (success OR error) without re-running the side effect.
For 5xx upstream errors, retry with the same key is the right move. For 4xx errors, the retry will return the same 4xx — fix the request, then retry with a fresh key.
What you don’t see
A few things that look like errors but aren’t surfaced as error responses:
- Webhook delivery failures — when the wrapper’s POST to your URL fails, your registration’s
consecutiveFailurescounter increments, but no error appears on a customer-side API call. CheckGET /v1/webhooks/{id}/deliveriesto debug. - Refund failures — if a refund-on-terminal flow fails partway through, the refund is logged as failed and the operator is alerted. The terminal state on the envelope (
REJECTEDetc.) doesn’t change; the customer doesn’t see an API error from the originalPOST /v1/envelopes(it succeeded, then later failed terminally). - Backfill / migration errors — operator-side internal flows. Customers don’t see them.
Audit log
Every customer-side mutation that persists state writes to an audit log row owned by the customer. There’s no customer-facing API for the audit log in V1; it’s an operator surface. If you need to investigate why an envelope ended up in an unexpected state, the operator can pull the audit history and explain.
In V1.x the operator may expose a per-customer audit-read API; that’s not a V1 commitment.