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