✅ Meta App Review approved — GoSendAPI Cloud is live. Sign up free →
GuidesWebhooks

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 retries

Configuring a webhook

Two scopes:

ScopeConfigured atEvents received
Phone-levelA specific phone numberOnly events scoped to that phone (messages, conversations, status updates)
Project-levelYour 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...
}
FieldAlways presentDescription
eventThe event type (see catalog below)
delivery_idUnique ID for this delivery. Use for idempotency
occurred_atWhen the event happened (ISO 8601)
tenant_idYour project ID
phone_number_idMost eventsThe phone scope

Event catalog

Messages (phone-scoped)

EventTriggers when
whatsapp.message.receivedInbound message from a contact
whatsapp.message.sentOutbound message accepted by Meta
whatsapp.message.deliveredDelivery confirmed by recipient device
whatsapp.message.readRecipient opened the message (if read receipts on)
whatsapp.message.failedDelivery failed (blocked, expired, etc.)

Conversations (phone-scoped)

EventTriggers when
whatsapp.conversation.createdFirst inbound message opens a 24h window
whatsapp.conversation.inactiveNo activity for N minutes
whatsapp.conversation.ended24h window closed

Phone numbers (project-scoped)

EventTriggers when
whatsapp.phone_number.createdNew phone registered to a WABA
whatsapp.phone_number.deletedPhone removed
whatsapp.phone_number.quality_updateQuality rating changed (green→yellow, etc.)

Templates (project-scoped)

EventTriggers when
whatsapp.template.approvedMeta approved a submitted template
whatsapp.template.rejectedMeta rejected (check rejection_reason in payload)
whatsapp.template.pausedAuto-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.

EventTriggers when
whatsapp.contact.syncedCustomer added/updated contacts in the WhatsApp Business app contact book (requires smb_app_state_sync subscription at Meta App level).
whatsapp.contact.identity_changedMeta 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_receivedA 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/webhook

Or 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=1

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