Error Handling

SignStack uses conventional HTTP response codes to indicate the success or failure of API requests. This guide covers error responses, common causes, and how to handle them in your applications.

HTTP Status Codes

Code Meaning
200 Success - Request completed successfully
201 Created - Resource was created successfully
400 Bad Request - Invalid request parameters or body
401 Unauthorized - Missing or invalid authentication
403 Forbidden - Valid auth, but insufficient permissions
404 Not Found - Resource doesn't exist
409 Conflict - Resource state conflict (e.g., concurrent edit)
422 Unprocessable Entity - Validation error
429 Too Many Requests - Rate limit exceeded
500 Internal Server Error - Something went wrong on our end

Error Response Format

Error responses include a JSON body with details:

{
  "error": {
    "code": "validation_error",
    "message": "The request body is invalid",
    "details": [
      {
        "field": "email",
        "message": "Must be a valid email address"
      }
    ]
  }
}
Field Description
error.code Machine-readable error code
error.message Human-readable description
error.details Array of specific issues (when applicable)

Common Error Codes

Authentication Errors (401)

invalid_token

{
  "error": {
    "code": "invalid_token",
    "message": "The access token is invalid or expired"
  }
}

Causes:

  • Token has expired (tokens are valid for ~1 hour)
  • Token was revoked
  • Malformed Authorization header

Solution: Exchange your API key for a fresh token:

POST /v1/auth/token
{ "grantType": "api_key", "apiKey": "sk_live_..." }

Permission Errors (403)

insufficient_scope

{
  "error": {
    "code": "insufficient_scope",
    "message": "Token lacks required scope: workflow:write"
  }
}

Causes:

  • API key doesn't have the required scopes
  • Trying to access resources from another organization

Solution: Create a new API key with the required scopes in your dashboard.

Validation Errors (400, 422)

validation_error

{
  "error": {
    "code": "validation_error",
    "message": "Request validation failed",
    "details": [
      { "field": "participants[0].email", "message": "Required field is missing" },
      { "field": "entitySlots[1].schemaKey", "message": "Schema 'invalid-schema' not found" }
    ]
  }
}

Causes:

  • Missing required fields
  • Invalid field types or formats
  • Reference to non-existent resources (schemas, templates, etc.)

Solution: Check the details array for specific field issues.

Resource Not Found (404)

not_found

{
  "error": {
    "code": "not_found",
    "message": "Workflow wf_abc123 not found"
  }
}

Causes:

  • Resource was deleted
  • Incorrect ID or key
  • Resource belongs to different organization

Solution: Verify the resource ID and your organization context.

Conflict Errors (409)

conflict

{
  "error": {
    "code": "conflict",
    "message": "Resource was modified by another request",
    "details": [
      { "field": "updatedAt", "message": "Expected 2024-01-15T10:00:00Z but resource was updated at 2024-01-15T10:05:00Z" }
    ]
  }
}

Causes:

  • Concurrent updates to the same resource
  • Stale updatedAt timestamp in request

Solution: Re-fetch the resource, merge your changes, and retry with the current updatedAt value.

Rate Limit Errors (429)

rate_limit_exceeded

{
  "error": {
    "code": "rate_limit_exceeded",
    "message": "Too many requests"
  }
}

Headers included:

  • Retry-After: Seconds to wait before retrying
  • X-RateLimit-Limit: Request limit per window
  • X-RateLimit-Remaining: Remaining requests in window

Solution: Implement exponential backoff and respect the Retry-After header.

Handling Errors in Code

JavaScript/TypeScript

async function createWorkflow(data: CreateWorkflowReq): Promise<Workflow> {
  const response = await fetch(`https://api.signstack.ai/v1/orgs/${orgId}/workflows`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(data)
  });

  if (!response.ok) {
    const error = await response.json();

    switch (response.status) {
      case 401:
        // Token expired - refresh and retry
        await refreshToken();
        return createWorkflow(data);

      case 422:
        // Validation error - log details for debugging
        console.error('Validation failed:', error.error.details);
        throw new ValidationError(error.error.message, error.error.details);

      case 429:
        // Rate limited - wait and retry
        const retryAfter = parseInt(response.headers.get('Retry-After') || '60');
        await sleep(retryAfter * 1000);
        return createWorkflow(data);

      default:
        throw new ApiError(error.error.message, response.status);
    }
  }

  return response.json();
}

Python

import requests
import time

def create_workflow(data):
    response = requests.post(
        f'https://api.signstack.ai/v1/orgs/{org_id}/workflows',
        headers={'Authorization': f'Bearer {access_token}'},
        json=data
    )

    if response.status_code == 401:
        # Token expired - refresh and retry
        refresh_token()
        return create_workflow(data)

    if response.status_code == 429:
        # Rate limited - wait and retry
        retry_after = int(response.headers.get('Retry-After', 60))
        time.sleep(retry_after)
        return create_workflow(data)

    if not response.ok:
        error = response.json().get('error', {})
        raise ApiError(error.get('message'), response.status_code)

    return response.json()

Best Practices

1. Implement Retry Logic

For transient errors (429, 500, 502, 503, 504), implement exponential backoff:

async function withRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T> {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries - 1) throw error;
      if (!isRetryable(error)) throw error;

      const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
      await sleep(delay);
    }
  }
}

2. Handle Token Expiration

Access tokens expire after ~1 hour. Proactively refresh before expiration:

function isTokenExpiringSoon(expiresAt: string): boolean {
  const expiry = new Date(expiresAt);
  const buffer = 5 * 60 * 1000; // 5 minutes
  return Date.now() > expiry.getTime() - buffer;
}

3. Log Error Details

Always log the full error response for debugging:

if (!response.ok) {
  const error = await response.json();
  console.error('API Error:', {
    status: response.status,
    code: error.error?.code,
    message: error.error?.message,
    details: error.error?.details,
    requestId: response.headers.get('X-Request-Id')
  });
}

4. Use Optimistic Locking

For update operations, include the updatedAt timestamp to detect concurrent modifications:

const workflow = await getWorkflow(workflowId);

await updateWorkflow(workflowId, {
  ...changes,
  updatedAt: workflow.updatedAt // Include for conflict detection
});

5. Validate Before Sending

Catch obvious errors client-side before making API calls:

function validateWorkflowRequest(data: CreateWorkflowReq): void {
  if (!data.blueprintKey && !data.documents?.length) {
    throw new Error('Either blueprintKey or documents required');
  }

  for (const participant of data.participants ?? []) {
    if (!participant.email?.includes('@')) {
      throw new Error(`Invalid email for participant: ${participant.email}`);
    }
  }
}

Webhook Error Handling

For webhook deliveries, SignStack retries failed deliveries with exponential backoff:

Attempt Delay
1 Immediate
2 1 minute
3 5 minutes
4 30 minutes
5 2 hours

Your webhook endpoint should:

  • Return 2xx status for successful processing
  • Return 4xx for invalid/rejected events (won't retry)
  • Implement idempotency using the eventId field
app.post('/webhooks/signstack', async (req, res) => {
  const { eventId, eventType, data } = req.body;

  // Check if already processed
  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 (error) {
    console.error('Webhook processing failed:', error);
    res.status(500).json({ status: 'error' }); // Triggers retry
  }
});