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" }
}

meta carries structured context when relevant (which role failed, which idempotency key collided, etc). The one exception is ip_not_allowed, which carries the observed caller address as a top-level ip field rather than inside meta — it is produced by the authentication layer, before any route-level meta exists.

Common codes

Authentication / authorization

StatusCodeWhen
401invalid_keyBearer token missing, malformed (neither wsk_live_ nor lsk_live_), unknown, or revoked.
402tier_requiredWorkspace not on a tier with API access, or the monthly document cap is reached (meta.tier, meta.cap, meta.used).
402enterprise-requiredThe endpoint itself is Enterprise-only, whatever the plan's API access: template channels, QES, and minting embedded signing sessions. Sales-led — the message names the feature.
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.

Request shape

StatusCodeWhen
400invalid_requestBody validation failed (zod-style message in error) — including phone_e164 is required when require_sms_verification is true.
400invalid_idempotency_keyIdempotency-Key header value not 1–255 ASCII-printable chars.
415unsupported_media_typeBody is not multipart/form-data or JSON, or file is not application/pdf.
413file_too_largePDF exceeds 25 MB.

Placement

StatusCodeWhen
400unknown_roleA field/anchor references a role no signer claims.
400signer_has_no_anchorA signer was passed but no anchor for their role exists in the PDF.
400duplicate_anchorSame signature/role appears twice on the same page.
400no_anchors_foundplacement="anchors" strict + the PDF has no extractable text or no markers. Switch to auto_append or explicit.
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.
422placement_failedpdf-lib threw while masking anchors / appending the signature page.

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.

Manage + download endpoints

StatusCodeWhen
409invalid_stateremind/withdraw rejected because the signing request is already signed/declined/withdrawn/expired.
409expiredremind called on a request whose TTL has elapsed.
409not_complete/signed or /audit-trail requested before every signer signed. Wait for document.completed.
409not_storedEvery signer signed but the sealed file is not on storage yet. Retry shortly.
404no_signers/signed on a document with no signers (a finalized file-only instance) — use /pdf.
502email_failedResend or workspace SMTP errored while sending the reminder.
503email_not_configuredThe deployment doesn't have RESEND_API_KEY set yet (development only).

Templates

StatusCodeWhen
400missing_recipientsinstantiate: a slot that has fields was given no recipient (meta.slots).
404version_not_foundinstantiate pinned a version that has no snapshot (meta.version, meta.current_version).
422template_input_invalidOne or more field_values failed validation; problems[] lists every one with its own code (field_required, invalid_enum, …). See Templates → Validation.
409not_staged / discarded / already_confirmedConfirm or discard called on an instance that is not (or no longer) staged.
410review_expiredThe 14-day review window closed.
422invalid_recipientsBad email, non-E.164 phone, SMS without a number, duplicate signing_order on confirm.
502render_failedThe PDF could not be rendered on confirm; nothing was sent.
503review_unavailableStaging a generated file is not available on this deployment.

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, 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.

Retired endpoints

StatusCodeWhen
410embedded_sessions_retiredPOST /v1/embedded/sessions. See Embedded signing.
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.
500storage_failedBlob upload or download threw — most often a transient platform error, retry.
500db_failedInsert into signing_requests / documents errored. Surface to oncall; usually a schema-cache issue resolved by Supabase.

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 ${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') {
      throw new UpgradeRequiredError(body.meta?.tier, body.meta?.cap)
    }
    throw new ApiError(body.code, body.error, res.status)
  }
  return res.json()
}