Features Shopify AI Chatbot Pricing Contact Start Free Trial
Developer API

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:

  1. Media messages, sending images, video, audio and documents, and receiving them.
  2. Interactive menus, reply buttons and list pickers, and reading the customer's choice.
  3. 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 URLhttps://app.hatchium.ai/api/v1
Auth headerX-API-Key: <your key>
Content typeapplication/json
Rate limit60 requests per minute, per API key
Send endpointPOST /messages
Success202 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 messageFree-form message
typetemplatetext, image, video, audio, document, interactive
When you can sendAny timeOnly within 24 hours of the customer's last message to that number
ApprovalTemplate must be approved by Meta firstNo approval needed
Outside the windowWorksRejected 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

FieldApplies toNotes
linkall media typesPublic HTTPS URL. Must be reachable by WhatsApp without authentication.
idall media typesA 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.
captionimage, video, documentText shown under the file.
filenamedocumentThe name the customer sees when saving the file. Set this on every document.
voiceaudiotrue 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.

typeFormatsMax size
imageJPEG, PNG5 MB
videoMP4, 3GP (H.264 video, AAC audio)16 MB
audioAAC, AMR, MP3, M4A (AAC codec), OGG (Opus codec)16 MB
documentAny file type100 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?

TypeRecommendation
audioAlways 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, documentLink 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_id to upload for a specific number; a number-scoped API key always uploads to its own number. Sending an ID from the wrong number returns MEDIA_SCOPE_MISMATCH.
  • Media IDs expire. WhatsApp deletes uploaded media after 30 days; expires_at tells you when. Sending an expired ID returns MEDIA_EXPIRED, so upload again.
  • Type must match. Sending an audio upload as type: image returns MEDIA_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 by link. Unsupported formats return UNSUPPORTED_MEDIA_TYPE, and a file that cannot be converted returns MEDIA_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:

ElementLimit
Buttons per message3
Button title20 characters
Button / row id256 characters
List rows, total across all sections10
List sections10
Row title24 characters
Row description72 characters
List action button text20 characters
Body text1024 characters
Header text60 characters
Footer text60 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
  }
}
FieldMeaning
reply.kindbutton for a reply button or a template quick reply, list for a list row
reply.idThe id you set when sending. This is what you branch on.
reply.titleThe 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

  1. Webhook Integration must be active on your plan.
  2. Go to Settings → Webhooks in your Hatchium account.
  3. Enter your endpoint URL. It must be public HTTPS. Private, internal and loopback addresses are rejected.
  4. Tick the events you want.
  5. Save. A signing secret is generated on first save, starting with whsec_. Copy it.

4.2 Events

EventFires when
message.sentWhatsApp accepted the message
message.deliveredIt reached the customer's device
message.readThe customer opened it
message.failedDelivery failed
message.inboundA 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:

HeaderValue
Content-Typeapplication/json
X-Hatchium-EventThe event name, matching event in the body
X-Hatchium-SignatureHex HMAC-SHA256 of the raw request body, keyed with your signing secret
X-Webhook-EventThe same event name
X-Webhook-SignatureThe 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:

FieldNotes
message_idHatchium's ID for the inbound message
meta_message_idThe WhatsApp message ID, useful for reply threading
fromCustomer number, digits only, no +
typetext, image, document, audio, video, sticker, interactive, button, location, contacts
textBody text, null for non text messages
channelWhich of your numbers received it. null on older messages predating multi number support.
replyButton tap or list pick, otherwise null
mediaAttachment 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

BehaviourDetail
Timeout10 seconds. Acknowledge first, process afterwards.
SuccessAny 2xx
RetriesUp to 3 attempts, spaced 10s, 60s, then 300s
RedirectsNot followed. Give the final URL.
After 3 failuresThe 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

CodeHTTPMeaningWhat to do
MISSING_API_KEY401No X-API-Key headerAdd the header
INVALID_API_KEY401Key not recognised or revokedCheck the key
ACCOUNT_SUSPENDED402Account suspended over an unpaid invoiceSettle the invoice
TENANT_INACTIVE403Account is not activeContact support
INSUFFICIENT_CREDITS402Not enough credits to hold for this sendTop up
RECIPIENT_OPTED_OUT403Customer replied STOP, or the number is undeliverableDo not retry
OUTSIDE_24H_WINDOW403Free-form send with no open sessionSend an approved template instead
DUPLICATE_REQUEST409Same idempotency_key is still being processedWait, then re-read the original
INVALID_INTERACTIVE422content.interactive is missing type, body or actionFix the object
INVALID_WABA_CONNECTION422Unknown, inactive, or not your numberCheck waba_connection_id
KEY_SCOPE_MISMATCH422Scoped key was told to send from another numberDrop waba_connection_id, or use the master key
TEMPLATE_NOT_FOUND422No such template on this numberCheck the name and language
TEMPLATE_NOT_SUBMITTED / TEMPLATE_PENDING_REVIEW / TEMPLATE_REJECTED / TEMPLATE_NOT_USABLE422Template is not approved for sendingWait for approval, or use another
TEMPLATE_PARAM_MISMATCH422Variables do not match the approved templateThe message names the missing variables
FREQUENCY_CAP_EXCEEDED422Marketing cap for this customer reached this weekDo not retry this week
OTP_COOLDOWN / OTP_RATE_LIMIT_EXCEEDED429Too many authentication sends to one numberHonour retry_after in the response
ADDON_REQUIRED403The channel's add-on is not active on your accountContact your account manager
CHANNEL_FIELD_NOT_SUPPORTED422A WhatsApp-only field on a Messenger/Instagram sendRemove the field named in the message
CHANNEL_SCOPE_MISMATCH403A Messenger/Instagram key used on a WhatsApp-only endpointUse a WhatsApp or master key
CONTENT_BODY_REQUIRED422Text send with an empty content.bodyProvide the text
MEDIA_SOURCE_MISSING422Media send without a source (content.id/content.link)See sections 2 and 8
SEND_FAILED422Anything elseRead 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_key on every send, and retries reuse the same key
  • Files checked against the size limits before sending
  • filename set on every document
  • content.body set on every interactive message, so agents see it in the inbox
  • Branching on reply.id, not reply.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_id plus event
  • Inbound media downloaded within 24 hours, using download_url exactly as received
  • OUTSIDE_24H_WINDOW handled by falling back to a template
  • RECIPIENT_OPTED_OUT treated 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

WhatsAppMessenger / Instagram
toPhone numberThe customer's messenger/instagram ID, from the inbound webhook's from field
Account selectorwaba_connection_idconnection_id (the connected Page / Instagram account)
typetemplate, text, media, interactivetext, image, video, audio, document
Outside the 24h windowApproved templateNot possible. There is no template escape; the send fails with OUTSIDE_24H_WINDOW
Media sourceUpload (content.id) or URLPublic URL only (content.link)
CostCredits by conversation categoryNo 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.inbound
  • instagram.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.

Start Free Trial Talk to Our Team