Home/ Developers/Webhooks
Get notified when a message arrives or a delivery status changes
Subscribe an HTTPS endpoint to the events you care about; BotPulsar signs every delivery so you can verify it actually came from BotPulsar before you act on it.
Subscribe a public HTTPS endpoint to one or more event types, then verify the
X-Botpulsar-Signature header on every delivery using the one-time
signing secret shown when you create the subscription — the same
HMAC-SHA256(secret, "{timestamp}." + raw_body) check on both sides, compared
in constant time.
Subscribe an endpoint
-
Operations → Developer → Create webhook subscription
Give it a name, a public HTTPS endpoint URL, and the event types to send. Only public HTTPS destinations are accepted — no
localhost, private IPs, or internal hostnames. -
Copy the signing secret immediately
Shown once, as
whsec_..., in the creation response. You'll need it to verify every delivery. -
Return a
2xxquicklyAcknowledge the delivery fast and do slow work asynchronously — a delivery that keeps timing out gets retried, then eventually dead-lettered.
What arrives at your endpoint
Content-Type: application/json User-Agent: BOTPULSAR-Webhooks/1.0 X-Botpulsar-Delivery: 7c2b1e4a-9f3d-4c8b-b1a0-5e6d7c8b9a0f X-Botpulsar-Event: message.received X-Botpulsar-Signature: v1=5f4dcc3b5aa765d61d8327deb882cf99... X-Botpulsar-Timestamp: 1798623330
{
"api_version": "2026-07-01",
"type": "message.received",
"event_id": "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"delivery_id": "7c2b1e4a-9f3d-4c8b-b1a0-5e6d7c8b9a0f",
"workspace_id": "3f9c7e2a-1b4d-4e8a-9c3f-2a1b4d4e8a9c",
"created_at": "2026-08-29T10:15:30Z",
"data": {
"message_id": "...",
"conversation_id": "...",
"contact_id": "...",
"channel_account_id": "...",
"direction": "inbound",
"content_type": "text",
"contact_created": false,
"first_conversation": false
}
}
delivery_id in the body is the same value as the
X-Botpulsar-Delivery header — use whichever's convenient as your idempotency
key. A retried delivery reuses the same ID.
Verify the signature
Recompute the HMAC over the timestamp and the exact raw request body — not a re-serialized copy of the parsed JSON, which can reorder keys or change whitespace and break the match.
import hashlib import hmac import time def verify_botpulsar_signature(secret: str, timestamp: str, raw_body: bytes, signature_header: str) -> bool: # Reject stale deliveries so a captured request can't be replayed later. if abs(time.time() - int(timestamp)) > 300: return False expected = hmac.new( secret.encode("ascii"), f"{timestamp}.".encode("ascii") + raw_body, hashlib.sha256, ).hexdigest() received = signature_header.removeprefix("v1=") return hmac.compare_digest(expected, received)
const crypto = require('crypto'); function verifyBotpulsarSignature(secret, timestamp, rawBody, signatureHeader) { // Reject stale deliveries so a captured request can't be replayed later. if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.`) .update(rawBody) // the raw request body, not JSON.parse'd and re-stringified .digest('hex'); const received = signatureHeader.replace(/^v1=/, ''); return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received)); }
Use secret as the whsec_... value from creating (or rotating) the
subscription. Rotating replaces it immediately — swap your verification code over before
the old secret stops being accepted.
Every event type
| Event | Fires when |
|---|---|
message.received |
An inbound WhatsApp message arrived. |
message.outbound.submitted |
A message you sent was accepted by WhatsApp for delivery. |
message.delivery_status.changed |
A sent message moved to sent, delivered, read, or failed. |
message.outbound.failed |
A message you sent could not be delivered. |
message.media.ready |
Media attached to a message finished processing and can be fetched. |
conversation.created |
A new conversation was opened with a contact. |
conversation.status.changed |
A conversation moved between open, closed, or blocked. |
contact.created |
A new contact was created. |
contact.field.changed |
A custom field value on a contact changed. |
contact.tag.changed |
A tag was added to or removed from a contact. |
Common questions
What happens if my endpoint is down when an event fires?
Delivery retries transient HTTP and network failures with bounded backoff. A delivery that keeps failing is dead-lettered rather than retried forever — check `webhook-deliveries/{id}/replay/` from the dashboard's Developer area to resend it once your endpoint is back.
How do I avoid processing the same event twice?
Store `X-Botpulsar-Delivery` (or the payload's `delivery_id` — they're the same value) for each delivery you've handled, and skip anything you've already seen. Retries reuse the same delivery ID.
Why verify over timestamp + body instead of just the body?
Binding the timestamp into the signed material stops a captured request from being replayed later. Reject any timestamp more than a few minutes old even if the signature itself is valid.
Does BotPulsar follow redirects on my endpoint?
No. Delivery follows no redirects and re-validates public DNS before every attempt, which is also why the endpoint must be a public HTTPS URL — not localhost, a private IP, or an internal hostname.