Webhook Agents
Build a custom agent that receives inbound messages from the platform and returns replies. Best for: full conversational control, LLM-driven responses, multi-step automation.
This is different from the API Keys integration. With API keys your agent calls us. With webhook agents, we call your agent every time an end user sends a message.
How it works
End user sends a message (WhatsApp / Messenger / Instagram)
│
▼
┌─────────────────────────────┐
│ Platform │
│ - receives Meta webhook │
│ - stores message │
│ - looks up agent config │
│ - POSTs to your agent │
└────────────┬────────────────┘
│ POST /process-message
▼
┌─────────────────────────────┐
│ Your Agent │
│ - receives message + ctx │
│ - processes (LLM, etc.) │
│ - returns replies │
└────────────┬────────────────┘
│ { replies: [...] }
▼
┌─────────────────────────────┐
│ Platform │
│ - sends replies via Meta │
│ - stores outbound messages │
└─────────────────────────────┘
Your agent never touches the Meta APIs. The platform owns Meta credentials, sends messages, and persists everything.
What you implement
1. POST /process-message
Receives one inbound message plus conversation context. Returns one or more reply messages.
Request body
{
"conversation_id": "uuid",
"channel_id": "uuid",
"tenant_id": "uuid",
"from": "5215512345678",
"to": "PHONE_NUMBER_ID",
"message": {
"type": "text",
"content": { "text": "Hola, quiero información sobre sus servicios" },
"meta_message_id": "wamid.xxx",
"timestamp": "2026-02-22 18:30:00"
},
"conversation_history": [
{
"direction": "inbound",
"from": "5215512345678",
"to": "PHONE_NUMBER_ID",
"type": "text",
"content": { "text": "Previous message" },
"timestamp": "2026-02-22 18:25:00"
},
{
"direction": "outbound",
"from": "PHONE_NUMBER_ID",
"to": "5215512345678",
"type": "text",
"content": { "text": "Previous reply" },
"timestamp": "2026-02-22 18:25:05"
}
],
"agent_config": {
"custom_key": "custom_value"
}
}
| Field | Type | Description |
|---|---|---|
conversation_id | string | Unique per (channel, user). Use it as your session key — persists across messages. |
channel_id | string | Which connected channel (phone number, FB page, IG account) received the message. |
tenant_id | string | Which tenant (business) owns this channel. |
from | string | Sender's identifier (WhatsApp phone number, PSID, IGSID). |
to | string | Your channel's identifier on the platform. |
message | object | The inbound message (see types below). |
conversation_history | array | Last 20 messages in chronological order. Includes both inbound and outbound. Saves you from storing your own log. |
agent_config | object | Arbitrary JSON, configured per channel in the platform UI. Use for system prompts, feature flags, per-tenant settings, etc. |
Inbound message types you'll receive
Text:
{ "type": "text", "content": { "text": "Hello" } }
Interactive (button reply):
{
"type": "interactive",
"content": { "type": "button_reply", "button_reply": { "id": "btn_1", "title": "Option A" } }
}
Interactive (list reply):
{
"type": "interactive",
"content": { "type": "list_reply", "list_reply": { "id": "row_1", "title": "Selected Item" } }
}
Image:
{ "type": "image", "content": { "id": "media_id", "mime_type": "image/jpeg", "caption": "Photo" } }
Document:
{
"type": "document",
"content": { "id": "media_id", "mime_type": "application/pdf", "filename": "invoice.pdf" }
}
Response — synchronous (recommended)
Return replies immediately. We send them via the platform.
{
"status": "ok",
"replies": [
{ "type": "text", "text": "¡Hola! Soy Sofia, tu asesora de ventas." },
{ "type": "text", "text": "¿En qué puedo ayudarte hoy?" }
]
}
Response — asynchronous
For long-running work (LLM chains, external API calls), return immediately and call back later.
{ "status": "processing" }
Then POST the replies to the platform's callback endpoint (see section 3).
Reply types
Text:
{ "type": "text", "text": "Your message here" }
Interactive buttons (max 3):
{
"type": "interactive_buttons",
"body_text": "Choose an option:",
"buttons": [
{ "id": "opt_1", "title": "Sales" },
{ "id": "opt_2", "title": "Support" },
{ "id": "opt_3", "title": "Info" }
],
"header": "Optional header",
"footer": "Optional footer"
}
Interactive list:
{
"type": "interactive_list",
"body_text": "Browse our services:",
"button_text": "View Options",
"sections": [
{
"title": "Services",
"rows": [
{ "id": "svc_1", "title": "Consulting", "description": "1-on-1 sessions" },
{ "id": "svc_2", "title": "Training", "description": "Group workshops" }
]
}
]
}
Image:
{ "type": "image", "image_url": "https://example.com/photo.jpg", "caption": "Optional caption" }
Document:
{
"type": "document",
"document_url": "https://example.com/brochure.pdf",
"filename": "brochure.pdf"
}
Template (pre-approved):
{ "type": "template", "template_name": "hello_world", "language_code": "en_US", "components": [] }
2. GET /health
Used to show agent status in the UI (green/red indicator).
{ "status": "ok", "agent": "your-agent-slug", "version": "1.0.0" }
Return HTTP 200 with this JSON. Anything else is treated as unhealthy.
3. Async callback (optional)
If you returned {"status": "processing"}, POST the replies later:
POST https://api-meta.agenticlabs.site/api/agents/callback
Headers:
Content-Type: application/json
X-Metaai-Timestamp: <unix seconds at signing time>
X-Metaai-Signature: sha256=<hex HMAC-SHA256(secret, "<ts>." + raw_body)>
Body:
{
"conversation_id": "uuid-from-original-request",
"replies": [
{ "type": "text", "text": "Here's what I found..." }
]
}
We verify the signature against the agent's shared secret and reject requests with a timestamp more than 300 seconds off. See Request signing below for how to compute it.
Legacy path (deprecated): if you were issued a plaintext
X-Agent-Secretheader before 2026-06, it still works for now but logs a deprecation warning on our side. Migrate to HMAC at your earliest convenience.
Request signing
Every POST /process-message we send is signed with HMAC-SHA256 using the shared secret issued for your agent at registration. The same secret signs callbacks you POST to /api/agents/callback.
Headers we send
| Header | Value |
|---|---|
X-Metaai-Timestamp | Unix seconds at the moment of signing |
X-Metaai-Signature | sha256=<hex> where <hex> = HMAC_SHA256(secret, "<ts>." + raw_body) |
The signed string is timestamp + "." + raw_body (no JSON re-serialization — sign the bytes you received). The timestamp prevents replay; we recommend rejecting anything more than 300 seconds old.
Verify in Node.js
import crypto from 'crypto';
import express from 'express';
const app = express();
// IMPORTANT: capture the raw body for signature verification
app.use(
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf;
},
})
);
app.post('/process-message', (req, res) => {
const ts = req.header('x-metaai-timestamp') ?? '';
const sigHeader = req.header('x-metaai-signature') ?? '';
const provided = sigHeader.replace(/^sha256=/, '');
// Reject stale requests (5-minute window)
const skew = Math.abs(Math.floor(Date.now() / 1000) - parseInt(ts, 10));
if (!ts || Number.isNaN(skew) || skew > 300) {
return res.status(401).json({ error: 'stale timestamp' });
}
const expected = crypto
.createHmac('sha256', process.env.METAAI_WEBHOOK_SECRET)
.update(ts + '.')
.update(req.rawBody)
.digest('hex');
const ok =
provided.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
if (!ok) return res.status(401).json({ error: 'bad signature' });
// ... process the message
res.json({ status: 'ok', replies: [] });
});
Verify in Python (FastAPI)
import hmac, hashlib, os, time
from fastapi import FastAPI, Request, HTTPException
SECRET = os.environ["METAAI_WEBHOOK_SECRET"].encode()
app = FastAPI()
@app.post("/process-message")
async def process(request: Request):
raw = await request.body()
ts = request.headers.get("x-metaai-timestamp", "")
sig = request.headers.get("x-metaai-signature", "").removeprefix("sha256=")
try:
if abs(time.time() - int(ts)) > 300:
raise HTTPException(401, "stale timestamp")
except (TypeError, ValueError):
raise HTTPException(401, "bad timestamp")
expected = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
raise HTTPException(401, "bad signature")
# ... process the message
return {"status": "ok", "replies": []}
Sign callbacks the same way
When POSTing to /api/agents/callback, build the headers yourself:
const body = JSON.stringify({ conversation_id, replies });
const ts = Math.floor(Date.now() / 1000).toString();
const sig = crypto
.createHmac('sha256', process.env.METAAI_WEBHOOK_SECRET)
.update(ts + '.' + body)
.digest('hex');
await fetch('https://api-meta.agenticlabs.site/api/agents/callback', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Metaai-Timestamp': ts,
'X-Metaai-Signature': `sha256=${sig}`,
},
body, // send the exact string you signed — do NOT re-serialize
});
Rotating the secret
If your secret leaks (or you just want to rotate), ask us to issue a new one — we'll regenerate it on the registry side and hand you the plaintext once. Update your METAAI_WEBHOOK_SECRET env var and redeploy. There's no overlap window today, so coordinate the cutover with us.
Registration
Webhook agents are onboarded by our team — they need to be reachable from our network and assigned to one or more channels. Contact us with:
- Agent slug (e.g.
your-company-sales-bot) - Public endpoint URLs for
/process-messageand/health - Tenants/channels the agent should serve
You'll receive the HMAC shared secret (one-time plaintext) and the channel IDs you'll see in incoming requests. Store the secret in your environment as METAAI_WEBHOOK_SECRET.
Implementation checklist
-
POST /process-messageaccepts the request schema above - Returns
{ "status": "ok", "replies": [...] }with valid reply types -
GET /healthreturns{ "status": "ok", "agent": "slug", "version": "x.y.z" } - Your agent does NOT call Meta APIs directly — the platform handles sending
- You use
conversation_historyfor context (no need to store your own log) - You use
agent_configfor per-channel settings - HMAC signature verified on
/process-message(timestamp + body); requests with bad/stale signatures rejected with 401 - If you use async mode: callbacks are HMAC-signed with the same secret
-
METAAI_WEBHOOK_SECRETis stored as an env var / secret (not committed, not logged) - Deployed and reachable from the public internet over HTTPS
Timeouts
- Synchronous: 30 seconds to respond before we treat it as failed
- Async: respond immediately with
{"status": "processing"}, then POST the callback within a few minutes /health: must respond within 5 seconds
Tips
- Multiple replies: Return an array to send several messages in sequence (e.g., greeting + question).
- Button IDs: When a user taps a button, you'll get the button
idback. Use descriptive IDs ("schedule_call") not arbitrary ones ("btn_1"). - Phone numbers: The
fromfield is the user's identifier. You don't need to normalize it — we handle that when sending. - Conversation state: Use
conversation_idas your session key. It's stable per (channel, user) pair. - Config: Anything you'd want to tune per channel (system prompt, CRM creds, language) should go in
agent_config— set in the platform UI, not hard-coded.
Webhook agents vs API keys
| API keys | Webhook agents | |
|---|---|---|
| Direction | Your agent → Platform | Platform → Your agent |
| Triggered by | Your code | Inbound end-user messages |
| Setup | Self-service (Settings → API Keys) | Onboarded by our team |
| Best for | Outbound automation, analytics pulls, scheduled jobs | Conversational AI, real-time replies |
| Auth | X-API-Key: mta_… | HMAC-SHA256 signature (X-Metaai-Signature) |
Many integrations use both — webhook agent for conversational replies, API keys for scheduled broadcasts or analytics dashboards.