Webhooks
GoSendAPI POSTs events to your endpoint whenever something happens: a message is received, a status changes, a template gets approved. Webhooks are the recommended way to consume the API — much better than polling.
How it works
Event happens
│
▼
GoSendAPI builds event envelope
│
▼
POST https://your-app.com/webhook ──── HMAC-signed
│
▼
You respond 2xx (under 10s)
│
├──> ✓ Delivered, done
│
└──> ✗ Failed (timeout / 5xx / network)
│
▼
Retry with exponential backoff (5 attempts: 0s, 1m, 5m, 30m, 2h)
│
└──> Marked as failed permanently after 5 retriesConfiguring a webhook
Two scopes:
| Scope | Configured at | Events received |
|---|---|---|
| Phone-level | A specific phone number | Only events scoped to that phone (messages, conversations, status updates) |
| Project-level | Your project (all phones) | Platform events (phone_number created/deleted, template approved/rejected, account alerts) |
You can have many webhooks per scope — useful for sending events to multiple internal systems.
Via dashboard
app.gosendapi.com → Webhooks → click New webhook.
Via API
POST /v1/webhooks
{
"url": "https://your-app.com/webhook",
"events": [
"whatsapp.message.received",
"whatsapp.message.delivered",
"whatsapp.conversation.created"
],
"phone_number_id": "<internal_phone_id>",
"type": "gosendapi_events"
}Response includes the secret you’ll need for HMAC verification:
{
"id": "42",
"url": "https://your-app.com/webhook",
"secret": "whsec_a1b2c3...",
"events": [...],
"enabled": true,
...
}The secret is shown once at creation. Store it in your env vars immediately. If you lose it, rotate from the dashboard.
Verifying the signature
Every webhook request includes a header:
X-GoSendAPI-Signature: sha256=<hex>The <hex> is HMAC-SHA256(secret, raw_body). You must verify it before processing — otherwise anyone can POST fake events to your endpoint.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// Critical: use raw body parser, NOT json. We need the exact bytes.
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-gosendapi-signature'];
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
console.log('Received', event.event);
// Acknowledge fast — process async
res.status(200).send('OK');
// Then process in background
processEvent(event).catch(console.error);
}
);Payload structure
All events share an envelope:
{
"event": "whatsapp.message.received",
"delivery_id": "8b3f1c2d-...",
"occurred_at": "2026-05-18T12:34:56.000Z",
"tenant_id": "42",
"phone_number_id": "555123456789",
...event-specific fields...
}| Field | Always present | Description |
|---|---|---|
event | ✓ | The event type (see catalog below) |
delivery_id | ✓ | Unique ID for this delivery. Use for idempotency |
occurred_at | ✓ | When the event happened (ISO 8601) |
tenant_id | ✓ | Your project ID |
phone_number_id | Most events | The phone scope |
Event catalog
Messages (phone-scoped)
| Event | Triggers when |
|---|---|
whatsapp.message.received | Inbound message from a contact |
whatsapp.message.sent | Outbound message accepted by Meta |
whatsapp.message.delivered | Delivery confirmed by recipient device |
whatsapp.message.read | Recipient opened the message (if read receipts on) |
whatsapp.message.failed | Delivery failed (blocked, expired, etc.) |
Conversations (phone-scoped)
| Event | Triggers when |
|---|---|
whatsapp.conversation.created | First inbound message opens a 24h window |
whatsapp.conversation.inactive | No activity for N minutes |
whatsapp.conversation.ended | 24h window closed |
Phone numbers (project-scoped)
| Event | Triggers when |
|---|---|
whatsapp.phone_number.created | New phone registered to a WABA |
whatsapp.phone_number.deleted | Phone removed |
whatsapp.phone_number.quality_update | Quality rating changed (green→yellow, etc.) |
Templates (project-scoped)
| Event | Triggers when |
|---|---|
whatsapp.template.approved | Meta approved a submitted template |
whatsapp.template.rejected | Meta rejected (check rejection_reason in payload) |
whatsapp.template.paused | Auto-paused by Meta due to low quality |
Contacts (phone-scoped)
Events related to Meta’s Business-Scoped User ID (BSUID) identity rollout (April 2026+). See BSUID identity below for the identifier fields.
| Event | Triggers when |
|---|---|
whatsapp.contact.synced | Customer added/updated contacts in the WhatsApp Business app contact book (requires smb_app_state_sync subscription at Meta App level). |
whatsapp.contact.identity_changed | Meta rotated the BSUID of a contact (typically because the user changed their phone number). Payload includes previous + current for BSUID and parent BSUID. Use it to keep your CRM in sync. |
whatsapp.contact.phone_received | A user shared their phone number in response to a request_contact_info interactive message. Emitted after we backfill phone on the contact and conversation. Payload includes BSUID + phone: {previous, current}. |
BSUID identity in payloads
Meta rolled out Business-Scoped User IDs (BSUID) in April 2026 — a stable per-business identifier for WhatsApp users (US.xxx format, or US.ENT.xxx for enterprise). As users adopt WhatsApp usernames (June 2026+), phone numbers may be omitted from webhook payloads for users who kept their phone private. Our envelopes expose the BSUID on both the conversation and the individual message so you can correlate identity across events.
On the conversation object (in every event that includes it):
{
"conversation": {
"id": "...",
"phone_number": "5492223677754",
"business_scoped_user_id": "US.13491208655302741918",
"parent_business_scoped_user_id": "US.ENT.11815799212886844830",
"username": "gestic"
}
}On the message object:
- Inbound (
whatsapp.message.received):from_user_id,from_parent_user_id. - Status (
whatsapp.message.sent/delivered/read/failed):recipient_user_id,recipient_parent_user_id.
All BSUID fields are optional — omitted when Meta doesn’t send them. Design your handlers to fall back on phone_number / from / recipient_id when the BSUID isn’t present, and vice versa.
Retry policy
We retry only on:
- HTTP timeout (>10 seconds)
- HTTP 5xx response
- Network errors (DNS, connection reset)
We do NOT retry on:
- HTTP 2xx (success)
- HTTP 3xx (we follow redirects up to 5x)
- HTTP 4xx (your endpoint rejected it — your fault, not ours)
Schedule: 0s, 1m, 5m, 30m, 2h — 5 attempts total. After the last fail, the delivery is marked permanently failed and visible in the dashboard’s delivery log.
Use delivery_id to dedupe on your side. If a retry succeeds after the first one timed out, you might receive the same event twice. Check if you already processed this delivery_id.
Best practices
Acknowledge fast, process async
Your handler should respond 2xx within 1-2 seconds, ideally under 500ms. If you need to do heavy work (DB writes, downstream API calls), queue it:
app.post('/webhook', (req, res) => {
if (!verifySignature(req)) return res.status(401).end();
// 1. Ack immediately
res.status(200).send('OK');
// 2. Process in background
jobQueue.add('process-event', { event: JSON.parse(req.body) });
});If your handler takes >10s, GoSendAPI considers it timed out and retries — even if your code eventually succeeded.
Idempotency on your side
Use delivery_id as the key in a Redis SET (with TTL of ~7 days):
const key = `webhook:processed:${event.delivery_id}`;
const wasProcessed = await redis.set(key, '1', 'NX', 'EX', 7 * 86400);
if (!wasProcessed) {
return res.status(200).send('Already processed');
}Verify signature with constant-time compare
Always use crypto.timingSafeEqual / hmac.compare_digest / hmac.Equal. Plain == is vulnerable to timing attacks.
Use HTTPS only
We refuse to send to HTTP endpoints. Use a real cert (Let’s Encrypt is free).
Don’t whitelist by IP
Our infra scales horizontally — IPs change without notice. Verify by signature, not IP allowlist.
Test with ngrok / webhook.site locally
ngrok http 3000
# Then point your webhook URL to https://abc123.ngrok.io/webhookOr webhook.site for inspection without writing code.
Delivery logs
Every delivery attempt is logged. View in app.gosendapi.com → Webhooks → Delivery logs.
Shows: timestamp, event, HTTP response code, latency, attempt count.
You can also query via API:
GET /v1/webhooks/{webhook_id}/deliveries?page=1Coming next
- Webhook replay: re-send a specific delivery from the dashboard (manual retry)
- Webhook filtering: subscribe to only certain event subtypes (e.g. messages from a specific country code)
- Webhook transformations: optional JS transformation pipeline before delivery
For now, contact us if you need either.