🚀 GoSendAPI is in beta — Meta App Review in progress. Get on the waitlist →
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

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.