letssign.now docs

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

Common codes

Authentication / authorization

StatusCodeWhen
401invalid_keyBearer token missing, malformed, or revoked.
402tier_requiredWorkspace not on a tier with API access (or monthly cap reached).
404not_foundResource doesn't exist, OR belongs to a different workspace.

Request shape

StatusCodeWhen
400invalid_requestBody validation failed (zod-style message in error).
400invalid_idempotency_keyIdempotency-Key header value not 1–255 ASCII-printable chars.
415unsupported_media_typeBody is not multipart/form-data, 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.
422placement_failedpdf-lib threw while masking anchors / appending the signature page.
501placement_not_implementedReserved — currently unused.

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 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_completeTried to download signed.pdf or audit-trail.pdf before every signer signed.
502email_failedResend or workspace SMTP errored while sending the reminder.
503email_not_configuredThe deployment doesn't have RESEND_API_KEY set yet (development only).

Rate limiting + storage

StatusCodeWhen
429rate_limited60 requests/minute/key exceeded. Retry after the Retry-After header.
500storage_failedBlob upload 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.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()
}