Using Webhooks for Real-Time Updates

Polling GET /workflows/{id} works for prototypes, but for production integrations webhooks are the right tool: SignStack pushes a signed JSON event to your endpoint as soon as something interesting happens — no polling, no missed transitions, no wasted API calls.

This guide walks through the full integration: registering an endpoint, receiving and verifying events, handling rotation, and operating reliably.

What you'll build

An HTTPS endpoint in your app that:

  1. Receives a POST from SignStack with a signed JSON envelope
  2. Verifies the signature using the secret you stored at create time
  3. Rejects replayed events (timestamp older than 5 minutes)
  4. Deduplicates by eventId
  5. Returns a 2xx quickly and processes the event asynchronously

1. Register a webhook endpoint

Create the endpoint in Studio (Settings → Webhooks) or via the API:

POST /v1/orgs/{orgId}/namespaces/{namespaceKey}/webhook-endpoints

{
  "url": "https://your-app.example.com/webhooks/signstack",
  "description": "Production CRM notifications",
  "eventTypes": [
    "workflow.completed",
    "workflow.failed",
    "workflow.declined",
    "participant.signing_completed"
  ]
}

Available event types (snake-case dot notation):

  • Workflow: workflow.started, workflow.completed, workflow.failed, workflow.declined, workflow.voided
  • Step: step.started, step.completed, step.failed
  • Participant: participant.task_assigned, participant.email_sent, participant.task_viewed, participant.signing_completed, participant.signing_declined
  • Envelope: envelope.sent, envelope.completed, envelope.voided

Response — your one chance to grab the secret:

{
  "id": "a1b2c3d4-1234-4567-8910-abcdef012345",
  "url": "https://your-app.example.com/webhooks/signstack",
  "eventTypes": ["workflow.completed", "..."],
  "isActive": true,
  "secret": "zhqQhy2u8zKAa2z7wsAjYy/XDrqQm9xB21eO5LQXc90=",
  "createdAt": "2026-04-25T14:11:42.000Z",
  ...
}

⚠️ The secret is shown once and never again. Store it in your secret manager immediately. If you lose it, rotate — never request it back. The GET endpoints intentionally don't return the secret.

2. Receive the event

Every delivery is a POST with a JSON envelope:

{
  "apiVersion": "1",
  "eventType": "workflow.completed",
  "eventId": "a1b2c3d4-1234-4567-8910-abcdef012345",
  "timestamp": "2026-04-25T14:30:00.000Z",
  "orgId": "org_...",
  "namespaceKey": "production",
  "mode": "live",
  "data": {
    "workflowId": "a1b2c3d4-1234-4567-8910-abcdef012345",
    "status": "completed"
    // event-specific fields
  }
}

Routing fields:

  • apiVersion identifies the payload schema. Branch on this in your handler — when we ship a backwards-incompatible change to data for any event type, the new shape goes out under a new version ("2", ...). Existing code keeps receiving the version it was built against.
  • namespaceKey + orgId identify which webhook endpoint fired. Useful when a single handler URL receives events from multiple namespaces (e.g., a multi-tenant SaaS that creates one namespace per its own customer).
  • mode"test" or "live", mirrors the namespace's mode. Critical safety signal: branch on this to make sure test workflows never write to your production systems.

3. Verify the signature (REQUIRED)

The HTTP request carries:

  • Content-Type: application/json
  • X-Webhook-Signature: t=<unix-ms>,v1=<sig>[,v1=<sig>...] — same scheme used by Stripe / Svix
  • X-Webhook-Event-Type, X-Webhook-Event-Id, X-Webhook-Attempt — convenience headers (the same values are also in the body)

The signature is computed as:

HMAC-SHA256(secret, '<t>.<raw-body>')

where <t> is the value from the t= part of the header and <raw-body> is the request body as bytes (don't re-serialize the parsed JSON — key ordering and whitespace matter).

Verification is four steps:

  1. Parse the X-Webhook-Signature header — extract t and every v1= entry
  2. Replay-check — reject if |now - t| > 5 minutes
  3. Compute the expected signature with your stored secret(s)
  4. Constant-time compare against every v1= candidate; pass if any match

Step 4's "every candidate" is what makes graceful rotation work — during a grace window we sign with both the old and the new secret and emit one v1= per active secret.

Node.js example

import crypto from 'node:crypto';
import express from 'express';

const app = express();

// IMPORTANT: capture the raw body for HMAC verification.
// express.json() would re-serialize and break the signature match.
app.use('/webhooks/signstack', express.raw({ type: 'application/json' }));

app.post('/webhooks/signstack', (req, res) => {
  const secret = process.env.SIGNSTACK_WEBHOOK_SECRET; // also keep PREVIOUS secret during rotation
  const header = req.header('X-Webhook-Signature') || '';

  // 1. Parse t= and v1= entries
  let t;
  const candidates = [];
  for (const part of header.split(',')) {
    const [k, v] = part.trim().split('=');
    if (k === 't') t = v;
    else if (k === 'v1' && v) candidates.push(v);
  }
  if (!t || candidates.length === 0) return res.status(400).send('bad signature');

  // 2. Replay protection (5 min)
  if (Math.abs(Date.now() - Number(t)) > 5 * 60 * 1000) {
    return res.status(400).send('stale');
  }

  // 3. Compute expected for our stored secret(s).
  //    During rotation, check against [previousSecret, currentSecret].
  const secrets = [secret, process.env.SIGNSTACK_WEBHOOK_SECRET_PREV].filter(Boolean);
  const expected = secrets.map((s) =>
    crypto.createHmac('sha256', s).update(`${t}.${req.body}`).digest('hex')
  );

  // 4. Constant-time compare against every candidate × every stored secret
  const ok = candidates.some((c) =>
    expected.some((e) =>
      e.length === c.length &&
      crypto.timingSafeEqual(Buffer.from(c), Buffer.from(e))
    )
  );
  if (!ok) return res.status(403).send('bad signature');

  // Verified. Now process — see step 4.
  const event = JSON.parse(req.body.toString('utf8'));
  // ... handle the event
  res.sendStatus(200);
});

Python example

import hmac, hashlib, os, time, json
from flask import Flask, request, abort

app = Flask(__name__)

@app.post('/webhooks/signstack')
def signstack_webhook():
    header = request.headers.get('X-Webhook-Signature', '')
    raw = request.get_data()  # raw bytes — do NOT use request.json

    # 1. Parse t= and v1=
    t, candidates = None, []
    for part in header.split(','):
        k, _, v = part.strip().partition('=')
        if k == 't':
            t = v
        elif k == 'v1' and v:
            candidates.append(v)
    if not t or not candidates:
        abort(400, 'bad signature')

    # 2. Replay protection (5 min)
    if abs(int(time.time() * 1000) - int(t)) > 5 * 60 * 1000:
        abort(400, 'stale')

    # 3. Compute expected for stored secret(s)
    secrets = [s for s in [os.environ['SIGNSTACK_WEBHOOK_SECRET'],
                           os.environ.get('SIGNSTACK_WEBHOOK_SECRET_PREV')] if s]
    expected = [
        hmac.new(s.encode(), f'{t}.'.encode() + raw, hashlib.sha256).hexdigest()
        for s in secrets
    ]

    # 4. Constant-time compare
    ok = any(hmac.compare_digest(c, e) for c in candidates for e in expected)
    if not ok:
        abort(403, 'bad signature')

    event = json.loads(raw)
    # ... handle the event
    return '', 200

4. Process and respond quickly

Once verified, respond 2xx within a few seconds — SignStack treats a non-2xx response or timeout as a delivery failure and queues a retry. The actual processing (DB writes, downstream API calls, sending emails) should happen asynchronously in a job queue.

Idempotency: dedupe by eventId

Webhook events are delivered at least once. The same eventId may arrive twice — for example if your endpoint takes too long to reply and we retry. Make your handler idempotent:

// Pseudo-code
if (await alreadyProcessed(event.eventId)) {
  return res.sendStatus(200); // ack, but don't re-process
}
await markProcessed(event.eventId);
await processEvent(event);
res.sendStatus(200);

A simple processed_webhook_events(eventId UNIQUE) table is enough.

Retries

Failed deliveries are retried with exponential backoff for up to 3 days. After that the delivery is marked failed and we stop. New events on the same webhook endpoint keep firing — failed deliveries don't auto-disable the endpoint (yet).

You can inspect attempt history via GET /v1/orgs/{orgId}/namespaces/{namespaceKey}/webhook-deliveries.

Rotating the secret

If your secret is lost or compromised, rotate it via Studio (Webhooks → Rotate secret action) or:

POST /v1/orgs/{orgId}/namespaces/{namespaceKey}/webhook-endpoints/{id}/rotate-secret

{ "gracePeriod": "24h" }

Response:

{
  "secret": "<new-secret>",
  "previousSecretExpiresAt": "2026-04-26T14:11:42.000Z"
}

What happens during the grace window:

  • The new secret takes over as the primary signer
  • The previous secret stays valid for verification until previousSecretExpiresAt
  • Every delivery's X-Webhook-Signature header carries two v1= entries — one signed with each secret
  • Your verification code matches against both your current AND your previous stored secret — either match passes

Recommended consumer flow:

  1. Call rotate; capture the new secret and previousSecretExpiresAt from the response
  2. In your secret store: copy the value currently in SIGNSTACK_WEBHOOK_SECRET to a new SIGNSTACK_WEBHOOK_SECRET_PREV, then set SIGNSTACK_WEBHOOK_SECRET to the new value
  3. Deploy — your verifier now accepts deliveries signed with either secret
  4. After previousSecretExpiresAt passes, drop SIGNSTACK_WEBHOOK_SECRET_PREV

This means zero dropped events during rotation. The verification snippets above already check both secrets — you don't need extra logic.

Compromised secret? Pass "gracePeriod": "immediate" instead — kills the previous secret instantly. Accept that any deliveries the customer's server hasn't yet verified will fail.

Available grace periods: immediate, 24h (default), 48h, 7d, 14d, 30d.

Pause vs delete

  • PausePATCH /v1/orgs/{orgId}/namespaces/{namespaceKey}/webhook-endpoints/{id} with { "isActive": false }. The endpoint is preserved; events stop firing; resume by setting isActive: true again. Use when your endpoint is temporarily down for maintenance.
  • DeleteDELETE /v1/orgs/{orgId}/namespaces/{namespaceKey}/webhook-endpoints/{id}. Soft-deleted: the row is preserved so historical deliveries remain queryable, but the endpoint disappears from list/get and stops firing. Use when you're done with this integration.

Common pitfalls

  • Re-serializing the body before HMAC. JSON.parse(body) then JSON.stringify(parsed) produces different bytes (key ordering, spaces) — verification fails. Always sign against the raw bytes.
  • Using the parsed JSON timestamp instead of t= from the header. The signature is computed against t= — use that exact value when computing the expected HMAC.
  • Skipping the 5-minute replay check. Without it, anyone who captures one signed delivery can replay it indefinitely.
  • String compare instead of constant-time. actual === expected leaks timing info. Use crypto.timingSafeEqual / hmac.compare_digest.
  • Doing real work synchronously. Slow handlers cause retry storms. Verify, ack, then process.
  • The Webhooks section of the API reference — endpoint shapes, request/response examples
  • Workflows — when each event fires
  • Embedding components — webhooks remain authoritative even when signing happens in your embed