Error Handling
SignStack uses conventional HTTP status codes to signal success or failure. This page covers the error response shape, how to interpret the common failure modes, and patterns for handling them in your code.
HTTP Status Codes
| Code | Meaning | NestJS exception |
|---|---|---|
200 |
Success — request completed | — |
201 |
Created — resource was created | — |
400 |
Bad Request — invalid parameters, missing If-Match, or validation error |
BadRequestException |
401 |
Unauthorized — missing or invalid access token | UnauthorizedException |
403 |
Forbidden — valid auth, but token lacks a required scope | ForbiddenException |
404 |
Not Found — the resource doesn't exist (or isn't visible to your token) | NotFoundException |
409 |
Conflict — stale If-Match on an optimistic-locked mutation |
ConflictException |
429 |
Too Many Requests — rate limit exceeded | ThrottlerException |
5xx |
Server error — something went wrong on our end | — |
Error Response Format
Every 4xx / 5xx response has the same envelope:
{
"statusCode": 400,
"timestamp": "2026-04-21T14:32:10.123Z",
"path": "/v1/orgs/.../namespaces/.../workflows",
"traceId": "c0f8e2d1-4a5b-4c6d-8e7f-0a1b2c3d4e5f",
"errorInfo": {
"message": "Template 'offer_letter@1.0.0' not found"
}
}
| Field | Description |
|---|---|
statusCode |
The HTTP status, repeated in the body |
timestamp |
ISO 8601 time the error was generated |
path |
The request URL |
traceId |
Correlation ID — include this when opening a support ticket |
errorInfo |
Exception payload — typically { message: string }, occasionally richer when validation collects multiple failures |
There is no machine-readable error.code. Branch on HTTP status in your code; read errorInfo.message when you need to show or log human-readable detail.
Common Failure Modes
401 — Access token invalid or expired
Access tokens are valid for 1 hour. When they expire, mutating endpoints return 401 with a message like "Invalid or expired token".
Fix: Exchange your API key for a fresh token.
POST /v1/auth/token
{ "grantType": "api_key", "apiKey": "sk_ns_live_..." }
See A Full Guide to API Keys and JWTs.
403 — Insufficient scope
Your token is valid, but the API key it came from doesn't carry the required scope for this endpoint. The errorInfo.message names the missing scope.
Fix: Mint a new API key in Studio (Settings → API Keys) with the needed scopes and re-authenticate. Scopes are chosen at key-creation time; you can't grant them to an existing key.
404 — Resource not found
The resource ID you referenced doesn't exist in your namespace, or you've crossed org boundaries.
Fix: Verify the UUID and that your token targets the correct org + namespace. Each namespace is an isolated data boundary — a token scoped to one namespace can't see resources in another, even within the same org.
400 — Validation or missing If-Match
Two common shapes:
- Validation error on create/update:
errorInfo.messagedescribes what's wrong with the body. - Missing or malformed
If-Matchheader on a mutation that requires optimistic locking: the message will say"The 'If-Match' header is required."or"Invalid 'If-Match' format..."
409 — Conflict (concurrent update)
SignStack uses optimistic locking via the standard If-Match HTTP header. When you update a resource, you send:
If-Match: "1729520000000"
The value is the resource's updatedAt timestamp as milliseconds since epoch. The server compares it against the current stored value — if they match, your update goes through; if another request has modified the resource since you read it, the server returns:
{
"statusCode": 409,
"errorInfo": {
"message": "Workflow has been modified by another process. Please refresh and try again."
}
}
Fix: Re-fetch the resource, reapply your changes on top of the fresh state, and retry with the new updatedAt as your If-Match.
Handling Errors in Code
async function updateWorkflow(
orgId: string,
namespaceKey: string,
id: string,
updates: UpdateWorkflowReq,
ifMatch: number
): Promise<Workflow> {
const res = await fetch(
`https://api.signstack.ai/v1/orgs/${orgId}/namespaces/${namespaceKey}/workflows/${id}`,
{
method: 'PATCH',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'If-Match': `"${ifMatch}"`,
},
body: JSON.stringify(updates),
}
);
if (res.ok) return res.json();
const body = await res.json();
const message = body?.errorInfo?.message ?? 'Unknown error';
const traceId = body?.traceId;
switch (res.status) {
case 401:
await refreshAccessToken();
return updateWorkflow(orgId, namespaceKey, id, updates, ifMatch);
case 409: {
// Another writer changed the workflow — refetch, reapply, retry once
const fresh = await getWorkflow(orgId, namespaceKey, id);
return updateWorkflow(
orgId,
namespaceKey,
id,
mergeUpdates(fresh, updates),
new Date(fresh.updatedAt).getTime()
);
}
default:
throw new ApiError(message, res.status, traceId);
}
}
For Python or any other language, the pattern is identical — branch on response.status_code and read errorInfo.message.
Best Practices
1. Retry only transient failures
5xx, network errors, and 429 are safe to retry with exponential backoff. Other 4xx errors indicate a client-side problem — retrying without a change will produce the same result.
async function withRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
const isTransient =
err instanceof ApiError && (err.status >= 500 || err.status === 429);
if (!isTransient || attempt === maxRetries - 1) throw err;
// Honor Retry-After when present (typically on 429); otherwise fall back
// to exponential backoff.
const retryAfterMs = err.retryAfterSeconds
? err.retryAfterSeconds * 1000
: Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
await sleep(retryAfterMs);
}
}
throw new Error('unreachable');
}
2. Refresh access tokens proactively
Tokens live for 1 hour. Check expiresAt from the token response and refresh a few minutes before expiry rather than reactively on 401:
function isTokenExpiringSoon(expiresAt: string): boolean {
const buffer = 5 * 60 * 1000; // 5 minutes
return Date.now() > new Date(expiresAt).getTime() - buffer;
}
3. Always send If-Match on mutations
For updates, include the last-seen updatedAt.getTime() as the If-Match header. This turns silent overwrites into explicit 409 conflicts you can reconcile.
4. Log traceId on every failure
When you surface an error (to your own logs or a user-facing support ticket), always include the traceId from the response body. That's the correlation key SignStack support will ask for.
console.error('SignStack API error:', {
status: res.status,
message: body.errorInfo?.message,
path: body.path,
traceId: body.traceId,
});
Webhook Error Handling
Webhook deliveries use configurable exponential backoff. Defaults:
| Setting | Default |
|---|---|
| Strategy | Exponential (2 ^ attempt, configurable) |
| Starting delay | 60 seconds |
| Max doublings | 10 (backoff stops growing after ~17 h of one delay) |
| Max single backoff | 12 hours |
| Max attempts | 20 |
| Max total duration | 72 hours |
| Jitter | Off by default (full jitter available) |
So a failing endpoint will be retried 20 times over up to 3 days, with delays growing roughly 60s → 2m → 4m → 8m → 16m → 32m → 1h → 2h → 4h → 8h → 12h (capped), 12h, 12h, …
Your endpoint should…
- Return 2xx on successful processing. Delivery is marked successful and no further attempts are made.
- Return 4xx for events you're intentionally rejecting (bad signature, unknown event type, etc.) — SignStack will still retry, but you can log to detect persistent drops.
- Return 5xx (or time out / disconnect) for transient failures. This triggers the retry schedule above.
- Implement idempotency on
eventIdso replays after transient failures are safe.
Webhook event payload
Every delivered webhook body follows this shape:
{
"eventType": "workflow.completed",
"eventId": "e1b2c3d4-5678-4abc-9def-012345678901",
"timestamp": "2026-04-21T14:32:10.123Z",
"organizationId": "f1e2d3c4-b5a6-4789-a012-345678901234",
"data": {
/* event-specific shape */
}
}
Headers also carry X-Webhook-Event-Id and X-Webhook-Event-Type for routing without parsing the body.
Idempotent consumer pattern
app.post('/webhooks/signstack', async (req, res) => {
const { eventId, eventType, data } = req.body;
if (await isEventProcessed(eventId)) {
return res.status(200).json({ status: 'already_processed' });
}
try {
await processEvent(eventType, data);
await markEventProcessed(eventId);
res.status(200).json({ status: 'processed' });
} catch (err) {
console.error('Webhook processing failed:', err);
res.status(500).json({ status: 'error' }); // triggers retry
}
});
Related
- A Full Guide to API Keys and JWTs — Access token lifecycle
- Namespaces — How namespace + mode scope errors like 404 and 403
- Using Webhooks for Real-Time Updates — Full webhook setup
