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).
Status Code When 401 invalid_keyBearer token missing, malformed, or revoked. 402 tier_requiredWorkspace not on a tier with API access (or monthly cap reached). 404 not_foundResource doesn't exist, OR belongs to a different workspace.
Status Code When 400 invalid_requestBody validation failed (zod-style message in error). 400 invalid_idempotency_keyIdempotency-Key header value not 1–255 ASCII-printable chars.415 unsupported_media_typeBody is not multipart/form-data, or file is not application/pdf. 413 file_too_largePDF exceeds 25 MB.
Status Code When 400 unknown_roleA field/anchor references a role no signer claims. 400 signer_has_no_anchorA signer was passed but no anchor for their role exists in the PDF. 400 duplicate_anchorSame signature/role appears twice on the same page. 400 no_anchors_foundplacement="anchors" strict + the PDF has no extractable text or no markers. Switch to auto_append or explicit.422 placement_failedpdf-lib threw while masking anchors / appending the signature page. 501 placement_not_implementedReserved — currently unused.
Status Code When 409 idempotency_in_progressA previous call with the same Idempotency-Key is still processing. Retry after the Retry-After seconds. 422 idempotency_key_reuseSame Idempotency-Key was used with a different request body. Use a fresh key.
See Idempotency for the full retry-safe pattern.
Status Code When 409 invalid_stateremind/withdraw rejected because the signing request is already signed/declined/withdrawn/expired.409 expiredremind called on a request whose TTL has elapsed.409 not_completeTried to download signed.pdf or audit-trail.pdf before every signer signed. 502 email_failedResend or workspace SMTP errored while sending the reminder. 503 email_not_configuredThe deployment doesn't have RESEND_API_KEY set yet (development only).
Status Code When 429 rate_limited60 requests/minute/key exceeded. Retry after the Retry-After header. 500 storage_failedBlob upload threw — most often a transient platform error, retry. 500 db_failedInsert into signing_requests / documents errored. Surface to oncall; usually a schema-cache issue resolved by Supabase.
async function send ( opts ) {
const res = await fetch ( 'https://api.letssign.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 ()
}