Build on Hatchium AI
One REST API for Customer Messaging
Send WhatsApp, Messenger, and Instagram messages, media, and interactive menus from your own system. Webhooks report every delivery, read, failure, and inbound message in real time.
Base URL https://app.hatchium.ai/api/v1 · header X-API-Key · every plan includes API access
This guide covers three capabilities of the Hatchium AI API:
- Media messages, sending images, video, audio and documents, and receiving them.
- Interactive menus, reply buttons and list pickers, and reading the customer's choice.
- Webhooks, so your system reacts to deliveries, reads, failures and incoming messages in real time.
For authentication, plain text and template sends, listing messages and checking your balance, see the API Documentation page inside your account.
1. Quick reference
| Base URL | https://app.hatchium.ai/api/v1 |
|---|---|
| Auth header | X-API-Key: <your key> |
| Content type | application/json |
| Rate limit | 60 requests per minute, per API key |
| Send endpoint | POST /messages |
| Success | 202 Accepted (queued), or 200 OK with "duplicate": true on an idempotency replay |
| Errors | { "error": { "code": "...", "message": "..." } } |
Every send needs a unique idempotency_key. Retry the same request with the same key and you get the original message back instead of a second delivery. Keys are remembered for 48 hours.
The two send modes
Everything in this guide falls into one of two modes, and the difference decides what you are allowed to send.
| Template message | Free-form message | |
|---|---|---|
type | template | text, image, video, audio, document, interactive |
| When you can send | Any time | Only within 24 hours of the customer's last message to that number |
| Approval | Template must be approved by Meta first | No approval needed |
| Outside the window | Works | Rejected with OUTSIDE_24H_WINDOW |
Media and interactive messages are free-form. If you need to reach a customer who has not written to you in the last 24 hours, you must use an approved template. Section 2.6 shows how to attach media to a template so you can still send a file outside the window.
2. Media messages
2.1 Sending media
Set type to the media kind and put the file in content:
bashcurl -X POST https://app.hatchium.ai/api/v1/messages \
-H "X-API-Key: $HATCHIUM_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "923001234567",
"type": "image",
"conversation_type": "service",
"idempotency_key": "order-8842-photo",
"content": {
"link": "https://cdn.example.com/products/8842.jpg",
"caption": "Here is the item you asked about."
}
}'
Response:
json{
"message": {
"id": 918442,
"to": "923001234567",
"type": "image",
"status": "queued",
"credits_charged": 1,
"meta_message_id": null,
"scheduled_for": null,
"sent_at": null,
"delivered_at": null,
"read_at": null,
"created_at": "2026-08-06T17:22:04+00:00"
}
}
status is queued because delivery is asynchronous. Watch the message.sent and message.delivered webhooks (section 4) for the rest of the lifecycle, or poll GET /messages/{id}.
When a message fails after WhatsApp accepted it, GET /messages/{id} includes WhatsApp's own failure reason:
json{
"message": {
"id": 918442,
"status": "failed",
"error": {
"code": "131053",
"message": "Media upload error"
}
}
}
error is null unless WhatsApp reported a failure. Code 131053 on an audio send almost always means a codec problem; see section 2.3 and upload through POST /v1/media (section 2.4) to rule it out.
2.2 The content object
| Field | Applies to | Notes |
|---|---|---|
link | all media types | Public HTTPS URL. Must be reachable by WhatsApp without authentication. |
id | all media types | A WhatsApp media ID, from POST /v1/media (section 2.4) or your own upload to Meta. Use instead of link, not alongside it: sending both returns MEDIA_SOURCE_CONFLICT, and neither returns MEDIA_SOURCE_MISSING. |
caption | image, video, document | Text shown under the file. |
filename | document | The name the customer sees when saving the file. Set this on every document. |
voice | audio | true renders the clip as a voice note rather than an audio attachment. |
Anything else in content is ignored. Only the fields above are forwarded.
Use link unless you have a reason not to, with one exception: audio. WhatsApp fetches the file from your URL. A media id is only valid for the WhatsApp number that uploaded it, so an ID obtained elsewhere will fail. Audio should always go through POST /v1/media (section 2.4), which validates the codec and converts when needed; the link flow cannot do either, and a codec mismatch by link fails only after delivery was accepted.
2.3 Supported types and size limits
These are WhatsApp platform limits, enforced by Meta rather than by Hatchium. A file that exceeds them fails at delivery, after the API has already accepted the request, so check sizes on your side first.
type | Formats | Max size |
|---|---|---|
image | JPEG, PNG | 5 MB |
video | MP4, 3GP (H.264 video, AAC audio) | 16 MB |
audio | AAC, AMR, MP3, M4A (AAC codec), OGG (Opus codec) | 16 MB |
document | Any file type | 100 MB |
Stickers can be received but not sent through this endpoint.
Audio needs the right codec inside the right container, not just the right file extension. audio/mp4 (M4A) must contain AAC audio, and OGG must contain Opus. Browser recorders are the classic trap: Chrome's MediaRecorder writes Opus audio into an MP4 container, which WhatsApp accepts at send time and then fails at delivery with no useful detail. If your audio comes from a browser or any source you do not fully control, upload it through POST /v1/media (section 2.4): it checks the actual codec and converts automatically when needed. If you send audio by link, no conversion is possible, so verify the codec yourself first.
2.4 Uploading media: POST /v1/media
Upload a file and get back a media ID to send as content.id.
Upload or link?
| Type | Recommendation |
|---|---|
audio | Always upload. Codec validation and automatic conversion happen only here; this is the reliable path for browser recordings and any audio you did not encode yourself. |
image, video, document | Link is fine when the file is publicly hosted and specific to one recipient. Upload instead when the file is not publicly hosted, when you send the same file to many recipients (upload once, reuse the media_id, instead of WhatsApp fetching your URL on every send), or when you want size and format errors up front instead of a failure after the send was accepted. |
Weigh the two upload trade-offs before migrating a high-volume flow: a media_id is bound to one WhatsApp number and expires after 30 days, and uploads are limited to 10 per minute per API key while sends allow 60.
bashcurl -X POST https://app.hatchium.ai/api/v1/media \
-H "X-API-Key: hc_your_api_key_here" \
-F "[email protected]"
Response, 201 Created:
json{
"media_id": "1234567890",
"media_type": "audio",
"mime_type": "audio/ogg; codecs=opus",
"converted": true,
"expires_at": "2026-09-08T16:00:00+00:00"
}
Then send it:
json{
"to": "923001234567",
"type": "audio",
"conversation_type": "service",
"idempotency_key": "vn-77214",
"content": {
"id": "1234567890",
"voice": true
}
}
Note where voice lives: on the send request's content, next to id, not on the upload. It is what renders the clip as a voice note with a waveform instead of a plain audio attachment, so keep passing it after you migrate to uploads.
Points to know:
- Audio is validated and converted for you. The endpoint inspects the actual audio codec. Safe combinations (MP3, AAC in M4A, AMR, OGG/Opus) upload untouched; anything else, including browser-recorded Opus-in-MP4, is re-encoded to OGG/Opus and returned with
converted: true. - A media ID belongs to one WhatsApp number. It can only be sent from the number that uploaded it. Pass
waba_connection_idto upload for a specific number; a number-scoped API key always uploads to its own number. Sending an ID from the wrong number returnsMEDIA_SCOPE_MISMATCH. - Media IDs expire. WhatsApp deletes uploaded media after 30 days;
expires_attells you when. Sending an expired ID returnsMEDIA_EXPIRED, so upload again. - Type must match. Sending an audio upload as
type: imagereturnsMEDIA_TYPE_MISMATCH. - Size limits from section 2.3 are enforced up front (
MEDIA_TOO_LARGE), with one difference: document uploads through this endpoint are capped at 50 MB. Documents between 50 MB and WhatsApp's 100 MB limit must be sent bylink. Unsupported formats returnUNSUPPORTED_MEDIA_TYPE, and a file that cannot be converted returnsMEDIA_CONVERSION_FAILED. Unlike the link flow, where an oversized or malformed file fails only after the API accepted the send, every one of these checks happens before this endpoint returns. - There is no duration limit on audio, only bytes. At voice-note bitrates, 16 MB is roughly 45 minutes of OGG/Opus. The cap applies to the file as you upload it; conversion output is usually smaller than the input.
- Uploads are limited to 10 per minute per API key.
2.5 Worked examples
Document with a filename
json{
"to": "923001234567",
"type": "document",
"conversation_type": "service",
"idempotency_key": "inv-2026-0114",
"content": {
"link": "https://files.example.com/invoices/2026-0114.pdf",
"filename": "Invoice 2026-0114.pdf",
"caption": "Your invoice for August."
}
}
Voice note
json{
"to": "923001234567",
"type": "audio",
"conversation_type": "service",
"idempotency_key": "vn-77213",
"content": {
"link": "https://cdn.example.com/vn/77213.ogg",
"voice": true
}
}
2.6 Sending media outside the 24 hour window
Free-form media needs an open session. To send a file to a customer who has gone quiet, use an approved template that has a media header, and pass the file as a header parameter:
json{
"to": "923001234567",
"type": "template",
"template_name": "monthly_invoice",
"idempotency_key": "inv-2026-0114-tpl",
"content": {
"components": [
{
"type": "header",
"parameters": [
{
"type": "document",
"document": {
"link": "https://files.example.com/invoices/2026-0114.pdf",
"filename": "Invoice 2026-0114.pdf"
}
}
]
},
{
"type": "body",
"parameters": [
{ "type": "text", "parameter_name": "customer_name", "text": "Ayesha" },
{ "type": "text", "parameter_name": "amount", "text": "PKR 12,400" }
]
}
]
}
}
Header parameters accept image, video and document objects, matching whatever header format the template was approved with. Body parameters are validated before the message is queued: a missing or misnamed variable is rejected with TEMPLATE_PARAM_MISMATCH and costs you nothing, rather than failing at Meta after you have been charged.
template_language is optional. The approved template's own language is used.
2.7 Receiving media
When a customer sends you a file, the message.inbound webhook carries a ready made media block. You do not parse WhatsApp payloads and you do not need a Meta access token:
json{
"event": "message.inbound",
"timestamp": "2026-08-06T17:31:02+00:00",
"data": {
"message_id": 918511,
"meta_message_id": "wamid.HBgMOTIzMDAxMjM0NTY3FQIAEhgg...",
"from": "923001234567",
"type": "image",
"text": null,
"timestamp": "2026-08-06T17:31:02+00:00",
"channel": {
"waba_connection_id": 14,
"phone_number_id": "109876543210987",
"display_phone_number": "+92 300 8880464",
"label": "Support line"
},
"reply": null,
"media": {
"type": "image",
"media_id": "1234567890123456",
"mime_type": "image/jpeg",
"filename": null,
"caption": "Is this the right part?",
"download_url": "https://app.hatchium.ai/media/1234567890123456?c=14&expires=1786124560&t=125&signature=61b457a0..."
}
}
}
media.type is one of image, document, audio, video, sticker. The block is null for text messages.
About download_url:
- Use it exactly as received. The signature covers the whole URL, so adding, removing or reordering query parameters invalidates it.
- It is valid for 24 hours. Download and store the file within that window if you need to keep it.
- No authentication header is required. Anyone holding the URL can fetch the file until it expires, so treat it as a secret and do not put it in client side code or logs you share.
- The response carries the real
Content-Type. The first fetch pulls from WhatsApp and caches, so it is slower than later ones.
php// PHP: persist an inbound attachment
$media = $payload['data']['media'] ?? null;
if ($media) {
$binary = file_get_contents($media['download_url']);
$name = $media['filename'] ?? ($media['media_id'] . '.' . $this->extensionFor($media['mime_type']));
Storage::disk('s3')->put("whatsapp/{$name}", $binary);
}
3. Interactive menus
Interactive messages give the customer buttons or a list to tap instead of typing. Two kinds are supported:
- Reply buttons, up to 3 choices, shown as buttons under the message.
- List, up to 10 rows grouped in sections, opened from a single button.
Set type to interactive and put a standard WhatsApp interactive object in content.interactive. It is passed through as given, so anything WhatsApp supports in that object works here.
Interactive messages are free-form, so the 24 hour session rule applies.
3.1 Reply buttons
bashcurl -X POST https://app.hatchium.ai/api/v1/messages \
-H "X-API-Key: $HATCHIUM_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "923001234567",
"type": "interactive",
"conversation_type": "service",
"idempotency_key": "appt-5521-confirm",
"content": {
"body": "Your appointment is on Friday at 4:00 PM. Confirm or reschedule?\n1. Confirm\n2. Reschedule\n3. Cancel",
"interactive": {
"type": "button",
"header": { "type": "text", "text": "Appointment 5521" },
"body": { "text": "Your appointment is on Friday at 4:00 PM. Would you like to confirm?" },
"footer": { "text": "Reply any time" },
"action": {
"buttons": [
{ "type": "reply", "reply": { "id": "appt_confirm_5521", "title": "Confirm" } },
{ "type": "reply", "reply": { "id": "appt_reschedule_5521", "title": "Reschedule" } },
{ "type": "reply", "reply": { "id": "appt_cancel_5521", "title": "Cancel" } }
]
}
}
}
}'
3.2 List menu
json{
"to": "923001234567",
"type": "interactive",
"conversation_type": "service",
"idempotency_key": "menu-thread-4471",
"content": {
"body": "Please choose what you would like to talk about.\n1. Sales\n2. Support\n3. Billing",
"interactive": {
"type": "list",
"header": { "type": "text", "text": "How can we help?" },
"body": { "text": "Please choose what you would like to talk about, so we can connect you with the right team." },
"footer": { "text": "Tap the button below" },
"action": {
"button": "Choose a topic",
"sections": [
{
"title": "Topics",
"rows": [
{ "id": "topic_sales", "title": "Sales", "description": "Prices, packages and new orders" },
{ "id": "topic_support", "title": "Support", "description": "Problems with an existing order" },
{ "id": "topic_billing", "title": "Billing", "description": "Invoices, refunds and payments" }
]
}
]
}
}
}
}
3.3 Field rules
content.interactive must have a type of button or list, plus body and action. Anything else is rejected before it reaches WhatsApp:
json{ "error": { "code": "INVALID_INTERACTIVE", "message": "content.interactive must be a Meta interactive object with type (button|list), body and action." } }
WhatsApp's own limits, which Hatchium passes through rather than enforces:
| Element | Limit |
|---|---|
| Buttons per message | 3 |
| Button title | 20 characters |
Button / row id | 256 characters |
| List rows, total across all sections | 10 |
| List sections | 10 |
| Row title | 24 characters |
| Row description | 72 characters |
| List action button text | 20 characters |
| Body text | 1024 characters |
| Header text | 60 characters |
| Footer text | 60 characters |
Titles must be unique within a message, and neither titles nor IDs may be empty. Exceeding a limit fails at delivery, not at the API, so trim on your side.
3.4 content.body: what your team sees
content.interactive is what the customer sees. content.body is a plain text summary shown in the Hatchium team inbox, where an interactive object cannot be rendered.
Set it on every interactive send. Without it your agents see a blank outgoing message in the conversation history. Repeat the question and number the options, as in the examples above.
3.5 Reading the customer's choice
A tap arrives as a message.inbound webhook with a reply block:
json{
"event": "message.inbound",
"timestamp": "2026-08-06T17:33:40+00:00",
"data": {
"message_id": 918520,
"meta_message_id": "wamid.HBgMOTIzMDAxMjM0NTY3...",
"from": "923001234567",
"type": "interactive",
"text": null,
"channel": { "waba_connection_id": 14, "phone_number_id": "109876543210987", "display_phone_number": "+92 300 8880464", "label": "Support line" },
"reply": {
"kind": "button",
"id": "appt_confirm_5521",
"title": "Confirm"
},
"media": null
}
}
| Field | Meaning |
|---|---|
reply.kind | button for a reply button or a template quick reply, list for a list row |
reply.id | The id you set when sending. This is what you branch on. |
reply.title | The label the customer saw |
Always branch on reply.id, never on reply.title. Titles are translated for customers in other languages, IDs are not. Put your own reference in the ID, for example appt_confirm_5521, so you can act without a lookup.
reply is null for ordinary text and media messages.
4. Webhooks
4.1 Turning them on
- Webhook Integration must be active on your plan.
- Go to Settings → Webhooks in your Hatchium account.
- Enter your endpoint URL. It must be public HTTPS. Private, internal and loopback addresses are rejected.
- Tick the events you want.
- Save. A signing secret is generated on first save, starting with
whsec_. Copy it.
4.2 Events
| Event | Fires when |
|---|---|
message.sent | WhatsApp accepted the message |
message.delivered | It reached the customer's device |
message.read | The customer opened it |
message.failed | Delivery failed |
message.inbound | A customer sent you a message |
Only ticked events are delivered. Statuses never go backwards, so you will not see delivered after read for the same message.
4.3 Request format
Every delivery is a POST with this envelope:
json{
"event": "message.delivered",
"timestamp": "2026-08-06T17:22:11+00:00",
"data": { }
}
Headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Hatchium-Event | The event name, matching event in the body |
X-Hatchium-Signature | Hex HMAC-SHA256 of the raw request body, keyed with your signing secret |
X-Webhook-Event | The same event name |
X-Webhook-Signature | The same signature |
X-Webhook-Signature and X-Webhook-Event carry identical values to the pair above. They exist so a shared integration can read one header name across accounts. Either pair is fine to verify against, so pick one and be consistent.
4.4 Status event payload
message.sent, message.delivered, message.read and message.failed share one shape. The timestamp key is named after the status.
json{
"event": "message.delivered",
"timestamp": "2026-08-06T17:22:11+00:00",
"data": {
"message_id": 918442,
"meta_message_id": "wamid.HBgMOTIzMDAxMjM0NTY3...",
"recipient": "923001234567",
"status": "delivered",
"delivered_at": "2026-08-06T17:22:10+00:00",
"channel": {
"waba_connection_id": 14,
"phone_number_id": "109876543210987",
"display_phone_number": "+92 300 8880464",
"label": "Support line"
}
}
}
message_id is the ID returned when you sent the message. Store it and use it to match.
4.5 Inbound event payload
Shown in full in sections 2.6 and 3.5. Summary of the fields:
| Field | Notes |
|---|---|
message_id | Hatchium's ID for the inbound message |
meta_message_id | The WhatsApp message ID, useful for reply threading |
from | Customer number, digits only, no + |
type | text, image, document, audio, video, sticker, interactive, button, location, contacts |
text | Body text, null for non text messages |
channel | Which of your numbers received it. null on older messages predating multi number support. |
reply | Button tap or list pick, otherwise null |
media | Attachment plus signed download_url, otherwise null |
Note that a customer who replies STOP or unsubscribe is added to your suppression list automatically, and further sends to that number are rejected with RECIPIENT_OPTED_OUT. START clears it.
4.6 Verifying the signature
Compute HMAC-SHA256 over the raw request body, exactly as received, using your signing secret. Compare it to X-Hatchium-Signature with a constant time comparison. Reject anything that does not match.
Do not re-encode the parsed JSON before hashing. Key order and whitespace change, and the signature will never match.
PHP (Laravel)
phpRoute::post('/hatchium/webhook', function (Request $request) {
$raw = $request->getContent();
$expected = hash_hmac('sha256', $raw, config('services.hatchium.webhook_secret'));
if (! hash_equals($expected, (string) $request->header('X-Hatchium-Signature'))) {
abort(401);
}
$payload = json_decode($raw, true);
// Return fast, do the work in a queue.
ProcessHatchiumEvent::dispatch($payload);
return response()->noContent();
});
Node.js (Express)
jsconst express = require('express');
const crypto = require('crypto');
const app = express();
// Keep the raw body: express.json() alone would discard it.
app.use('/hatchium/webhook', express.raw({ type: 'application/json' }));
app.post('/hatchium/webhook', (req, res) => {
const expected = crypto
.createHmac('sha256', process.env.HATCHIUM_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
const received = req.get('X-Hatchium-Signature') || '';
const ok =
expected.length === received.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
if (!ok) return res.sendStatus(401);
const payload = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200); // acknowledge first
queue.push(payload); // then process off the request
});
Python (Flask)
pythonimport hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["HATCHIUM_WEBHOOK_SECRET"].encode()
@app.post("/hatchium/webhook")
def hatchium_webhook():
raw = request.get_data()
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-Hatchium-Signature", "")):
abort(401)
payload = request.get_json()
enqueue(payload) # process asynchronously
return "", 200
4.7 Handling deliveries reliably
| Behaviour | Detail |
|---|---|
| Timeout | 10 seconds. Acknowledge first, process afterwards. |
| Success | Any 2xx |
| Retries | Up to 3 attempts, spaced 10s, 60s, then 300s |
| Redirects | Not followed. Give the final URL. |
| After 3 failures | The event is dropped, not replayed later |
Because a retry can arrive after your first attempt actually succeeded, make your handler idempotent. Deduplicate on data.message_id together with event, or on meta_message_id for inbound messages.
4.8 Routing by number
If your account has more than one WhatsApp number, the channel block on every event tells you which one it belongs to, without a lookup:
jsswitch (payload.data.channel?.waba_connection_id) {
case 14: return routeToSupportDesk(payload);
case 22: return routeToSalesDesk(payload);
default: return routeToDefault(payload);
}
To send from a specific number, pass waba_connection_id on the send request. Without it, replies follow the customer's existing conversation and new contacts use your default number.
A number scoped API key always sends from its own number, only reads its own messages and templates, and rejects a conflicting waba_connection_id with KEY_SCOPE_MISMATCH.
5. Error reference
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
MISSING_API_KEY | 401 | No X-API-Key header | Add the header |
INVALID_API_KEY | 401 | Key not recognised or revoked | Check the key |
ACCOUNT_SUSPENDED | 402 | Account suspended over an unpaid invoice | Settle the invoice |
TENANT_INACTIVE | 403 | Account is not active | Contact support |
INSUFFICIENT_CREDITS | 402 | Not enough credits to hold for this send | Top up |
RECIPIENT_OPTED_OUT | 403 | Customer replied STOP, or the number is undeliverable | Do not retry |
OUTSIDE_24H_WINDOW | 403 | Free-form send with no open session | Send an approved template instead |
DUPLICATE_REQUEST | 409 | Same idempotency_key is still being processed | Wait, then re-read the original |
INVALID_INTERACTIVE | 422 | content.interactive is missing type, body or action | Fix the object |
INVALID_WABA_CONNECTION | 422 | Unknown, inactive, or not your number | Check waba_connection_id |
KEY_SCOPE_MISMATCH | 422 | Scoped key was told to send from another number | Drop waba_connection_id, or use the master key |
TEMPLATE_NOT_FOUND | 422 | No such template on this number | Check the name and language |
TEMPLATE_NOT_SUBMITTED / TEMPLATE_PENDING_REVIEW / TEMPLATE_REJECTED / TEMPLATE_NOT_USABLE | 422 | Template is not approved for sending | Wait for approval, or use another |
TEMPLATE_PARAM_MISMATCH | 422 | Variables do not match the approved template | The message names the missing variables |
FREQUENCY_CAP_EXCEEDED | 422 | Marketing cap for this customer reached this week | Do not retry this week |
OTP_COOLDOWN / OTP_RATE_LIMIT_EXCEEDED | 429 | Too many authentication sends to one number | Honour retry_after in the response |
ADDON_REQUIRED | 403 | The channel's add-on is not active on your account | Contact your account manager |
CHANNEL_FIELD_NOT_SUPPORTED | 422 | A WhatsApp-only field on a Messenger/Instagram send | Remove the field named in the message |
CHANNEL_SCOPE_MISMATCH | 403 | A Messenger/Instagram key used on a WhatsApp-only endpoint | Use a WhatsApp or master key |
CONTENT_BODY_REQUIRED | 422 | Text send with an empty content.body | Provide the text |
MEDIA_SOURCE_MISSING | 422 | Media send without a source (content.id/content.link) | See sections 2 and 8 |
SEND_FAILED | 422 | Anything else | Read message |
6. Cost note
conversation_type decides how the send is priced. For free-form replies inside the 24 hour window, set "conversation_type": "service", which costs 1 credit everywhere.
Leaving the field out prices the message as utility instead: the same 1 credit in Zone A, but 2 in Zones B and C and 3 in Zone D. If you message customers outside Zone A, setting it is worth real money.
On template sends the field is ignored: the approved template's own category is always used.
7. Integration checklist
- Unique
idempotency_keyon every send, and retries reuse the same key - Files checked against the size limits before sending
filenameset on every documentcontent.bodyset on every interactive message, so agents see it in the inbox- Branching on
reply.id, notreply.title - Webhook signature verified against the raw body, with a constant time comparison
- Webhook responds within 10 seconds and processes asynchronously
- Webhook handler deduplicates on
message_idplusevent - Inbound media downloaded within 24 hours, using
download_urlexactly as received OUTSIDE_24H_WINDOWhandled by falling back to a templateRECIPIENT_OPTED_OUTtreated as final, never retried
8. Messenger and Instagram
The same POST /messages endpoint sends Facebook Messenger and Instagram direct messages. Add "channel": "messenger" or "channel": "instagram"; leaving channel out means WhatsApp, and nothing changes for existing integrations. The Messenger Channel or Instagram Channel add-on must be active on your account.
8.1 How it differs from WhatsApp
| Messenger / Instagram | ||
|---|---|---|
to | Phone number | The customer's messenger/instagram ID, from the inbound webhook's from field |
| Account selector | waba_connection_id | connection_id (the connected Page / Instagram account) |
type | template, text, media, interactive | text, image, video, audio, document |
| Outside the 24h window | Approved template | Not possible. There is no template escape; the send fails with OUTSIDE_24H_WINDOW |
| Media source | Upload (content.id) or URL | Public URL only (content.link) |
| Cost | Credits by conversation category | No credit charge, access is part of the channel add-on |
Templates, conversation_type, scheduling, and waba_connection_id do not exist on these channels; sending any of them fails with CHANNEL_FIELD_NOT_SUPPORTED naming the field, so nothing is ever silently ignored.
jsonPOST /messages
{
"channel": "messenger",
"to": "24680135790864213",
"connection_id": 7,
"type": "text",
"content": { "body": "Your order has shipped." },
"idempotency_key": "order-1042-shipped"
}
Responses everywhere now carry channel, connection_id, and channel_account_id, so a message can always be traced back to the account that sent or received it. These fields also appear on WhatsApp messages.
8.2 The 24-hour rule, strictly
Messenger and Instagram allow replies only within 24 hours of the customer's last message to that specific Page or account. A message to one of your Pages does not open the window on another. When the window is closed the send fails with OUTSIDE_24H_WINDOW, and the error includes last_inbound_at and window_expires_at so your system can tell "never wrote to us" apart from "wrote to us yesterday".
To check before composing:
textGET /conversations/window?channel=messenger&to=24680135790864213&connection_id=7
{ "open": true, "last_inbound_at": "2026-08-11T14:03:22+00:00", "expires_at": "2026-08-12T14:03:22+00:00" }
8.3 Receiving messages
Two new webhook events, enabled from Settings → Webhooks:
messenger.message.inboundinstagram.message.inbound
They are separate from message.inbound (which stays WhatsApp-only) so an existing integration never starts receiving payloads it was not built for. The payload carries channel, connection_id, channel_account_id, from, type, text, and message_id under the same envelope and signature scheme as every other event (section 4).
8.4 Per-account API keys
From Sub Accounts, every connected Page and Instagram account can issue its own API key. Such a key can only send and read messages for that one account. It cannot touch WhatsApp, templates, media uploads, other accounts, or key management. Reusing an idempotency_key across channels returns the first send's result, so use distinct keys per channel if you generate them from order ids.
Ready to build?
Create an account, connect a number, and issue an API key from your dashboard in minutes. Messages are billed at Meta's own rates with zero markup from Hatchium.