Guide
Error envelope
Every 4xx and 5xx response uses the same JSON shape:
{
"error": {
"code": "BAD_REQUEST",
"message": "Validation failed: email is required",
"details": { "field": "email" },
"correlationId": "00000000-0000-4000-8000-000000000005"
}
}
| Field | Type | Description |
|---|---|---|
error.code | string | Stable, safe-to-expose enum value. Switch on this in your client. |
error.message | string | Human-readable, sanitised of ARNs / JWTs / UUIDs. Safe to surface to end-users. |
error.details | object? | Optional structured detail when an HttpError carries one. |
error.correlationId | string | Request correlation ID, also echoed in the X-Correlation-Id response header. Include in every support request. |
Status code catalog
| Code | Meaning | When it fires |
|---|---|---|
400 BAD_REQUEST | Validation or schema failure | Missing required field, wrong type, malformed JSON. |
401 UNAUTHORIZED | Missing or invalid auth | No token / API key, expired JWT, bad signature. |
402 PAYMENT_REQUIRED | Plan limit reached or dunning grace expired | Org over seat-cap, subscription past-due > 14 days. |
403 FORBIDDEN | Authenticated but not authorised | Cross-org request, member touching admin-only field, owner self-demote. |
404 NOT_FOUND | Resource doesn’t exist or is soft-deleted | Bad UUID, deleted user / template. |
409 CONFLICT | Optimistic-concurrency mismatch | version field doesn’t match server state. See Idempotency. |
412 PRECONDITION_FAILED | If-Match / similar precondition failed | Rare; conditional-update flows. |
413 PAYLOAD_TOO_LARGE | Request body > 5 MB or render output > 30 KB | Image upload too big, signature exceeded Outlook cap. |
429 TOO_MANY_REQUESTS | Rate limit exceeded | See Rate limits for limits and Retry-After. |
500 INTERNAL_ERROR | Server bug or unhandled exception | Transient; retry with backoff. Open a support ticket if persistent. |
| 503 | Service degraded | Health-check classifier; see /health. |
Recommended client behavior
| Status | Action |
|---|---|
| 400 / 403 / 404 | Don’t retry. Fix the request. |
| 402 | Don’t retry. Check the /plan-status endpoint for the specific cap that hit. |
| 409 | Read fresh, retry once. See Idempotency for the read-modify-write pattern. |
| 413 | Don’t retry; shrink the payload. |
| 429 | Honor Retry-After (seconds). Exponential backoff if no header (start 1s, max 30s, jitter). |
| 5xx | Retry with capped exponential backoff (start 250ms, max 8s, max 5 attempts). |
Correlation IDs
Every request carries an X-Correlation-Id header (we generate one if you
don’t send one). It rides through every Lambda log line and ends up in the
audit table for write operations. Always include it in support requests —
we can usually pinpoint a failure mode in under a minute given the ID.
curl -X POST "https://api.esigkit.com/v1/orgs/$ORG_ID/render/$USER_ID" \
-H "Authorization: Bearer $ESIGKIT_TOKEN" \
-H "X-Correlation-Id: my-app-trace-12345" \
-i
# Response includes:
# X-Correlation-Id: my-app-trace-12345
Retry pattern (Node + fetch)
async function callWithRetry(url: string, init: RequestInit, attempt = 0): Promise<Response> {
const res = await fetch(url, init);
if (res.ok) return res;
// 429 — honor Retry-After
if (res.status === 429 && attempt < 5) {
const retryAfter = Number(res.headers.get('retry-after') ?? 2 ** attempt);
await new Promise((r) => setTimeout(r, retryAfter * 1000));
return callWithRetry(url, init, attempt + 1);
}
// 5xx — capped exponential backoff
if (res.status >= 500 && res.status < 600 && attempt < 5) {
await new Promise((r) => setTimeout(r, Math.min(8000, 250 * 2 ** attempt)));
return callWithRetry(url, init, attempt + 1);
}
// 4xx (other than 429) — don't retry
const body = await res.json().catch(() => ({}));
throw new Error(`eSigKit ${res.status} ${body.error?.code}: ${body.error?.message}`);
}