Webhooks API
Webhooks push ReachBell events to your own HTTPS endpoint as they happen — a notification click, a dead token, a new subscriber, a finished campaign. Each delivery is a signed JSON POST so you can verify it came from us.
The management routes (create, update, delete, logs) authenticate with a dashboard JWT — send Authorization: Bearer <token> plus the x-org-id header. The event-list route is public. See Authentication.
Subscribable events
GET /webhooks/events
Returns every event type a webhook can subscribe to. This route needs no auth — it's safe to call when building your integration's setup UI.
curl https://api.reachbell.com/webhooks/events{
"events": [
"push.clicked",
"token.dead",
"subscriber.created",
"campaign.completed"
]
}| Event | Fires when |
|---|---|
push.clicked | A user tapped one of your notifications. |
token.dead | A push service reported a subscriber's token as expired or unregistered. |
subscriber.created | A new subscriber registered via the SDK. |
campaign.completed | A campaign finished sending; aggregate stats are included in data. |
A webhook with an empty events array receives all events. Provide a subset to receive only those.
Delivery payload
Every delivery is a POST with this envelope:
{
"event": "push.clicked",
"projectId": "proj_01HX2...",
"timestamp": "2026-06-12T09:14:00.000Z",
"data": {
"subscriberId": "sub_01HX3...",
"campaignId": "cmp_01HX5..."
}
}event— one of the subscribable events above.projectId— the project the event belongs to.timestamp— ISO 8601 send time.data— event-specific payload.
Requests carry these headers:
Content-Type: application/json
User-Agent: ReachBell-Webhooks/1.0
X-ReachBell-Signature: sha256=<hex>
Verifying the signature
Every delivery includes an X-ReachBell-Signature header. It's an HMAC-SHA256 of the raw request body, keyed by your webhook's secret, formatted as sha256=<hex digest>. Verify it before trusting the payload.
Hash the raw bytes, not a re-serialized object.
JSON.parsethenJSON.stringifycan reorder keys or change whitespace and break the comparison. Read the raw body first, verify, then parse.
Node.js (Express, using the raw body):
const crypto = require("crypto");
const express = require("express");
const app = express();
const WEBHOOK_SECRET = process.env.REACHBELL_WEBHOOK_SECRET;
// Capture the raw body so we can hash the exact bytes ReachBell signed.
app.use("/reachbell/webhook", express.raw({ type: "application/json" }));
app.post("/reachbell/webhook", (req, res) => {
const signature = req.get("X-ReachBell-Signature") || "";
const expected =
"sha256=" +
crypto.createHmac("sha256", WEBHOOK_SECRET).update(req.body).digest("hex");
// Constant-time compare to avoid timing leaks.
const ok =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) return res.status(401).send("invalid signature");
const payload = JSON.parse(req.body.toString("utf8"));
console.log("event:", payload.event, payload.data);
// Respond 2xx promptly — anything else counts as a failure (see retries).
res.status(200).send("ok");
});Respond with a
2xxstatus quickly. ReachBell treats any non-2xx response — or a connection that doesn't complete within 10 seconds — as a failed delivery. Do your heavy processing asynchronously after you've acknowledged.
Retries and auto-disable
Delivery is best-effort with bounded retries:
- Each event is attempted up to 3 times per webhook: immediately, then after 10 seconds, then after 30 seconds.
- A delivery succeeds on a
2xxresponse. On success the webhook'sfailedCountresets to0andlastSuccessAtupdates. - If all 3 attempts fail,
failedCountincrements by one. - After 10 consecutive failures the webhook is auto-disabled (
isActiveset tofalse,disabledAtstamped) and stops receiving events until you re-enable it with a PATCH.
A single bad deploy that 500s for a few minutes won't disable your webhook —
failedCountresets the moment one delivery succeeds. Auto-disable only trips on 10 failures in a row, which means a genuinely dead endpoint.
Security constraints
Webhook URLs must use https://. ReachBell refuses URLs that resolve to private, loopback, link-local, RFC1918, or cloud-metadata addresses — both at create time and again at delivery time, which closes the DNS-rebinding window. A blocked URL is rejected with 400 Bad Request on write, or logged as a failed delivery at send time.
Create a webhook
POST /webhooks/project/:projectId
url is required and must be HTTPS. events is optional (empty = all events). secret is optional — if you omit it, ReachBell generates a random one and returns it. Save the secret; you need it to verify signatures.
curl -X POST https://api.reachbell.com/webhooks/project/proj_01HX2... \
-H "Authorization: Bearer eyJ..." \
-H "x-org-id: org_01HX1..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/reachbell",
"events": ["push.clicked", "campaign.completed"]
}'Success:
{
"_id": "wh_01HXA...",
"projectId": "proj_01HX2...",
"url": "https://hooks.example.com/reachbell",
"secret": "9f8c7b6a5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b9a8f7e6d",
"events": ["push.clicked", "campaign.completed"],
"isActive": true,
"failedCount": 0,
"createdAt": "2026-06-12T09:00:00.000Z"
}List webhooks
GET /webhooks/project/:projectId
curl https://api.reachbell.com/webhooks/project/proj_01HX2... \
-H "Authorization: Bearer eyJ..." \
-H "x-org-id: org_01HX1..."Update a webhook
PATCH /webhooks/:id/project/:projectId
Send any of url (re-validated for HTTPS + SSRF safety), events, or isActive. Set isActive: true to re-enable a webhook that was auto-disabled — that also lets failed deliveries start counting fresh again.
curl -X PATCH https://api.reachbell.com/webhooks/wh_01HXA.../project/proj_01HX2... \
-H "Authorization: Bearer eyJ..." \
-H "x-org-id: org_01HX1..." \
-H "Content-Type: application/json" \
-d '{ "isActive": true, "events": ["push.clicked", "token.dead", "campaign.completed"] }'Delete a webhook
DELETE /webhooks/:id/project/:projectId
curl -X DELETE https://api.reachbell.com/webhooks/wh_01HXA.../project/proj_01HX2... \
-H "Authorization: Bearer eyJ..." \
-H "x-org-id: org_01HX1..."{ "deleted": true }Delivery logs
GET /webhooks/project/:projectId/logs
Returns recent delivery attempts, newest first. Filter to one webhook with webhookId, and cap the result with limit (default 50, max 200).
curl "https://api.reachbell.com/webhooks/project/proj_01HX2.../logs?webhookId=wh_01HXA...&limit=20" \
-H "Authorization: Bearer eyJ..." \
-H "x-org-id: org_01HX1..."Each log entry records the outcome of one attempt:
[
{
"_id": "whl_01HXB...",
"webhookId": "wh_01HXA...",
"projectId": "proj_01HX2...",
"event": "push.clicked",
"statusCode": 200,
"duration": 142,
"success": true,
"attempt": 1,
"createdAt": "2026-06-12T09:14:00.123Z"
},
{
"_id": "whl_01HXC...",
"webhookId": "wh_01HXA...",
"event": "campaign.completed",
"statusCode": 0,
"duration": 10003,
"success": false,
"error": "The operation was aborted due to timeout",
"attempt": 3,
"createdAt": "2026-06-12T08:00:30.000Z"
}
]statusCode— HTTP status from your endpoint.0means a network-level error (timeout, DNS, refused).duration— round-trip time in milliseconds.attempt— which try this was (1= first,2= first retry,3= second retry).error— present only on failures.
Logs auto-expire after 30 days via a TTL index. Pull anything you need into your own observability stack if you need longer retention.
Errors
All management routes use the standard error envelope.
{
"statusCode": 400,
"message": "webhook url must use https://",
"requestId": "req_01HX4..."
}| Status | When |
|---|---|
400 Bad Request | URL is missing, not a valid URL, not HTTPS, or resolves to a restricted network. |
404 Not Found | The webhook ID doesn't exist on this project. |
403 Forbidden | Missing x-org-id, or your role can't write. |
What's next?
- Track clicks end to end with UTM tracking.
- Reconcile
campaign.completedpayloads against the campaign stats in the dashboard. - Handle non-delivery cleanly with the error codes reference.