Webhooks
Signed notifications to your server when documents change.
Add an endpoint
Add an HTTPS endpoint in Settings, Developers, or with POST /webhooks. Choose the events you want, or * for all of them. The response includes the signing secret (whsec_…); you can see it again later in the app or with GET /webhooks/{id}.
curl -X POST "https://api.esorasign.com/v1/webhooks" \
-H "Authorization: Bearer $ESORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/esora/webhooks", "events": ["document.completed", "recipient.signed"] }'Endpoints must be public HTTPS addresses. Private and local network addresses are refused.
What we send
Each delivery is a POST with a JSON event and these headers:
- Esora-Signature: t=1762162887,v1=5f1c… (see below)
- Esora-Event-Id: the event’s ID (evt_…). Use it to skip duplicates.
- Esora-Delivery-Id: this delivery attempt’s ID (dlv_…).
- User-Agent: eSora-Sign-Webhooks/1.0
data.object is the full document as GET /documents/{id} returns it. Recipient events also carry data.recipient_id.
{
"id": "evt_5ydfhl3TvtnythpZPPPP6U",
"object": "event",
"type": "recipient.signed",
"created_at": "2026-11-03T09:41:27.000Z",
"data": {
"object": {
"id": "doc_3y9Pf34qY6Nb3wWD25RQ4F",
"object": "document",
"status": "sent",
"title": "Service agreement - Northwind",
"recipients": [ { "id": "rcp_5ZR3qa7yEeeby3abP3E2Zs", "object": "recipient", "status": "signed", "...": "..." } ],
"metadata": { "crm_deal_id": "D-10442" },
"...": "the full document object"
},
"recipient_id": "rcp_5ZR3qa7yEeeby3abP3E2Zs"
}
}Verify the signature
v1 is an HMAC-SHA256 of the timestamp, a dot and the raw request body, using your signing secret. Compute it yourself and compare in constant time. Reject old timestamps (5 minutes is a good limit) to stop replays. Use the raw body: parsing and re-serializing JSON changes it.
// Node 18 or later with Express (npm install express).
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.ESORA_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 300;
function verify(rawBody, header) {
const parts = header.split(',').map((p) => p.split('='));
const t = Number(parts.find(([k]) => k === 't')?.[1]);
const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
if (!t || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', SECRET).update(`${t}.${rawBody}`).digest('hex');
return sigs.some((s) => s.length === expected.length && timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}
const app = express();
app.post('/esora/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.body.toString('utf8'), req.get('Esora-Signature') ?? '')) return res.sendStatus(400);
const event = JSON.parse(req.body);
// Deliveries can repeat: skip event.id values you have already handled.
if (event.type === 'document.completed') {
console.log('completed', event.data.object.id, event.data.object.metadata);
}
res.sendStatus(200); // answer within 10 seconds; do slow work afterwards
});
app.listen(3000);
# Python 3.9 or later with Flask (pip install flask).
import hashlib, hmac, os, time
from flask import Flask, request, abort
SECRET = os.environ["ESORA_WEBHOOK_SECRET"].encode() # whsec_...
app = Flask(__name__)
def verify(raw_body: bytes, header: str) -> bool:
items = [p.split("=", 1) for p in header.split(",") if "=" in p]
t = next((v for k, v in items if k == "t"), None)
sigs = [v for k, v in items if k == "v1"]
if not t or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(SECRET, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(s, expected) for s in sigs)
@app.post("/esora/webhooks")
def esora_webhooks():
if not verify(request.get_data(), request.headers.get("Esora-Signature", "")):
abort(400)
event = request.get_json()
if event["type"] == "document.completed":
print("completed", event["data"]["object"]["id"])
return "", 200
Retries and rotation
- Answer with any 2xx status within 10 seconds. Redirects are not followed and count as failures.
- Failed deliveries are retried up to 16 times, waiting longer each time (from 10 seconds up to an hour), for about 8 hours in all. Then the delivery is marked failed.
- See recent deliveries in the app or with GET /webhooks/{id}/deliveries, and send one again with POST /webhooks/{id}/deliveries/{delivery_id}/retry.
- POST /webhooks/{id}/test sends a webhook.test event right away and tells you how your endpoint answered.
- Rotate the secret with POST /webhooks/{id}/rotate-secret. For 24 hours we sign with both the new and the old secret (two v1 values), so you can switch without missing anything.
Events are also kept for 90 days at GET /events, so you can catch up after an outage.