Documentation
Connect a WhatsApp number, authenticate with an API key, and send your first message in minutes.
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:
- Open your dashboard and go to Developer Studio → API keys.
- Click Create key and copy the secret. It is shown once — store it somewhere safe.
- A key is bound to your workspace’s active WhatsApp connection. You can revoke it at any time.
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
- Open Developer Studio → AI agent in your dashboard.
- 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/.internalnames, URLs with a username or password, and WAGate’s own hosts are refused — we also re-check where the name resolves before every delivery. - Copy the signing secret (it starts with
whsec_). It is shown once, right after the first save, and again whenever you rotate it. - 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.
- Read the raw body before parsing it. Re-serialising the JSON changes the bytes and breaks the match.
- Split
WAGate-Signatureintotand one or morev1values. - Reject the request if
tis more than 5 minutes from your clock (replay protection). - Compute the HMAC and compare it to each
v1with 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
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
idand usecreated/timestampto order events. - When we stop: an HTTP
410 Goneswitches 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:
- A customer writes. WAGate posts a
message.receivedevent to your endpoint and the built-in chatbot stays quiet for that message. - 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. - If it needs a person, it calls
POST /api/agent/handoff.phpand the chat lands in the inbox’s Needs agent list. Ahandoffevent withstate: "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
- API reference — every endpoint, field, and error code.
- Terms of Service and Acceptable Use Policy.