Skip to content

Messages & Webhooks ​

Use Perfox as your WhatsApp provider: send text, media and locations you chose, and receive every inbound message and delivery update on your own endpoint. No AI runs on these sends — what you pass is exactly what is sent — but the number you send from must belong to a published agent, which is how Perfox knows which WhatsApp connection to use and where the customer's reply should go.

OperationEndpointScope
Send a messagePOST /api/v1/messagesconversations:write
List messagesGET /api/v1/messagesconversations:read
Read one messageGET /api/v1/messages/{id}conversations:read
Subscribe to eventsPOST /api/v1/webhook-subscriptionswebhooks:write
List subscriptionsGET /api/v1/webhook-subscriptionswebhooks:read
Update onePATCH /api/v1/webhook-subscriptions/{id}webhooks:write
Delete oneDELETE /api/v1/webhook-subscriptions/{id}webhooks:write
Send a test deliveryPOST /api/v1/webhook-subscriptions/{id}/testwebhooks:write

Sending ​

bash
curl -X POST "https://<your-workspace>-api.perfox.ai/api/v1/messages" \
  -H "Authorization: Bearer sk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "whatsapp",
    "from": "+918000000001",
    "to": "+919876543210",
    "text": "Your order #4182 has shipped."
  }'
FieldValues
channelwhatsapp — optional, but if sent it must be whatsapp. Any other value is refused, not silently sent on WhatsApp; for sms/email/phone use POST /api/v1/outbound.
fromThe WhatsApp number this goes out on — the platform resolves which agent owns it
agent_idInstead of from, never alongside it
toThe customer's number
textThe exact words to send
mediaAn image, audio clip, video or document — by public URL
locationA map pin: latitude, longitude, and optional name / address
templateAn approved template plus its variables — the only thing deliverable outside the 24-hour window

The message is recorded, billed and visible in the Studio like any other, and the customer's reply arrives the same way any other reply does. The only difference is that no model was asked what to say.

A successful send returns 201:

json
{
  "id": "01a0…",
  "conversation_id": "01a0…",
  "agent_id": "01a0…",
  "channel": "whatsapp",
  "to": "+919876543210",
  "from": "+918000000001",
  "send_authorized": true,
  "status": "sent",
  "provider_message_id": "…"
}

id is what you pass to GET /api/v1/messages/{id}. status: "sent" means the provider accepted the message; delivery arrives later. Add customer_id to the request to attach the message to a customer you already know instead of looking them up by phone number.

Address by number, not by agent ​

from is matched exactly against the WhatsApp number on each published agent's Trigger.

  • A number no published agent claims → 404 not_found, naming the number.
  • A number two agents claim → 422 ambiguous_sender, with the competing agent_ids, rather than resolved by picking one. Send agent_id to choose.
  • A number whose agent cannot run → 422 sender_agent_invalid, with what is wrong with it.

An agent_id is accepted instead, but sending both is rejected: two ways of saying the same thing that can disagree is how a message ends up on the wrong number.

Exactly one of text, media, location or template ​

They are different messages, so sending two would deliver one of them and leave you unable to tell which — the response ids and status are identical either way. The API refuses the request instead.

Outside WhatsApp's 24-hour session window only an approved template is deliverable. Text, media and a location all require an open window, which a customer opens by messaging you. See WhatsApp for the window and for getting templates approved.

The Sender action still gates it ​

The owning agent must have a WhatsApp Sender on its canvas, exactly as an agent-written reply must. A provider send is not a way around that gate: without it the request is refused up front with 422 sender_not_authorized, before anything is written — so a message is never recorded as sent when nothing went out. An agent that is still a draft is refused with 422 agent_not_published.

Sending media ​

The provider fetches the bytes from your URL — you are not uploading to us.

The URL must outlive the send

It has to be publicly reachable and stay reachable until the provider has fetched it. A signed link that expires in thirty seconds is the usual cause of a media message that reports sent and never arrives.

Image ​

bash
curl -X POST "https://<your-workspace>-api.perfox.ai/api/v1/messages" \
  -H "Authorization: Bearer sk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "+918000000001",
    "to": "+919876543210",
    "media": {
      "type": "image",
      "url": "https://cdn.example.com/orders/4182/label.jpg",
      "caption": "Your shipping label for order #4182"
    }
  }'

Document ​

filename is what the recipient sees in their chat — without it they get document.

bash
curl -X POST "https://<your-workspace>-api.perfox.ai/api/v1/messages" \
  -H "Authorization: Bearer sk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "+918000000001",
    "to": "+919876543210",
    "media": {
      "type": "document",
      "url": "https://cdn.example.com/invoices/INV-4182.pdf",
      "filename": "INV-4182.pdf",
      "caption": "Invoice for order #4182"
    }
  }'

Video ​

bash
curl -X POST "https://<your-workspace>-api.perfox.ai/api/v1/messages" \
  -H "Authorization: Bearer sk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "+918000000001",
    "to": "+919876543210",
    "media": {
      "type": "video",
      "url": "https://cdn.example.com/how-to/unboxing.mp4",
      "caption": "How to set it up in 60 seconds"
    }
  }'

Audio ​

bash
curl -X POST "https://<your-workspace>-api.perfox.ai/api/v1/messages" \
  -H "Authorization: Bearer sk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "+918000000001",
    "to": "+919876543210",
    "media": { "type": "audio", "url": "https://cdn.example.com/voice/reminder.ogg" }
  }'

Audio takes no caption

WhatsApp has no caption field for audio. Rather than letting the same request behave differently depending on which provider carries it, the API refuses caption on audio — send the words as a separate text message.

Location ​

bash
curl -X POST "https://<your-workspace>-api.perfox.ai/api/v1/messages" \
  -H "Authorization: Bearer sk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "+918000000001",
    "to": "+919876543210",
    "location": {
      "latitude": 12.9716,
      "longitude": 77.5946,
      "name": "Acme Service Centre",
      "address": "MG Road, Bengaluru"
    }
  }'

What each type accepts ​

typecaptionfilenameNotes
imageyes—jpg / png
videoyes—mp4
documentyesyesany file; filename is what the recipient sees
audiono—ogg / mp3 / m4a; refused with a caption

filename on anything but a document is refused too — the recipient would never see it, so accepting it would be a silent no-op.

Your agents can send media too ​

The same types are available to an AI agent through its send_whatsapp action — it shares one builder with this API, so what this API can send, an agent can send. An agent must pass a media_url it got from a prior tool result; it is instructed never to invent one.

Formats and size limits are WhatsApp's ​

Perfox passes your URL to the provider; the codec, container and size rules are Meta's and can change. If a message is accepted here but never delivered, check Meta's supported media types first — a failed delivery status with the provider's reason will also appear on GET /api/v1/messages/{id} and on your message.status webhook.

Following delivery ​

GET /api/v1/messages/{id} returns the message as sent and where it has got to:

FieldMeaning
id, conversation_id, customer_id, agent_idWho and where
channel, from, toThe route it took
kindtext, template, image, audio, video, document or location
textWhat was sent; template_name is added for a template
statussent, delivered, read or failed
status_historyEvery transition, each with status, at and — on a failure — error
errorThe provider's reason, when it failed
provider_message_idThe carrier's id
created_at / updated_atTimestamps

GET /api/v1/messages lists the messages you sent, newest first, as { "messages": [...], "next_cursor" }.

ParameterMeaning
to, from, statusExact-match filters
since / untilISO timestamps — since inclusive, until exclusive
limit1–200, default 50
cursorThe next_cursor from the previous page

The cursor is a position rather than an offset, so messages sent while you page are never skipped.

Receiving ​

bash
curl -X POST "https://<your-workspace>-api.perfox.ai/api/v1/webhook-subscriptions" \
  -H "Authorization: Bearer sk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.example.com/hooks/perfox",
    "events": ["message.inbound", "message.status"]
  }'
json
{
  "id": "01a0…",
  "url": "https://yourapp.example.com/hooks/perfox",
  "events": ["message.inbound", "message.status"],
  "status": "active",
  "consecutive_failures": 0,
  "secret": "whsec_71d948cfc2753fe0…"
}

The secret is shown exactly once

Store it now. It is encrypted at rest and there is no endpoint that reads it back — we sign with it, so it cannot be kept as a hash, and a "show me my secret" route would turn one leaked API key into every subscriber's signing key. Lost it? PATCH the subscription with "rotate_secret": true.

Managing a subscription ​

PATCH /api/v1/webhook-subscriptions/{id} changes any of:

FieldValues
urlA new https endpoint — checked the same way as at creation
eventsmessage.inbound, message.status, or both
statusactive to re-enable (this also clears the failure counter), disabled to pause it yourself
rotate_secrettrue mints a new signing secret, returned once in this response; the old one stops signing immediately

DELETE /api/v1/webhook-subscriptions/{id} removes the endpoint and returns 204 with no body.

The two events ​

message.inbound — a customer wrote to you on WhatsApp.

json
{
  "id": "01a0d837-782b-770e-b63c-50168bf9c6c6",
  "event": "message.inbound",
  "created_at": "2026-09-25T10:58:29.035Z",
  "data": {
    "message_id": "b7e1…",
    "channel": "whatsapp",
    "from": "+919876543210",
    "to": "+918000000001",
    "text": "where is my order?",
    "profile_name": "Asha",
    "received_at": "2026-09-25T10:58:29.033Z"
  }
}

An image, audio, document or voice note adds a media array. If the vendor would not release the bytes, media_error says so in words rather than leaving an empty attachment — a silent empty attachment is indistinguishable from a message that had none.

message.status — a message you sent changed delivery state (sent, delivered, read or failed; a failure adds error with the provider's reason).

json
{
  "id": "01a0…",
  "event": "message.status",
  "created_at": "2026-09-25T10:59:02.114Z",
  "data": {
    "message_id": "b7e1…",
    "channel": "whatsapp",
    "status": "delivered",
    "conversation_id": "01a0…",
    "occurred_at": "2026-09-25T10:59:02.003Z"
  }
}

message_id is derived the same way on both events, so you can correlate an inbound message with the delivery updates for your reply.

It is a listener, not a mode ​

An inbound message reaches your endpoint whether or not an agent also answers it. Subscribing changes nothing about the agent path, and running an agent changes nothing about the webhook — so a number with no agent behind it still reaches you, and a number with one reaches you and gets answered.

Verifying the signature ​

Every delivery carries:

HeaderMeaning
X-Perfox-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
X-Perfox-Eventmessage.inbound, message.status, or webhook.test
X-Perfox-DeliveryIdempotency key — the same value on every retry

v1 is HMAC-SHA256 of <t>.<raw body> keyed with your subscription secret.

js
import crypto from 'node:crypto'

// Give yourself the RAW body: express.raw({ type: 'application/json' })
export function verify(raw_body, header, secret) {
  const parts = new Map(header.split(',').map((kv) => {
    const i = kv.indexOf('=')
    return [kv.slice(0, i), kv.slice(i + 1)]
  }))
  const t = Number(parts.get('t'))
  const v1 = parts.get('v1') ?? ''
  if (!Number.isFinite(t) || !v1) return false

  // Reject a replay — and a future timestamp too, or anything signed "next year"
  // can be held and replayed whenever the attacker likes.
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false

  const expected = crypto.createHmac('sha256', secret).update(`${t}.${raw_body}`).digest('hex')
  if (expected.length !== v1.length) return false // timingSafeEqual throws on unequal lengths
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
}

Verify the raw bytes, before parsing

The signature covers exactly what we sent. Parsing the JSON and re-serialising it changes key order, whitespace and unicode escaping — the result will not match, and the mismatch is very hard to explain from the outside. Keep the raw body, verify, then parse.

The timestamp is inside the signed string rather than beside it. That is what lets you reject a captured delivery by age: a replay older than the five-minute tolerance is refused, and changing the timestamp to make it look fresh invalidates the signature.

Compare in constant time. A plain === on the hex digest leaks the correct signature one byte at a time to anyone who can measure how long your endpoint takes to answer.

Testing your endpoint ​

bash
curl -X POST "https://<your-workspace>-api.perfox.ai/api/v1/webhook-subscriptions/01a0…/test" \
  -H "Authorization: Bearer sk_…"
json
{ "delivered": true, "status": 200 }

The report is synchronous: it tells you the status code your endpoint actually returned, or the transport error if it never answered. The delivery itself is byte-identical in shape to a real one — same envelope, same headers, same signature — and carries the event webhook.test, which cannot be subscribed to and is never emitted for real traffic. A test that differed from live traffic would prove nothing about the integration it exists to de-risk.

When delivery fails ​

What happensDetail
Non-2xx or a transport errorRetried with exponential backoff — 6 attempts in all, over about half a minute
Attempts exhaustedRecorded as a failed delivery with its full payload
20 consecutive failuresThe subscription is disabled, with the reason recorded
Any successThe failure counter resets to zero

GET /api/v1/webhook-subscriptions reports consecutive_failures, last_error, last_failed_at, last_delivered_at and disabled_reason, so a subscriber that has quietly stopped working is visible without reading logs. Re-enable one with PATCH … {"status": "active"}.

Retries carry the same X-Perfox-Delivery

Key your idempotency check on that header. A subscriber that processed attempt one and then timed out can discard attempt two instead of handling the message twice.

URLs we will not accept ​

Registration is refused — not the first delivery — when a URL is not https, or when its host resolves to a private, loopback or link-local address. The check repeats on every redirect hop, so a URL that redirects inward cannot be used to reach inside the platform.

https is required because a delivery carries the text of your customers' messages. The signature proves who sent a payload; it does nothing about who can read it in transit.

You can now … ​

  • Send WhatsApp messages you wrote yourself, on your own number, through POST /api/v1/messages
  • Send an image, video, document, audio clip or map pin by URL — no upload step
  • Receive every inbound message and delivery update on your own endpoint, signed and retried
  • Verify a delivery, reject a replay, and dedupe a retry
  • See at a glance when a subscriber has stopped accepting deliveries, and re-enable it once fixed