Search for a command to run...
API reference
messages:sendSend through a connected client using a Meta-compatible payload. IntelliPortal resolves the channel from client_ref and normalizes authentication, errors, request logging, and test mode.
/messages/sendSend from your backend with a key that has messages:send. The property named by type is forwarded in Meta's shape.
curl -X POST https://api.intelliconcierge.dev/v1/messages/send \
-H "Authorization: Bearer $INTELLI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"client_ref": "customer_42",
"to": "+254700000000",
"type": "text",
"text": { "body": "Your order is ready." }
}'| Property | Type | Description |
|---|---|---|
client_refrequired | string | Your stable identifier for the connected client and channel used to send. |
torequired | string | WhatsApp phone number (messages[].from in your webhook) or, for a customer who hides their number, their business-scoped user ID (messages[].from_user_id, e.g. US.13491208655302741918) — routed to Meta's recipient parameter for you. On Instagram, the Instagram-scoped user ID. |
recipient | string | A business-scoped user ID under the Cloud API's own parameter name — an alias for the same value in to, for bodies written against Meta's docs. Pass either to or recipient, not both. |
typerequired | enum | Selects the matching payload object: text, image, video, audio, document, sticker, location, contacts, interactive, or reaction. |
<type>required | object | The payload whose property name matches type. Its shape mirrors Meta's Cloud API. |
dry_run | boolean | Validate without delivery. With an ik_test_ key, sends are simulated unless `to` is one of your registered test recipients — those deliver for real. |
Media can use a public HTTPS link or an uploaded Meta media id. Free-form sends require an open 24-hour customer-service window.
| type | Payload essentials |
|---|---|
| text | body; optional preview_url |
| image | link or id; optional caption |
| video | link or id; optional caption |
| audio | link or id |
| document | link or id; optional caption and filename |
| sticker | link or id |
| location | latitude, longitude; optional name and address |
| contacts | Array of Meta contact objects |
| interactive | button, list, cta_url, and other Meta interactive shapes |
| reaction | message_id and emoji; an empty emoji removes the reaction |
{
"client_ref": "customer_42",
"to": "+254700000000",
"type": "text",
"text": {
"body": "Your order is ready.",
"preview_url": false
}
}/messages/send-templateTemplates are WhatsApp-only and can open a conversation outside the 24-hour service window. Reference a template you registered through the Templates API by template_ref and pass plain values — IntelliPortal resolves the template, checks that Meta has approved it, and builds the components for you. Only templates with status APPROVED are sendable; anything else returns template_not_approved instead of an opaque Meta error.
Prefer full control? Send a raw Meta template object instead — name, language, and components exactly as Meta's Cloud API takes them. Use one form or the other, not both.
curl -X POST https://api.intelliconcierge.dev/v1/messages/send-template \
-H "Authorization: Bearer $INTELLI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"client_ref": "customer_42",
"to": "+254700000000",
"template_ref": "order_ready",
"parameters": ["1842", "$12.50"]
}'| Property | Type | Description |
|---|---|---|
client_refrequired | string | Your stable identifier for the connected client whose WhatsApp Business Account holds the template. |
torequired | string | WhatsApp phone number or, for a customer who hides their number, their business-scoped user ID (messages[].from_user_id). One-tap, zero-tap and copy-code authentication templates need a phone number. recipient is accepted as an alias for the ID. |
template_refrequired | string | Template id from the Templates API, or the template name. Add language when the same name exists in several languages. |
language | string | Language code (e.g. en_US) to disambiguate template_ref by name. |
parameters | array | object | Body variable values. An array fills {{1}}, {{2}}… in order; an object matches named variables ({"name": "Ada"}) or numbered keys ({"1": "1842"}). |
header | object | Header variable, when the template has one: {"text": ...} for a text header, {"link": ...} or {"id": ...} for image / video / document (optional "filename" for documents), {"location": {...}} for location headers. |
buttons | array | Values in button order. Pass one per dynamic URL button (its {{1}} suffix) — or one for every dynamic URL button and quick reply, where a quick-reply value becomes the button.payload your webhook receives on tap. Quick replies left unset return their button text as the payload. |
cards | array | Carousel templates only: one object per card, in card order — {"header": {"link" | "id": ...}, "parameters": [...], "buttons": [...]}. Every card needs its header media at send time; quick replies take the payload your webhook receives on tap, dynamic URLs take their suffix. |
dry_run | boolean | Validate without delivery. With an ik_test_ key, sends are simulated unless `to` is a registered test recipient. The dry-run response echoes the assembled template object. |
{
"client_ref": "customer_42",
"to": "+254700000000",
"template_ref": "order_ready",
"parameters": ["1842", "$12.50"]
}Template send errors
400 with a code your integration can branch on:| template_not_approved | The template exists but Meta hasn't approved it (or rejected it — the reason is included). |
| template_not_found | No template with that id or name on this client's WhatsApp Business Account. |
| ambiguous_template | The name exists in several languages — pass language or use the template id. |
| missing_parameters | The template body has variables you didn't supply; the message lists them. |
| parameter_count_mismatch | A positional parameters array has the wrong length. |
| missing_header | The template has a dynamic header and header wasn't supplied. |
| missing_button_parameters | The template has dynamic URL buttons and buttons wasn't supplied. |
| empty_parameter | A supplied body, header, button or card value is blank — Meta rejects empty parameters. |
| invalid_header_link | header.link isn't a public http(s) URL Meta could fetch. |
| missing_cards | The template is a carousel and cards wasn't supplied, or its length doesn't match the card count. |
| missing_card_buttons | A card's buttons list doesn't cover its quick replies and dynamic URL buttons; the error names the card. |
| template_layout_unsupported | Authentication templates and copy-code buttons can't be assembled from template_ref yet — send them as a raw template object. |
/mediaTrade bytes for a media id you can reference from sends — header.id on a template, image.id and friends on a free-form message. Meta keeps the upload for 30 days, so one upload serves many sends.
Prefer an id over link in production: a linked URL is re-fetched by Meta at every delivery, after the API has already accepted the message — if the fetch fails, the message silently dies with a failed status on your webhook. WhatsApp-only: Instagram takes media as a public URL on the send itself.
curl -X POST https://api.intelliconcierge.dev/v1/media \
-H "Authorization: Bearer $INTELLI_API_KEY" \
-F client_ref=customer_42 \
-F file=@menu.jpg{
"success": true,
"media_id": "1013859600285441",
"kind": "image",
"expires_in_days": 30
}Accepted uploads: image jpeg/png (≤5 MB), video mp4/3gpp (≤16 MB), audio aac/mp4/mpeg/amr/ogg (≤16 MB), documents pdf, Office formats and txt (≤100 MB), sticker webp (≤500 KB). Anything else returns media_type_unsupported or media_too_large before reaching Meta.
Do not send a separate channel parameter. IntelliPortal looks up the client and routes through its connected channel.
Instagram takes text and media
to to the Instagram-scoped sender ID from a webhook. Text is limited to 1,000 characters and must be sent inside Instagram's reply window.image, video, audio and document are supported and must carry link — Instagram fetches media from a public HTTPS URL and cannot use an uploaded media id, which returns media_link_required. A caption or filename has nowhere to go on an Instagram attachment, so the send succeeds and reports it under warnings; send the caption as its own text message if you need it.sticker, location, contacts, interactive and reaction have no Instagram equivalent and return channel_type_unsupported.Store message_id to correlate later delivery status webhooks. Also capture the X-Request-Id response header for operational tracing.
{
"success": true,
"message_id": "wamid.xxx"
}{
"success": true,
"message_id": "wamid.TEST_xxx",
"dry_run": true
}A warnings array appears when the message went out but the channel could not carry part of what you sent — a caption on an Instagram attachment, for instance. The send still succeeded; nothing needs retrying. Log these, because they mean content you supplied never reached the recipient.
{
"success": true,
"message_id": "mid.xxx",
"warnings": [
"caption is not supported on Instagram attachments and was not sent — send it as a separate text message if you need it."
]
}/messages/read·POST/messages/typingAcknowledge what a customer sent before you answer. /messages/read paints the blue ticks on WhatsApp and “Seen” on Instagram. /messages/typing shows the typing bubble while your reply is being prepared and marks the conversation read in the same call. Both use messages:send, create no message, and never count toward usage.
curl -X POST https://api.intelliconcierge.dev/v1/messages/typing \
-H "Authorization: Bearer $INTELLI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"client_ref": "customer_42",
"to": "254700000000",
"message_id": "wamid.xxx"
}'| Property | Type | Description |
|---|---|---|
client_refrequired | string | Your stable identifier for the connected client whose conversation this is. |
torequired | string | The customer: the WhatsApp number the message came from (messages[].from in the webhook), their business-scoped user ID when they hide it (messages[].from_user_id), or the Instagram-scoped user ID (messages[].sender.id). recipient is accepted as an alias for a business-scoped user ID. |
message_id | string | WhatsApp: required — the wamid of the message you received (messages[].id in message.received). Only inbound messages can be marked read. Instagram: ignored, seen is per conversation. |
state | enum | /messages/typing only. on (default) shows the indicator and marks the conversation read; off clears it on Instagram and is accepted as a no-op on WhatsApp. |
dry_run | boolean | Validate without calling Meta. With an ik_test_ key, calls are simulated unless `to` is one of your registered test recipients. |
{
"success": true
}{
"success": true,
"state": "on"
}Channel differences
message_id — messages[].id from your message.received webhook — is required (message_id_required). Only messages you received can be marked read; one of your own message ids comes back as 422 meta_rejected. Meta only shows the typing bubble on a message being marked read, which is why /messages/typing sends the read receipt too. The bubble clears by itself when you send a message or after 25 seconds, and there is no call to clear it early: state: "off" is accepted as a no-op and reported under warnings.to is the Instagram-scoped user ID and message_id is ignored. /messages/read marks the thread seen; /messages/typing marks it seen and then shows typing, and state: "off" clears the indicator.The usual bot loop is one call per step: on message.received, call /messages/typing; produce the answer; call /messages/send, which clears the indicator. If the answer can take longer than 25 seconds on WhatsApp, call /messages/typing again. Only show typing when a reply is actually coming — that is Meta's guidance, and it keeps the indicator meaningful to the customer. Test keys and dry_run follow the same rules as sends: simulated unless to is one of your registered test recipients.
Retry-After on 429. Do not immediately repeat requests rejected by Meta without correcting the cause.402 trial_expired or subscription_expired blocks product mutations, including dry-runs, until an active plan is confirmed.Copy the integration contract into your assistant, then let it tailor the implementation to your stack.
You are helping me build outbound messaging into my product using the Intelli Partner API (base URL: https://api.intelliconcierge.dev/v1). The WhatsApp/Instagram accounts I send from are already connected through Intelli; every request carries my Intelli API key and a client_ref naming the connected account.
Before writing code, ask me:
1. My backend framework/language.
2. Which channels I use (WhatsApp, Instagram or both) and whether I send templates, free-form messages, or both.
Environment variables (server-side only — the API key must NEVER reach the browser):
- INTELLI_API_KEY: ik_test_... keys simulate every send except to my registered test recipients (my own phone / Instagram username), which receive the real message; ik_live_... delivers for real. Auth header: Authorization: Bearer $INTELLI_API_KEY (scope messages:send).
A) Free-form messages — POST /messages/send
- Body: {"client_ref": "...", "to": "...", "type": "...", "<type>": {...}} mirroring Meta's Cloud API shape. WhatsApp types: text, image, video, audio, document, sticker, location, contacts, interactive, reaction. Media takes link (public HTTPS) or id (uploaded — see C).
- to is the customer's WhatsApp number (messages[].from in my webhook) or, when they hide their number (WhatsApp usernames), their business-scoped user ID (messages[].from_user_id, e.g. US.13491208655302741918) — Intelli routes an ID to the Cloud API's recipient parameter for me; I never send both. "recipient" is accepted as an alias for the ID. Both identifiers are stable per customer; the ID is scoped to this business and useless elsewhere.
- WhatsApp free-form only works inside the 24-hour customer-service window; outside it, send an approved template.
- Instagram: to is the Instagram-scoped user ID from a webhook; text (max 1000 chars) plus image/video/audio/document by link only. caption/filename can't be carried — the send succeeds and reports them under warnings.
- Never send a channel parameter: Intelli routes by client_ref.
B) Template messages — POST /messages/send-template
- Preferred shape: {"client_ref", "to", "template_ref": "<template id or name>", "language": "en_US" (only to disambiguate a name), "parameters": [...] or {...}, "header": {...}, "buttons": [...]}.
- parameters fills body variables: an array for positional {{1}}, {{2}}…, an object for named variables. header only when the template has a dynamic header: {"text": ...} or {"link"|"id": ...} (+ "filename" for documents). buttons: values in button order — one per dynamic URL button (its suffix), or one for every dynamic URL button and quick reply, where a quick-reply value is the payload my webhook gets back as button.payload on tap (unset quick replies return their button text).
- Carousel templates add "cards": one object per card, in card order — {"header": {"link"|"id": ...}, "parameters": [...], "buttons": [...]}. Every card needs its header media at send time; quick replies take the payload my webhook receives on tap, dynamic URLs their suffix.
- Intelli resolves the template and refuses anything not APPROVED (400 template_not_approved, with Meta's rejection reason when there is one) and any missing or blank value (missing_parameters, parameter_count_mismatch, missing_header, missing_button_parameters, missing_cards, missing_card_buttons, empty_parameter, invalid_header_link — each error names exactly what's missing). Gate my own UI on the template being APPROVED.
- Full-control alternative: a raw Meta "template" object instead of template_ref (exactly one of the two). Authentication templates and copy-code buttons currently require the raw form (template_layout_unsupported otherwise).
C) Send media — POST /media (multipart/form-data: client_ref, file)
- Returns {"media_id", "kind", "expires_in_days": 30}. Reference it as header.id on template sends or image.id / video.id / … on free-form sends.
- Prefer an id over a link in production: Meta re-fetches a link at EVERY delivery, after the API has already returned 200 — an unreachable link kills the message asynchronously (a failed status on my webhook), which looks like silent non-delivery.
- Limits are enforced with clear errors: image jpeg/png ≤5 MB, video mp4/3gpp ≤16 MB, audio ≤16 MB, document pdf/Office/txt ≤100 MB, sticker webp ≤500 KB. WhatsApp-only — Instagram media travels by URL on the send.
D) Test mode
- dry_run: true validates everything but never delivers; the response carries dry_run: true and, on template_ref sends, the fully assembled template object so I can inspect what a live send would carry.
- With an ik_test_ key, only my registered test recipients receive real messages; any other recipient gets a simulated response with a hint. I register recipients in the Intelli portal (Sandbox → Test recipients — one WhatsApp number and one Instagram username).
E) Responses and errors
- Success: {"success": true, "message_id": "..."} — store message_id to correlate message.status webhooks (sent / delivered / read / failed). Delivery is asynchronous: 200 means Meta ACCEPTED the message, not that it arrived.
- A warnings array means the message went out but a field couldn't be carried — log it.
- Failures use one envelope with a stable code to branch on. 422 meta_rejected carries Meta's own code/message and sometimes a hint. Honor Retry-After on 429. A 402 trial_expired or subscription_expired response blocks product mutations, including dry-runs, until a plan is active.
F) Read receipts and typing indicators — POST /messages/read and POST /messages/typing
- Body: {"client_ref", "to", "message_id"} (+ "state": "on" | "off" on /typing, default on). to is the customer — messages[].from (WhatsApp) or messages[].sender.id (Instagram) from my message.received webhook; message_id is messages[].id — required on WhatsApp (400 message_id_required), ignored on Instagram where seen is per conversation.
- /messages/read = blue ticks (WhatsApp) / Seen (Instagram). /messages/typing = typing bubble AND marks the conversation read, on both channels. Neither creates a message or counts toward usage; both use messages:send. Responses: {"success": true} and {"success": true, "state": "on"}.
- Bot loop: on message.received call /messages/typing, produce the answer, then /messages/send — sending clears the bubble. WhatsApp clears it by itself after 25 seconds, so call /typing again if the answer takes longer; there is no early clear on WhatsApp (state: "off" is a warned no-op there, it clears the indicator on Instagram). Only show typing when a reply is actually coming.
- Same test-mode rules as sends (dry_run, test keys reach only registered test recipients). Marking one of MY sent messages as read is refused by Meta — 422 meta_rejected.
Write idiomatic, production-quality code for my stack, retrying only where the rules above allow it.