Receive events with webhooks
Webhooks tell your server when something happens to an envelope: when it is opened, when each person signs, and when the signed PDF is ready. No polling, and no waiting.
How it works
You give SignSecure a URL. Whenever something happens to one of your envelopes, SignSecure sends an HTTP POST to that URL with an event as JSON. Your server checks the signature, does what it needs to, and answers with a 2xx status.
Every event carries the whole envelope as it was at that moment, including your metadata, so you rarely need to call the API back to find out what changed.
{
"id": "evt_4e6a8c0b2d3f5a7c9e1b2d",
"object": "event",
"type": "envelope.completed",
"created": 1791583201,
"data": {
"object": {
"id": "env_3f9a1c7e2b8d4a6f9c0e1b",
"object": "envelope",
"status": "completed",
"subject": "Order form, Northwind Traders",
"metadata": {
"crm_record": "006Qy00000AbCdE"
},
"completed_at": 1791583200,
"documents": [
{
"id": "doc_1b4d7f0a3c6e9b2d5f8a1c",
"signed_sha256": "4b8e2f6a0d3c7b1e5f9a2d6c0b4e8f3a7d1c5b9e2f6a0c4d8b3e7f1a5c9d2b6e",
"…": "…"
}
],
"…": "…"
}
}
}1. Create an endpoint
An endpoint is a URL on your server that accepts POST requests. It must be https, on a public hostname, on port 443 or 8443. SignSecure refuses IP addresses and private hostnames, and never follows redirects.
Add it under Developers in the app, or with the API using a key made by an admin. Choose the events you want, or all of them. Listening only to what you use keeps your server quieter.
The response includes the endpoint's signing secret, starting whsec_. It is shown once. Store it with your other secrets.
curl https://www.signsecure.org/v1/v1/webhook_endpoints \
-H "Authorization: Bearer $SIGNSECURE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.yourcompany.com/signsecure",
"enabled_events": [
"envelope.completed",
"envelope.declined"
]
}'const res = await fetch('https://www.signsecure.org/v1/v1/webhook_endpoints', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SIGNSECURE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://hooks.yourcompany.com/signsecure',
enabled_events: [
'envelope.completed',
'envelope.declined'
]
}),
});
const endpoint = await res.json();import os
import requests
res = requests.post(
"https://www.signsecure.org/v1/v1/webhook_endpoints",
headers={"Authorization": f"Bearer {os.environ['SIGNSECURE_API_KEY']}"},
json={
"url": "https://hooks.yourcompany.com/signsecure",
"enabled_events": [
"envelope.completed",
"envelope.declined"
]
},
)
endpoint = res.json(){
"id": "we_8a0c2e4b6d1f3a5c7e9b0d",
"object": "webhook_endpoint",
"url": "https://hooks.yourcompany.com/signsecure",
"enabled_events": [
"envelope.completed",
"envelope.declined"
],
"status": "enabled",
"secret": "whsec_Xq3vK8mN2pR5tW9yB4cF7hJ0lS6dG1aE3uZ8iO2nV5k"
}2. Write the handler
Your handler needs to do three things:
- Accept a
POSTwith a JSON body. - Check the signature, using the raw body exactly as it arrived.
- Answer with a
2xxstatus within 10 seconds. Anything else, or no answer, counts as a failure and is retried.
Check the signature against the raw request body. Many frameworks parse JSON before your code sees it, and re-serialising the parsed object rarely gives back the same bytes, so the signature will not match.
If your framework protects POST routes against cross-site request forgery, exempt this route: the signature is its protection.
import crypto from 'node:crypto';
import express from 'express';
const secret = process.env.SIGNSECURE_WEBHOOK_SECRET; // whsec_...
const app = express();
// The signature is over the raw bytes, so read the body raw.
app.post('/signsecure', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('SignSecure-Signature') || '';
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const timestamp = Number(parts.t);
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${req.body}`)
.digest('hex');
const valid = parts.v1
&& expected.length === parts.v1.length
&& crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
const fresh = Math.abs(Date.now() / 1000 - timestamp) < 300;
if (!valid || !fresh) return res.status(400).send('Bad signature');
const event = JSON.parse(req.body);
if (event.type === 'envelope.completed') {
const envelope = event.data.object;
// Find your record with envelope.metadata.crm_record, mark it signed,
// and download the signed PDF. Do the slow work after responding.
}
res.sendStatus(200);
});
app.listen(3000);import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
secret = os.environ["SIGNSECURE_WEBHOOK_SECRET"] # whsec_...
app = Flask(__name__)
@app.post("/signsecure")
def signsecure():
header = request.headers.get("SignSecure-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
payload = request.get_data() # the raw bytes, exactly as sent
expected = hmac.new(
secret.encode(), f"{parts.get('t', '')}.".encode() + payload, hashlib.sha256
).hexdigest()
valid = hmac.compare_digest(expected, parts.get("v1", ""))
fresh = abs(time.time() - int(parts.get("t", 0))) < 300
if not (valid and fresh):
return "Bad signature", 400
event = json.loads(payload)
if event["type"] == "envelope.completed":
envelope = event["data"]["object"]
# Find your record with envelope["metadata"]["crm_record"] and update it.
return "", 2003. Check the signature
Anyone who finds your endpoint's URL can send it a request, so check that each one came from SignSecure before you act on it. Every delivery has a SignSecure-Signature header:
SignSecure-Signature: t=1791583201,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
t is when the delivery was signed, in Unix seconds. v1 is an HMAC-SHA256 signature, in lower-case hex, made with your endpoint's signing secret. To check it:
- Split the header. Split on
,and then on=to gettandv1. Ignore any other schemes. - Build the signed payload. Join
t, a full stop, and the raw request body:t + "." + body. - Compute the expected signature. HMAC-SHA256 of the signed payload, keyed with the whole secret including
whsec_, as hex. - Compare. Compare your result with
v1using a constant-time comparison, and check thattis within five minutes of now.
The handlers in step 2 do exactly this in a few lines. A timing-safe comparison matters: an ordinary string comparison can leak how much of a forged signature was right.
1791583201.{"id":"evt_4e6a8c0b2d3f5a7c9e1b2d","object":"event","type":"envelope.completed",…}Replay protection
A replay attack is someone capturing a genuine delivery and sending it to you again later. The timestamp is inside the signed payload, so it cannot be changed without breaking the signature; refusing anything older than five minutes stops an old delivery from being reused.
Each retry is signed afresh with a new timestamp, so a retried delivery passes this check. Keep your server's clock right with NTP, or genuine deliveries will start failing it.
Delivery and retries
SignSecure tries each delivery as soon as the event happens. If your server does not answer with a 2xx within 10 seconds, it tries again with growing gaps: within about five minutes, then after 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours. That is eight attempts over about two days. Redirects count as failures.
If an endpoint fails 50 attempts in a row, SignSecure switches it off and the Developers screen says so. Turn it back on there, or with the API, once it is fixed. Nothing is lost while it is off: events stay readable through List all events for 30 days.
Events are not guaranteed to arrive in order. Use each event's created time, or the envelope's status, rather than the order of arrival.
Best practices
Handle duplicates
A delivery can occasionally arrive twice, for example when your server did the work but its response was lost. Record the event id (also sent as the SignSecure-Event-Id header) and skip any you have already handled.
Answer quickly, work later
Return 2xx as soon as the signature checks out, and do slow work such as downloading the signed PDF or updating your CRM in a background job.
Subscribe to what you use
Choose specific event types rather than all of them, so your server only hears about what it acts on.
Catch up after an outage
If your endpoint was down, page through List all events from where you stopped, rather than waiting for retries.
Keep the secret secret
If you think a signing secret has leaked, create a new endpoint, move your handler to its secret, then delete the old one.
Use metadata to find your record
Put your own record id in the envelope's metadata when you create it. It is in every event, so the handler never has to search.
Events you can receive
| Type | Occurs when |
|---|---|
envelope.sent | An envelope was sent for signature. |
recipient.viewed | A signer opened the document for the first time. |
recipient.signed | A signer finished signing. Others may still need to. |
envelope.completed | Everyone has signed. The signed PDF is ready to download. |
envelope.declined | A signer declined. Nobody else will be asked to sign. |
envelope.voided | The envelope was voided. |
envelope.expired | The signing links ran out before everyone signed. |
New types may be added. Ignore any you do not recognise, and answer 2xx anyway.
Troubleshooting
| What you see | Likely cause | What to do |
|---|---|---|
| Every signature check fails | The body was parsed or re-encoded before checking. | Check the raw bytes, before any JSON middleware. |
Some checks fail with an old t | Your server's clock has drifted. | Sync the clock with NTP. |
Last status is 301 or 302 | Your URL redirects, perhaps from http to https or to a trailing slash. | Use the final URL. Redirects are not followed. |
| Last status is empty | Your server took longer than 10 seconds, or could not be reached. | Respond first, then do the work. |
| The endpoint shows as disabled | It failed 50 attempts in a row. | Fix it, then turn it back on in Developers. |