Errors

Stable error codes mapped to HTTP statuses. Pin your error handling to the code, not the human-readable message.

Errors are JSON-bodied with a stable code string and a human-readable error message. The error is for humans (logs, dashboards, oncall chat) and may improve over time. The code is for code — pin your catch logic to that.

{
  "error": "Signer with role \"tenant\" has no [[ls:...:tenant]] anchor in the PDF",
  "code":  "signer_has_no_anchor",
  "meta":  { "role": "tenant" }
}

Match code exactly, as a string. Almost every code is snake_case. Three are kebab-case — enterprise-required, pinned-slot-mismatch and interactive-table-requires-sequential — and will stay that way: renaming a code would break every client that already matches it. Don't normalise case or separators before you compare.

meta carries structured context when relevant (which role failed, which signer carried an unknown key, etc). A few codes carry their context at the top level instead of inside meta:

CodeTop-level fields
ip_not_allowedip — the observed caller address (produced by the authentication layer, before any route-level meta exists).
sms_allowance_exhaustedsms_allowance: { monthly, used, remaining, resets_at }, required.
invalid_metadataproblems: [{ path, message }].
template_input_invalidOn instantiate and generate: problems[], warnings[], template_id, template_name, template_version, docs, and missing_optional[] on a validate_only call. On confirm: problems[] only.
invalid_recipientsproblems: [{ signing_request_id, field, message }].
pinned-slot-mismatchslot.

A success body can carry warnings: [{ field, code, message }] — see Warnings below.

Common codes

Authentication / authorization

StatusCodeWhen
401invalid_keyBearer token missing, malformed (neither wsk_live_ nor lsk_live_), unknown, or revoked — or a key with the embedded scope calling anything outside /v1/embedded/*, GET /v1/me included. See Key scopes.
402enterprise-requiredThe endpoint needs Enterprise: POST /v1/templates/{id}/instantiate, POST /v1/templates/{id}/generate, POST /v1/documents/{id}/confirm and POST /v1/embedded/sign-sessions. Reading templates and fields, discard, and reading or revoking a sign session are never gated. Sales-led — the message names the feature.
402tier_requiredPOST /v1/signing-requests and POST /v1/templates/{id}/instantiate (a staged review: true instance included): the workspace's plan has a monthly document cap and it is reached (meta.tier, meta.cap, meta.used). Nothing was created. Teams and Enterprise have no cap by default. No other endpoint answers this code.
402sms_allowance_exhaustedPOST /v1/signing-requests, a direct instantiate, or confirm: the signers who must verify by SMS outnumber the SMS codes the workspace has left this month. Nothing was created or sent. See SMS verification.
403ip_not_allowedThe key is valid, but its IP allowlist is non-empty and the caller's address is not on it — or could not be determined. The body carries the address we saw as ip (null when unknown). An empty allowlist means unrestricted; see Restricting a key to your own IP addresses.
404not_foundResource doesn't exist, OR belongs to a different workspace than the key.
404workspace_not_foundPOST /v1/signing-requests: the key's workspace could not be read. Retry; contact support if it persists.

Request shape

StatusCodeWhen
400invalid_requestBody validation failed (zod-style message in error) — including phone_e164 is required when require_sms_verification is true, sms_gate "before_view" requires require_sms_verification: true, a signer's company or job_title that is too long or holds a line break or other control character, and a file_url that is not an http(s) URL — or the body is not valid JSON.
400unknown_signer_fieldA signer object on POST /v1/signing-requests carried a key it does not accept (meta.signers: [{ index, fields }], meta.accepted).
400unknown_recipient_fieldThe same for a recipient on instantiate or confirm (meta.recipients, meta.accepted).
400unknown_fieldPATCH /v1/signing-requests/{id} got a key besides phone_e164 (meta.fields, meta.accepted). From 2026-12-31 also any unknown top-level key of the create, instantiate, generate and confirm calls — see Unknown keys.
400conflicting_fieldsConfirm: phone_e164 and its deprecated alias phone disagree (meta.recipients: [{ index, fields }]).
400invalid_phonePATCH /v1/signing-requests/{id}: phone_e164 is not E.164.
400invalid_metadatametadata is not a flat object within the limits; top-level problems[].
400invalid_idempotency_keyIdempotency-Key header value not 1–255 ASCII-printable chars.
413file_too_largeThe PDF — uploaded or fetched from file_url — exceeds 25 MB.
415unsupported_media_typeBody is not multipart/form-data or JSON, the multipart body could not be parsed, or the uploaded file is not application/pdf.

Fetching file_url

POST /v1/signing-requests with a file_url fetches the PDF before anything else happens. Nothing is created when the fetch fails.

StatusCodeWhen
400blocked_urlThe host is localhost or ends in .local / .internal, cannot be resolved, or resolves to a private or reserved address.
400redirect_not_allowedThe URL answered with a redirect. Redirects are never followed; pass the final URL.
502fetch_failedThe fetch failed: a network error, a non-2xx answer (the error names the status) or an empty body.
504fetch_timeoutThe URL did not answer within 20 seconds.

Placement

StatusCodeWhen
400unknown_roleA marker in the PDF, or an explicit field, names a role no signer has (meta.role; meta.page for a marker).
400signer_has_no_anchorThe PDF has markers, but none for this signer's role (meta.role).
400duplicate_anchorThe same kind and role twice on one page — signature, initial, date and name may appear once per page per role; text may repeat (meta.role, meta.kind, meta.page).
400placement_retiredplacement="manual" on POST /v1/signing-requests — retired 2026-07-31, refused before any document is minted. Use anchors, explicit coordinates or auto-append.
410placement_retiredAny call to POST /v1/documents — retired 2026-07-31 with the manual flow; file_url on POST /v1/signing-requests covers ingest-by-URL.
422signer_without_signatureplacement="explicit": a signer's role has no signature or initial field, so the signer would have nothing to sign (meta.roles).
422placement_failedpdf-lib threw while masking anchors / appending the signature page.

A PDF with no markers is never an error, whether you passed placement="anchors" or left the default: the call falls through to an appended signature page and the response says "placement": "auto_append" with anchors.found: 0. Check those two fields if a missing marker must stop your workflow — see Anchor placeholders.

Idempotency

StatusCodeWhen
409idempotency_in_progressA previous call with the same Idempotency-Key is still processing. Retry after the Retry-After seconds.
422idempotency_key_reuseSame Idempotency-Key was used with a different request body. Use a fresh key.

See Idempotency for the full retry-safe pattern.

Signing requests: remind, withdraw, correct the phone

StatusCodeWhen
409invalid_stateremind/withdraw on a request that is signed, declined, withdrawn — or still queued (a sequential follower, or a recipient of a staged template instance). withdraw also answers it when the request changed state during the call.
409expiredremind on a request whose TTL has elapsed.
409not_editablePATCH /v1/signing-requests/{id}: the request is no longer pending/viewed/queued, or the signer already verified a code (meta.status, meta.sms_verified_at).
429rate_limitedremind: this signer was already reminded in the last 60 seconds — by your key, a user in the dashboard or the automatic reminders. Carries Retry-After: 60 (no RateLimit-* headers): wait that long before the next attempt. Not the per-key rate limit, which it does not touch.
500db_failedwithdraw: the status update failed. Nothing changed — the request is still in flight and no webhook fired. Retry.
502email_failedOur email provider refused or failed the reminder. Nothing was recorded on the audit trail; retry later.
503email_not_configuredThe deployment has no email provider configured (development only).

Documents: downloads

StatusCodeWhen
404no_signers/signed or /audit-trail on a document with no signers (a finalized file-only instance) — use /pdf.
409not_complete/signed or /audit-trail requested before every signer signed. Wait for document.completed.
409not_storedEvery signer signed but the sealed file (or, for /audit-trail, its signing record) is not stored yet. Retry shortly.
409not_rendered/pdf: the document has no stored file.
410deleted_by_retention/signed: the sealed PDF was deleted after the workspace's retention period.
500render_failed/audit-trail: the audit-trail PDF could not be rendered. Retry.

Templates

StatusCodeWhen
400missing_recipientsinstantiate: a slot that has fields was given no recipient (meta.slots).
400pinned-slot-mismatchinstantiate or confirm: a slot the template pins to an address got another one (top-level slot).
404version_not_foundinstantiate pinned a version that has no snapshot (meta.version, meta.current_version).
409template_unlockedinstantiate or generate: the template is not locked — a draft, or archived (with or without a version pin).
409interactive-table-requires-sequentialinstantiate or confirm: an unresolved interactive table with several parallel recipients.
409not_renderedgenerate: the template has nothing to render.
422signer_without_signatureinstantiate: a recipient's slot has no signature or initial field.
422template_input_invalidinstantiate, generate or confirm: one or more field_values failed validation. problems[] lists every one with its own code — see below.
500insert_failedgenerate with review: true: the staged document could not be stored. Nothing was created; retry.
502render_failedinstantiate: the filled PDF of a rich-text template could not be rendered or stored; nothing was created or sent and the Idempotency-Key is released. generate: the PDF could not be rendered. confirm: the staged document could not be re-rendered; nothing was sent. Retry.
503review_unavailablegenerate with review: true: staging a generated file is not available on this deployment.

Confirm and discard

StatusCodeWhen
409not_stagedconfirm or discard: the document is not a staged instance (or no longer one).
409discardedconfirm on an instance that was discarded.
409already_confirmeddiscard on an instance that was already confirmed. (A repeated confirm is not an error: it answers 200 with already_confirmed: true and sends nothing.)
410review_expiredconfirm after the 14-day review window closed.
422invalid_recipientsconfirm: bad email, non-E.164 phone, SMS without a number, before_view without SMS, duplicate signing_order.
502update_failedconfirm: writing a recipient edit or the document's values failed. Nothing was sent; retry the confirm.
502confirm_failedconfirm: releasing the recipients (or finalizing a file-only instance) failed. Retry — the confirm resumes where it stopped.
502discard_faileddiscard: withdrawing the staged rows (or deleting a file-only instance) failed. Retry.

Problem codes of template_input_invalid

Each entry of problems[] is { field, label, code, message, allowed? }:

codeMeaning
field_requiredA required input was missing or blank. Only asked when the field is visible: an input inside a conditional section that is hidden is never required.
invalid_shapeA table (collection) got a single value, or a single value got rows.
invalid_booleanA boolean field got something other than true/false, yes/no or a UI-locale label.
invalid_agreeAn agree field got something other than a yes (allowed: ["yes"]).
invalid_enumNot one of the field's options (allowed lists them).
invalid_emailNot an email address.
invalid_numberNot a number.
invalid_dateNot a real calendar day in an accepted day-first or ISO spelling.
invalid_timeNot a 24-hour or AM/PM time.
invalid_ibanNot a valid IBAN.
invalid_vatNot a valid VAT ID.

The same body's warnings[] can carry unknown_field and unknown_column (below); they never cause the 422 on their own.

Embedded signing

StatusCodeWhen
400invalid_originorigin on POST /v1/embedded/sign-sessions is not a plain https origin — it carried a path, query, fragment, credentials or a wildcard, or used http:.
400origin_not_allowedThe origin is well-formed but is not registered on this API key. Add it under Settings → API, "Allowed embed origins". The message lists what is registered.
409request_not_signableThe signing request is not pending/viewed (a queued sequential follower or staged recipient included), or is past its own expiry, so there is nothing to frame.
503embedding_unavailableThis deployment has not applied migration 0155 yet. Transient by definition — retry, or contact support.

ttl_seconds outside 60–900 and an unknown locale are refused as 400 invalid_request rather than silently clamped or defaulted. See Embedded signing.

Webhook subscriptions

StatusCodeWhen
404not_foundPOST /v1/hooks/{id}/rotate: unknown id, another workspace's hook, or a document-scoped callback_url hook.
409rotation_conflictTwo rotations of the same hook raced. Retry; the other call's secret is the live one.

Retired endpoints

StatusCodeWhen
410embedded_sessions_retiredPOST /v1/embedded/sessions. See Embedded signing.
409already_consumedDELETE /v1/embedded/sessions/{id} on a session of that era that was already used.
410placement_retiredPOST /v1/documents.
400placement_retiredplacement="manual" on POST /v1/signing-requests.

Rate limiting + storage

StatusCodeWhen
429rate_limited60 requests per minute per API key exceeded. Retry after the Retry-After header. (remind also answers 429 rate_limited for its per-signer cooldown, with Retry-After: 60 — see above.)
500storage_failedBlob upload or download threw — most often a transient platform error, retry.
500db_failedA database read or write failed. Retry; if it persists, contact support with the time and endpoint. On POST /v1/signing-requests and instantiate nothing was sent and meta.rolled_back says whether everything the call created was removed (true) or the leftover document is named in meta.document_id (false) — see If the call fails.

Codes outside an HTTP error

Some codes reach you on a 200 or outside the HTTP response altogether:

  • problems[].code on a validate_only 200 — sms_allowance_exhausted when a dry run of instantiate finds the SMS allowance short (with ok: false). A dry run whose field_values fail still answers 422 template_input_invalid, like the real call. See SMS verification.
  • reason on signing_request.sms_failed — invalid_number, landline, blocked, rate_limited, allowance_exhausted, not_configured, provider_error. See Webhooks.
  • detail on the embedded frame's wesign.error message — not_found, revoked, key_revoked, unavailable, withdrawn, queued, sms_required (a before_view signer, who cannot verify inside a frame yet), sign_failed, sealing_in_progress, pades_failed, seal_persist_failed or http_<status>. See Embedded signing.

Warnings

A 2xx body — and the 422 template_input_invalid — may carry warnings: [{ field, code, message }]. A warning never fails the call, but each one names something that did not happen the way you probably meant:

CodeMeaning
unknown_fieldA top-level key the endpoint does not know was ignored (refused from 2026-12-31), or a field_values key the template does not know was not rendered. The message tells the two apart ("… of this endpoint …" / "… of this template …").
deprecated_fieldA spelling that still works until its sunset date — today recipients[i].phone on confirm (use phone_e164; accepted until 2026-12-31).
unknown_columnA column of a collection row the table does not have (field is table_key.column).

Log them, and alert on them in your integration tests.

Detecting an error in code

async function send(opts) {
  const res = await fetch('https://api.wesign.now/v1/signing-requests', {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.WSK_KEY}` },
    body: opts.formData,
  })
  if (!res.ok) {
    const body = await res.json()
    if (body.code === 'idempotency_in_progress') {
      const retryAfter = Number(res.headers.get('retry-after')) || 5
      await sleep(retryAfter * 1000)
      return send(opts) // retry the SAME idempotency key
    }
    if (body.code === 'rate_limited') {
      const retryAfter = Number(res.headers.get('retry-after')) || 60
      await sleep(retryAfter * 1000)
      return send(opts)
    }
    if (body.code === 'tier_required') {
      // The plan's monthly document cap is reached.
      throw new UpgradeRequiredError(body.meta?.tier, body.meta?.cap, body.meta?.used)
    }
    if (body.code === 'sms_allowance_exhausted') {
      // Nothing was created. Top-level, not under meta.
      throw new SmsAllowanceError(body.sms_allowance.remaining, body.required, body.sms_allowance.resets_at)
    }
    throw new ApiError(body.code, body.error, res.status)
  }
  return res.json()
}