Skip to content

Documentation

Connect a WhatsApp number, authenticate with an API key, and send your first message in minutes.

Preview. The WAGate API is available on the Growth and Scale plans. Generate keys in Developer Studio inside your dashboard. The endpoints below are stable; we’re expanding the surface — check the API reference for the current list.

What is WAGate

WAGate is a platform built on top of the official WhatsApp Cloud API from Meta. It handles the parts that are tedious to build yourself — Embedded Signup, encrypted token storage, webhook verification, delivery tracking, template syncing, retries, and rate limiting — and exposes a small, predictable REST API on top.

You connect a WhatsApp number once through the dashboard, then send and automate messages either from the app or programmatically with an API key.

Base URL

All API requests go to:

https://wagate.app

Requests and responses are JSON. POST bodies may be sent as application/json or as form fields.

Authentication

Programmatic requests authenticate with a secret API key sent as a Bearer token:

Authorization: Bearer ag_live_sk_xxxxxxxxxxxxxxxxxxxx

To create a key:

  1. Open your dashboard and go to Developer Studio → API keys.
  2. Click Create key and copy the secret. It is shown once — store it somewhere safe.
  3. A key is bound to your workspace’s active WhatsApp connection. You can revoke it at any time.
Keep keys secret. A key can send messages from your number and incur Meta conversation charges. Never expose it in client-side code, mobile apps, or public repositories. Programmatic sending requires a Growth or Scale plan.

Send your first message

Once you have a key and a verified recipient, send a text message with a single request:

curl -X POST https://wagate.app/api/messages.php \
  -H "Authorization: Bearer ag_live_sk_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "15551234567",
    "type": "text",
    "body": "Hello from WAGate 👋"
  }'

A successful response returns the WhatsApp message id and its initial status:

{ "ok": true, "wamid": "wamid.HBg...", "status": "sent" }

The full request shape — templates, media, and interactive messages — is in the API reference.

Webhooks & events

WAGate registers and verifies the WhatsApp webhook with Meta for you. Every inbound message and every status change (sent, delivered, read, failed) is processed automatically and shows up in:

  • Log Explorer — a searchable, signed record of every message and event.
  • Inbox — inbound customer replies, ready for your team or the AI Chatbot.

You don’t host or verify a Meta webhook yourself. On the Growth and Scale plans you can also have WAGate forward those events to your own HTTPS endpoint, as signed JSON — to feed your CRM, your helpdesk, or your own AI agent.

Set it up

  1. Open Developer Studio → AI agent in your dashboard.
  2. Enter your endpoint URL and press Save. It must be a public https:// address on port 443 with a real host name. IP addresses, localhost, .local / .internal names, URLs with a username or password, and WAGate’s own hosts are refused — we also re-check where the name resolves before every delivery.
  3. Copy the signing secret (it starts with whsec_). It is shown once, right after the first save, and again whenever you rotate it.
  4. Press Send test event — your endpoint should answer 2xx — then switch on Send chats to my agent.

Each connected WhatsApp number has its own endpoint (switch numbers in the dashboard sidebar); Messenger and Instagram accounts connected to that number send to the same one. Choose which events to receive with the checkboxes — webhook.test is always delivered.

What we send

Every delivery is an HTTP POST with a JSON body and these headers:

WAGate-Signature: t=1760270400,v1=2a8e9faa8be706e23bb80b4dfa1ee97e73b13f393d494fdb7fde4a56816d7f84
WAGate-Event-Id:  evt_9f2c41a7b3d84e10a65c2f91
WAGate-Event:     message.received
WAGate-Attempt:   1
Header Meaning
WAGate-Signature Timestamp and HMAC-SHA256 signature of the body — see Verifying signatures.
WAGate-Event-Id The event’s unique id (also id in the body). Retries and re-deliveries reuse it — use it to de-duplicate.
WAGate-Event The event type, e.g. message.received.
WAGate-Attempt 1 for the first try, 2 for the first retry, and so on.

The body is always an envelope: id, type, api_version, created (Unix seconds), connection_id and the event’s own data.

Verifying signatures

Anyone can POST to a public URL, so check every request. The signature is the hex HMAC-SHA256 of the string <t>.<raw body> — the timestamp from the header, a dot, then the request body exactly as received — keyed with your signing secret.

  1. Read the raw body before parsing it. Re-serialising the JSON changes the bytes and breaks the match.
  2. Split WAGate-Signature into t and one or more v1 values.
  3. Reject the request if t is more than 5 minutes from your clock (replay protection).
  4. Compute the HMAC and compare it to each v1 with a constant-time comparison. Any match is valid.

PHP

<?php
// Verify the WAGate-Signature header. $secrets = your whsec_… signing secret
// (pass the OLD one as well while you are rotating).
function wagate_verify(string $header, string $rawBody, array $secrets, int $tolerance = 300): bool
{
    $t = null;
    $sigs = [];
    foreach (explode(',', $header) as $part) {
        [$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
        if ($k === 't' && ctype_digit($v)) { $t = (int) $v; }
        if ($k === 'v1' && $v !== '')      { $sigs[] = $v; }
    }
    if ($t === null || !$sigs || abs(time() - $t) > $tolerance) {
        return false;                       // missing, or too old / too new (replay)
    }
    foreach ($secrets as $secret) {
        $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
        foreach ($sigs as $got) {
            if (hash_equals($expected, $got)) { return true; }
        }
    }
    return false;
}

$raw = file_get_contents('php://input');    // the EXACT bytes, before any json_decode
$ok  = wagate_verify($_SERVER['HTTP_WAGATE_SIGNATURE'] ?? '', $raw, [getenv('WAGATE_WEBHOOK_SECRET')]);
if (!$ok) { http_response_code(400); exit; }

http_response_code(200);                    // answer first, work after
$event = json_decode($raw, true);           // dedupe on $event['id'], then handle $event['type']

Node.js

const crypto = require("crypto");
const express = require("express");
const app = express();

// Keep the raw bytes — the signature covers them exactly (do not use express.json() here).
app.post("/wagate", express.raw({ type: "application/json" }), (req, res) => {
  const secrets = [process.env.WAGATE_WEBHOOK_SECRET];   // add the old secret too while rotating
  if (!verify(req.get("WAGate-Signature") || "", req.body, secrets)) return res.sendStatus(400);
  res.sendStatus(200);                                    // answer first, work after
  const event = JSON.parse(req.body.toString("utf8"));    // dedupe on event.id, then handle event.type
});

function verify(header, rawBody, secrets, toleranceSecs = 300) {
  const parts = header.split(",").map((p) => p.trim().split("="));
  const t = Number((parts.find(([k]) => k === "t") || [])[1]);
  const sigs = parts.filter(([k]) => k === "v1").map(([, v]) => v);
  if (!t || !sigs.length || Math.abs(Date.now() / 1000 - t) > toleranceSecs) return false;
  return secrets.some((secret) => {
    const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
    return sigs.some((s) => s.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
  });
}

To test your implementation, this input must produce this signature:

secret  whsec_test_secret
t       1760270400
body    {"id":"evt_test","type":"webhook.test"}
v1      2a8e9faa8be706e23bb80b4dfa1ee97e73b13f393d494fdb7fde4a56816d7f84
Rotating the secret. Press Rotate in Developer Studio to get a new secret. For the next 24 hours we sign every request with both the new and the old secret (the header then carries two v1= values), so you can deploy the new secret without dropping events. The old one stops signing after that.

Event reference

Event Sent when Key fields in data
message.received A customer sends a message on WhatsApp, Messenger or Instagram. channel; message (id, wamid, type, text, caption, reply_to, timestamp, media, and for voice notes transcript + transcript_status); contact (id, name, handoff, ai_off). Locations arrive as location, contact cards as contacts, button / list taps as interactive.
message.status A message you sent changes state: sent, delivered, read or failed. channel, status, message (id, wamid, type, template, sender), contact.id, error (code, title) on failures. Messenger and Instagram only report a watermark — the event carries through (Unix seconds) instead of a message id: everything sent up to then has that status.
handoff A chat needs a person, or a person took over. state: requested (the chatbot, an automation or a keyword rule flagged it — see tag) or human_active (someone answered from the dashboard, the WhatsApp Business app or Business Suite — pause your agent for this contact; via says where). Plus channel and contact.
webhook.test You press Send test event. test: true and a message string.

text is always the customer’s own words — the typed text, the title of the button they tapped, or the media caption. contact.id is the WhatsApp number (digits) or, for the other channels, an id such as fb_… / ig_…; pass it back as to / contact in the API. media.url points at WAGate’s own media endpoint — Meta URLs and tokens are never included.

Example — message.received

{
  "id": "evt_9f2c41a7b3d84e10a65c2f91",
  "type": "message.received",
  "api_version": "2026-10-12",
  "created": 1760270400,
  "connection_id": 12,
  "data": {
    "channel": "whatsapp",
    "message": {
      "id": 48213,
      "wamid": "wamid.HBgLMTU1NTEyMzQ1Njc...",
      "type": "audio",
      "text": "",
      "caption": null,
      "reply_to": null,
      "timestamp": 1760270398,
      "media": {
        "id": "48213",
        "mime": "audio/ogg",
        "filename": null,
        "url": "https://wagate.app/api/agent/media.php?message_id=48213"
      },
      "transcript": "Hi, can I move my appointment to Thursday?",
      "transcript_status": "ok"
    },
    "contact": { "id": "15551234567", "name": "Dana", "handoff": false, "ai_off": false }
  }
}

Example — message.status

{
  "id": "evt_41b7d0c2e95f4a3b8d17c6e0",
  "type": "message.status",
  "api_version": "2026-10-12",
  "created": 1760270461,
  "connection_id": 12,
  "data": {
    "channel": "whatsapp",
    "status": "failed",
    "message": { "id": 48214, "wamid": "wamid.HBgL...", "type": "text", "template": null, "sender": "api" },
    "contact": { "id": "15551234567" },
    "error": { "code": "131047", "title": "Re-engagement message" },
    "timestamp": 1760270460
  }
}

Delivery & retries

A delivery counts as successful only when your endpoint answers with a 2xx status. Anything else — a 3xx (we do not follow redirects), 4xx, 5xx, a timeout, or a connection error — is retried on this schedule:

After failed attempt Wait before the next try
1 10 seconds
2 30 seconds
3 2 minutes
4 10 minutes
5 30 minutes
6 2 hours
7 6 hours
8 12 hours
9 No more automatic tries — the delivery is marked Failed
  • Timeouts: 3 seconds to connect, 8 seconds in total. Answer quickly and do the real work after you respond.
  • Delivery is at-least-once and not strictly ordered. De-duplicate on the event id and use created / timestamp to order events.
  • When we stop: an HTTP 410 Gone switches the endpoint off immediately; so do 50 failed attempts in a row. Developer Studio then shows why — fix your endpoint and turn Send chats to my agent back on.
  • Delivery log: Developer Studio keeps each delivery — payload, status, attempts, HTTP code and timing — for 14 days, and a Redeliver button sends any of them again.
  • Plan or subscription lapsed? Events are no longer sent until it is active again.

Bring your own AI agent

Turn on Pause built-in chatbot next to Send chats to my agent and your agent answers your customers instead of WAGate’s built-in AI chatbot. The loop is simple:

  1. A customer writes. WAGate posts a message.received event to your endpoint and the built-in chatbot stays quiet for that message.
  2. Your agent decides what to say — optionally pulling the conversation history with the Read API — and replies with POST /api/messages.php, using the same API key as any other send.
  3. If it needs a person, it calls POST /api/agent/handoff.php and the chat lands in the inbox’s Needs agent list. A handoff event with state: "human_active" tells you when someone has stepped in.

Replies are tagged sender: "api"

While agent mode is on, every message you send with an API key is recorded with sender: "api". That keeps the built-in chatbot from mistaking your agent’s replies for a human teammate taking over (which would make it stand down). The tag shows up as sender in the Read API and in message.status events.

What your agent does not receive

  • Automations and keywords still run first. A message that belongs to an active Automation flow, a marketing keyword such as STOP / JOIN, or a tap on a campaign button is handled by WAGate and is not sent to your agent.
  • Conversations with the AI switched off. If you turned the AI off for a contact in the inbox, nothing is sent for them. If you set an AI chatbot pilot list, it doubles as your agent’s allow-list.
  • Website-widget chats keep their own pipeline and are never forwarded.
  • Bursts arrive merged. Several quick text messages in one Meta delivery reach you as one event with the combined text. The individual messages are in the Read API.

Switch Send chats to my agent off and the built-in chatbot takes over again straight away; the endpoint, secret and log are kept.

Voice-note transcripts

Customers send voice notes. With Include voice-note transcripts switched on (it is off by default), WAGate transcribes each voice note before sending the event, so your agent gets text in message.transcript. Check message.transcript_status:

transcript_status Meaning
ok transcript holds the text.
disabled The toggle is off, or your plan has no AI actions. transcript is null.
quota This month’s AI allowance is used up. The event is still delivered, without text.
failed The audio could not be fetched or transcribed. The event is still delivered, without text.

It uses your AI allowance. One transcribed voice note is one AI action — the same monthly pool as the inbox’s AI assist (Growth 500, Scale 2,000; free trial 25 or 50). Each voice note is transcribed at most once: the text is stored, so retries, re-deliveries and reading the thread later cost nothing. The meter in Developer Studio → AI agent shows what you have used. Prefer to decide per message? Leave the toggle off and call POST /api/agent/transcribe.php yourself.

Reading the conversation

An agent often needs more than the latest message. Four Bearer-authenticated endpoints under /api/agent/ give it the history, the attachments and a way to escalate. They use the same API key and plan as sending, and each key is limited to 120 requests per minute (429 with Retry-After beyond that).

Endpoint Purpose
GET /api/agent/thread.php A conversation’s messages, oldest first, up to 100 per page. Internal notes are never included.
GET /api/agent/media.php The file behind a message’s media.url (up to 25 MB).
POST /api/agent/transcribe.php Transcribe one voice note on demand (one AI action, once per message).
POST /api/agent/handoff.php Flag a chat as needing a person, or clear the flag.

Parameters, responses and error codes are in the API reference.

Rate limits & quality

Two limits apply to outbound messages:

  • Your plan’s monthly message allowance — 1,000 (Starter), 5,000 (Growth), or 20,000 (Scale) messages per month.
  • Meta’s messaging limits and quality rating for your number, which scale up as you maintain good sending practices.

WAGate paces dispatch to stay within these limits and retries transient failures automatically. Keep opt-in records and respect opt-outs to protect your number’s quality rating — see the Acceptable Use Policy.

Next steps