Webhooks and signatures
SignWren posts an event to your HTTPS endpoint each time an envelope changes. Check the signature on every one.
Status. The SignWren API is opening in stages and may not be on your account yet. If Settings shows an API keys section, you can make sandbox keys now; live keys come with the Pro and Business plans. SignWren is an electronic signature app for small businesses; it is not a law firm and does not give legal advice.
How do I subscribe to events?
Add an endpoint with POST /webhook-endpoints, giving an https:// URL and the event types you want (or "*" for all of them). The key needs the webhooks:manage scope, and you can also add endpoints in Settings.
curl "$SIGNWREN_API/webhook-endpoints" \
-H "Authorization: Bearer $SIGNWREN_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/signwren/webhook", "events": ["envelope.completed", "recipient.signed"] }'
# The answer includes "secret": "whsec_...". It is shown this once.Store the secret from the answer; you need it to check signatures and SignWren never shows it again. Each environment has its own endpoints, up to 16, so sandbox events never reach a live endpoint.
The URL has to resolve to public addresses only. Private, loopback, link-local and cloud metadata addresses are refused when you add the endpoint and again before every delivery, and redirects are never followed. To receive events on your laptop, use a tunnelling service that gives you a public HTTPS address.
Which events are there?
There is one event for each change to an envelope or a recipient:
envelope.createdenvelope.sentrecipient.viewedrecipient.signedrecipient.declinedrecipient.withdrew_consentenvelope.completingenvelope.completedenvelope.declinedenvelope.voidedenvelope.expiredenvelope.deleted
The test button in Settings and POST /webhook-endpoints/{webhook_id}/test send a webhook.test event. New event types can be added to /v1, so ignore types you don't know.
What does a delivery look like?
Each delivery is a JSON POST with ids, statuses and your metadata, never document content, names, email addresses or signature images.
POST /signwren/webhook
SignWren-Signature: t=1790812805,v1=9f180999dd3c5224b30dfc773b297c7ca5110b8949528fb6e24216ee199d5100
SignWren-Event-Id: evt_0b9d6a512f0c4d559b2f7a0d6c1e2f30
SignWren-Event-Type: recipient.signed
Content-Type: application/json
{
"id": "evt_0b9d6a512f0c4d559b2f7a0d6c1e2f30",
"type": "recipient.signed",
"created": 1790812805,
"environment": "sandbox",
"livemode": false,
"data": {
"envelope": { "id": "7d1e0c2b-9a8f-4e3d-8c7b-6a5f4e3d2c1b", "status": "sent", "metadata": { "crm_deal": "D-1042" } },
"recipient": { "id": "0a1b2c3d-4e5f-4071-8293-a4b5c6d7e8f9", "status": "signed" }
}
}In events, data.envelope.id and data.recipient.id are UUIDs, not the prefixed ids the REST API uses. Remove the dashes and add env_ or rcp_ to call the API, or use webhooks.envelopeIdOf(event) in the Node SDK.
How do I check that a webhook came from SignWren?
Compute an HMAC-SHA256 of the timestamp, a dot and the raw request body, keyed with your endpoint's secret, and compare it with each v1 value in the SignWren-Signature header using a constant-time comparison. The header looks like t=1790812805,v1=9f18....
- Read the raw body bytes before any JSON parsing; re-serialised JSON won't match.
- Split the header on commas into
tand one or morev1values. - Refuse it if
tis more than 5 minutes from your clock. That stops an old delivery being replayed. - Compute
HMAC-SHA256(secret, t + "." + body)as hex and accept if it equals anyv1, comparing in constant time. - Drop events whose
idyou already handled. Retries and resends keep the same id.
With the Node SDK
import { createServer } from 'node:http';
import { webhooks, WebhookSignatureError } from '@signwren/sdk';
const secret = process.env.SIGNWREN_WEBHOOK_SECRET ?? '';
const seen = new Set(); // use your database in production
createServer(async (req, res) => {
const chunks = [];
for await (const c of req) chunks.push(c);
const raw = Buffer.concat(chunks); // the raw bytes, not re-serialised JSON
try {
const event = webhooks.constructEvent(raw, req.headers['signwren-signature'], secret);
if (seen.has(event.id)) return res.writeHead(200).end(); // a retry you already handled
seen.add(event.id);
const envelopeId = webhooks.envelopeIdOf(event); // env_... for the REST API
// ...update your record, then answer quickly
res.writeHead(200).end();
} catch (err) {
res.writeHead(err instanceof WebhookSignatureError ? 400 : 500).end();
}
}).listen(3000);With Node's crypto module only
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = header.split(',').map((p) => p.trim().split('='));
const t = Number(parts.find(([k]) => k === 't')?.[1]);
const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => Buffer.from(v, 'hex'));
if (Number.isNaN(t) || sigs.length === 0) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
return sigs.some((s) => s.length === expected.length && timingSafeEqual(s, expected));
}What happens when I rotate the secret?
For 24 hours after POST /webhook-endpoints/{webhook_id}/rotate-secret, SignWren signs each delivery with both the new and the old secret, so the header has two v1 values and either one matching is enough. Update your secret during that day, or pass expire_now: true to stop the old one at once.
What if my endpoint is down?
Answer with any 2xx within 10 seconds. Anything else, a timeout or a redirect is retried after 30 seconds, 2 minutes, 10 minutes, 30 minutes, an hour, and then every 6 hours, for up to 3 days. An endpoint that fails every delivery for 3 days is turned off and the owner and admins get an email; turn it back on with enabled: true. The delivery log, in Settings and at GET /webhook-endpoints/{webhook_id}/deliveries, shows each attempt with its status code, how long it took and the first 1 KB of your answer, and any finished delivery can be resent.
Last checked against the API spec on .