---
title: Webhooks – HelpRange developers
url: https://www.helprange.com/developers/webhooks
---

Developers

# HelpRange webhooks

Signed, retried JSON events for documents, file requests, data rooms, NDAs and offers.

## Webhooks

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.

### Events

| 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. |

### Payload

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`.

### Headers

| `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`. |

### Verify the signature

Your signing secret (`whsec_…`) is shown once when the endpoint is saved or the secret is rotated. To verify a request:

1. Take `t` and every `v1` from the `HelpRange-Signature` header.
2. Compute HMAC-SHA256 with your secret over `t` + `"."` + the **raw** request body (the exact bytes – not re-serialised JSON).
3. Accept the request if any `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`.

### Retries and delivery

- Any **2xx** answer within 10 seconds (5 s to connect) counts as delivered. Redirects are not followed.
- Anything else – an error code, a timeout, a refused connection – is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h and 24 h: 9 attempts over about 3 days, then the event is marked failed.
- Delivery is at-least-once: the same event can arrive twice. Deduplicate on `id` (`HelpRange-Event-Id`). Events can arrive out of order – use `created_at`.
- Answer **410 Gone** and HelpRange turns the endpoint off at once. After 15 failed attempts in a row over at least 24 hours it is turned off as well, and your team administrators get an e-mail. Turn it back on in the delivery log.
- Only public https addresses are called.

### Delivery log, test events and resend

**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.
