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:
- Receives a
POSTfrom SignStack with a signed JSON envelope - Verifies the signature using the secret you stored at create time
- Rejects replayed events (timestamp older than 5 minutes)
- Deduplicates by
eventId - Returns a
2xxquickly 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
secretis shown once and never again. Store it in your secret manager immediately. If you lose it, rotate — never request it back. TheGETendpoints 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:
apiVersionidentifies the payload schema. Branch on this in your handler — when we ship a backwards-incompatible change todatafor any event type, the new shape goes out under a new version ("2", ...). Existing code keeps receiving the version it was built against.namespaceKey+orgIdidentify 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/jsonX-Webhook-Signature: t=<unix-ms>,v1=<sig>[,v1=<sig>...]— same scheme used by Stripe / SvixX-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:
- Parse the
X-Webhook-Signatureheader — extracttand everyv1=entry - Replay-check — reject if
|now - t| > 5 minutes - Compute the expected signature with your stored secret(s)
- 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-Signatureheader carries twov1=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:
- Call rotate; capture the new
secretandpreviousSecretExpiresAtfrom the response - In your secret store: copy the value currently in
SIGNSTACK_WEBHOOK_SECRETto a newSIGNSTACK_WEBHOOK_SECRET_PREV, then setSIGNSTACK_WEBHOOK_SECRETto the new value - Deploy — your verifier now accepts deliveries signed with either secret
- After
previousSecretExpiresAtpasses, dropSIGNSTACK_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
- Pause —
PATCH /v1/orgs/{orgId}/namespaces/{namespaceKey}/webhook-endpoints/{id}with{ "isActive": false }. The endpoint is preserved; events stop firing; resume by settingisActive: trueagain. Use when your endpoint is temporarily down for maintenance. - Delete —
DELETE /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)thenJSON.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 againstt=— 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 === expectedleaks timing info. Usecrypto.timingSafeEqual/hmac.compare_digest. - Doing real work synchronously. Slow handlers cause retry storms. Verify, ack, then process.
Related
- 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
