Signed, retried JSON events for documents, file requests, data rooms, NDAs and offers.
HelpRange sends a signed JSON POST to your https endpoint the moment something happens: a document is opened,
a file arrives, someone asks to join a data room, accepts an NDA or answers an offer. Set the endpoint in
Settings → Integrations → Webhook (team administrators). Zapier “Catch Hook”, Make, n8n and your own server all work.
| Event | When |
|---|---|
document.opened | Someone opened a shared link. |
file_request.uploaded | A file came in through a file request. |
room.access_requested | A verified visitor asks to join a data room. |
nda.accepted | A visitor accepted (or signed) the NDA of a link or data room. |
document.acknowledged | A data room guest confirmed reading a document. |
offer.accepted | A reader accepted an offer. |
offer.declined | A reader declined an offer. |
offer.contact_requested | A reader asked to be contacted about an offer or document. |
Every event has the same envelope. data depends on the event; new fields may be added at any time, so ignore
fields you do not know. id is unique per event – use it to ignore duplicates.
{
"id": "evt_3f9c2b7e0d1a4c5b8e6f7a9d0c1b2e3f",
"event": "document.opened",
"api_version": "2026-11-15",
"created_at": "2026-11-15T09:12:03.512345Z",
"data": {
"document": "Offer 2026", "file_id": 123, "link": "ACME", "link_id": 456,
"viewer": "jane@acme.com", "country": "PL", "browser": "Chrome", "os": "Mac OS X", "opening_id": 789
}
}
Other data examples:
file_request.uploaded {"request": "Financials 2025", "directory": "Due diligence", "filename": "balance.pdf",
"uploader_name": "Jan Kowalski", "uploader_email": "jan@example.com", "upload_id": 42}
room.access_requested {"room": "Project Falcon", "room_id": 7, "request_id": 31, "email": "cfo@buyer.com",
"name": "Anna Buyer", "company": "Buyer Ltd", "message": "Please add me.", "review_url": "https://…"}
nda.accepted {"nda": "Mutual NDA", "version": 2, "opened": "Project Falcon", "room_id": 7, "link_id": null,
"name": "Anna Buyer", "company": "Buyer Ltd", "email": "cfo@buyer.com", "method": "click",
"text_sha256": "…", "signed_sha256": "", "sealed": true, "certificate_url": "https://…"}
document.acknowledged {"document": "Policy.pdf", "file_id": 123, "version": "3", "room": "Onboarding", "room_id": 7,
"email": "new.hire@acme.com", "response_id": 55}
offer.accepted {"document": "Offer 2026", "file_id": 123, "link_id": 456, "folder_id": null,
offer.declined "email": "bob@corp.com", "email_verified": true, "comment": "…", "engagement": 87,
offer.contact_requested "response_id": 56, "offer_id": 9}
Test events sent from HelpRange carry "test": true.
HelpRange-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> – see below. Right after a secret rotation there are two v1= values (new and old secret) for 24 hours. |
HelpRange-Event-Id | The event's id. The same on every retry and resend. |
HelpRange-Event-Type | The event name, e.g. document.opened. |
HelpRange-Delivery-Attempt | 1 for the first attempt, 2 for the first retry, … |
X-HelpRange-Signature | Legacy: hex HMAC-SHA256 of the raw body only (no timestamp). Kept for existing integrations – prefer HelpRange-Signature. |
Your signing secret (whsec_…) is shown once when the endpoint is saved or the secret is rotated. To verify a request:
t and every v1 from the HelpRange-Signature header.t + "." + the raw request body (the exact bytes – not re-serialised JSON).v1 equals your hex digest (constant-time compare) and t is within 5 minutes of your clock – this stops replayed requests.Node.js (Express):
const crypto = require('crypto');
function verifyHelpRange(rawBody, header, secret, toleranceSec = 300) {
const parts = (header || '').split(',').map((p) => p.split('='));
const t = (parts.find(([k]) => k === 't') || [])[1];
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(t + '.').update(rawBody).digest('hex');
return parts.some(([k, v]) => k === 'v1' && v.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(v), Buffer.from(expected)));
}
app.post('/helprange-webhook', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyHelpRange(req.body, req.get('HelpRange-Signature'), process.env.HELPRANGE_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
// already seen event.id? answer 200 and skip it
res.sendStatus(200); // answer fast, do slow work in the background
});
Python:
import hashlib, hmac, time
def verify_helprange(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = [p.split('=', 1) for p in (header or '').split(',') if '=' in p]
t = next((v for k, v in parts if k == 't'), None)
if t is None or not t.isdigit() or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), t.encode() + b'.' + raw_body, hashlib.sha256).hexdigest()
return any(k == 'v1' and hmac.compare_digest(v, expected) for k, v in parts)
# Flask: verify_helprange(request.get_data(), request.headers.get('HelpRange-Signature'), SECRET)
# Django: verify_helprange(request.body, request.headers.get('HelpRange-Signature'), SECRET)
Test vector: secret whsec_test, t=1700000000, body {"id": "evt_test", "event": "document.opened"} →
v1=7448beb8cec717ca314e71edd0a5076074ae36cf3d7eb4d68d076f178765e3f8.
id (HelpRange-Event-Id).
Events can arrive out of order – use created_at.Settings → Integrations → Delivery log lists every delivery of the last 30 days with status, attempts, response code and payload; filter by status, Resend any event (same event id) or Send test event. Rotate signing secret shows a new secret once; the old one keeps signing for 24 hours so you can switch without losing events.