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"
  }
}
FieldTypeDescription
conversation_idstringUnique per (channel, user). Use it as your session key — persists across messages.
channel_idstringWhich connected channel (phone number, FB page, IG account) received the message.
tenant_idstringWhich tenant (business) owns this channel.
fromstringSender's identifier (WhatsApp phone number, PSID, IGSID).
tostringYour channel's identifier on the platform.
messageobjectThe inbound message (see types below).
conversation_historyarrayLast 20 messages in chronological order. Includes both inbound and outbound. Saves you from storing your own log.
agent_configobjectArbitrary 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-Secret header 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

HeaderValue
X-Metaai-TimestampUnix seconds at the moment of signing
X-Metaai-Signaturesha256=<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-message and /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-message accepts the request schema above
  • Returns { "status": "ok", "replies": [...] } with valid reply types
  • GET /health returns { "status": "ok", "agent": "slug", "version": "x.y.z" }
  • Your agent does NOT call Meta APIs directly — the platform handles sending
  • You use conversation_history for context (no need to store your own log)
  • You use agent_config for 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_SECRET is 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 id back. Use descriptive IDs ("schedule_call") not arbitrary ones ("btn_1").
  • Phone numbers: The from field is the user's identifier. You don't need to normalize it — we handle that when sending.
  • Conversation state: Use conversation_id as 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 keysWebhook agents
DirectionYour agent → PlatformPlatform → Your agent
Triggered byYour codeInbound end-user messages
SetupSelf-service (Settings → API Keys)Onboarded by our team
Best forOutbound automation, analytics pulls, scheduled jobsConversational AI, real-time replies
AuthX-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.