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.message describes what's wrong with the body.
  • Missing or malformed If-Match header 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 eventId so 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
  }
});