Quickstart in 5 minutes #
Five steps: a key, a channel id, your first message, its status, then your customers’ replies.
- Create an API key. In the dashboard open "Developer API", then "Keys", and create a key. The token is shown once only, so copy it and keep it on your server. The workspace needs API message balance, or requests answer with the code
api_not_enabled. - Find the channel you will send from:
GET /v1/numbers, then copy the id of a channel whose status is connected. - Send your first message:
POST /v1/messages/send. The server answers at once with 202 and the message id. The actual sending happens after that. - Follow the status:
GET /v1/messages/show/{id}. A message goes through queued, sent, delivered and read, or ends as failed. - Receive replies: in "Webhooks" add an HTTPS URL on your server and choose the event
message.received. You get a signed POST for every new customer message.
Step 2: your channels
curl https://derha.net/api/v1/numbers \
-H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret"Step 3: your first message
curl -X POST https://derha.net/api/v1/messages/send \
-H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4821" \
-d '{
"number_id": "7f2a1c9e-3b4d-5e6f-7a8b-9c0d1e2f3a4b",
"to": "201014642581",
"type": "text",
"text": "Hello, your order #4821 has shipped."
}'Step 4: the status
curl https://derha.net/api/v1/messages/show/3c9d8e7f-1a2b-3c4d-5e6f-7a8b9c0d1e2f \
-H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret"For AI models and tools #
This page is plain static HTML with no JavaScript needed, so any crawler or AI model can read it directly. For a lighter or machine-readable form, these public files exist:
| File | What it is |
|---|---|
https://derha.net/llms.txt | A short index of the documentation in the llms.txt standard, linking every file. |
https://derha.net/llms-full.txt | The complete documentation as one Markdown file in English. Paste it as it is into a model context. |
https://derha.net/llms-full.ar.txt | The same complete documentation in Arabic. |
https://derha.net/api-docs/openapi.json | An OpenAPI 3.1 specification of every endpoint. Use it to generate a client or to define tools for an AI model. |
Summary for a model writing code
- Base URL: https://
derha.net/api/v1. JSON, UTF-8. HTTPS only. - Authentication: header Authorization: Bearer <token> on every request. Never put the token in browser code.
- Every response is { success, message, data } on success and { success:false, code, message } on error. Branch on code, never on message.
- Sending is asynchronous: POST /messages/send answers 202 only, and the final result comes from GET /messages/show/{id} or a webhook.
- Send an
Idempotency-Keywith every send so a retry never duplicates the message. - Name the recipient one way only: to with
number_id,conversation_idalone, orcontact_idwithnumber_id. - Verify the signature of every webhook before trusting it, and use the event id to skip duplicates.
- Do not invent endpoints or fields that are not listed here. The endpoints in this documentation are everything available.
Core concepts #
| Concept | Meaning |
|---|---|
| Workspace | The account that owns keys and balance. A key works inside one workspace only and sees nothing else. |
| Business | A brand or activity inside the workspace, with its own channels and customers. Id: business_id. |
| Channel | The line messages leave from: a WhatsApp number, or a Messenger, Instagram, Telegram or TikTok account. Its id is number_id (also called channel_id). |
| Contact | The customer. They may have a phone number, or be known only by a platform id. Id: contact_id. |
| Conversation | The thread between a channel and a contact. Id: conversation_id. To reply to a thread you know, send conversation_id alone. |
| Message | Text or a file, incoming (inbound) or outgoing (outbound). |
| Webhook | An HTTPS URL on your server that receives signed events. It needs no API key. |
Authentication #
Every request carries the key token in the Authorization header as a Bearer token. The token is issued once, when the key is created, and cannot be retrieved afterwards. It starts with mh_live.
Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secretA key can be restricted to certain IP addresses or origins. A request from a place that is not allowed answers ip_not_allowed or origin_not_allowed. A request over plain HTTP is refused with insecure_transport.
Conventions #
Response envelope
Every response has the same shape. Success carries success=true with a data object. An error carries success=false with a stable code (safe to branch on) and a readable message.
// success
{ "success": true, "message": "...", "data": { } }
// error
{ "success": false, "code": "validation_failed", "message": "..." }Idempotency
When sending, add an Idempotency-Key header with a value unique to the operation, such as the order number. If you replay the same request after a network drop you get the same result and the message is not sent twice. The same value with a different body answers idempotency_key_reused, and a request still being processed answers request_in_progress.
Rate limits
Requests are limited per key and per IP. Past the limit you get 429 with the code rate_limited and a Retry-After header holding the seconds to wait. Wait that long, then retry.
Pagination
List endpoints accept page and per_page (maximum 100) and return meta.pagination with total, per_page, current_page and last_page.
Numbers and dates
- Phone numbers are international digits without +, for example 201014642581.
- Dates are ISO 8601, for example 2026-10-03T08:30:00+00:00.
- Ids are UUIDs. Message text is stored and returned exactly as sent.
Endpoints #
| Method | Path | Use |
|---|---|---|
GET | /v1/businesses | List businesses and channels |
GET | /v1/businesses/{id} | Business details |
GET | /v1/numbers | Channels |
POST | /v1/messages/send | Send a message |
GET | /v1/custom-messages | Custom messages |
POST | /v1/messages/send-custom | Send a custom message |
GET | /v1/messages/show/{id} | Message status |
GET | /v1/messages/list | List sent messages |
GET | /v1/messages/inbound | Incoming messages |
GET | /v1/media/{id} | Download a file |
GET | /v1/balance | Balance |
GET | /v1/webhooks/events | Webhook events |
GET | /v1/webhooks | List webhooks |
POST | /v1/webhooks/{id}/test | Send a test event |
GET | /v1/webhooks/deliveries | Delivery log |
POST | /v1/webhooks/deliveries/{id}/redeliver | Resend an event |
List businesses and channels
GET/v1/businesses
Returns every business in your workspace with its channels. Use each channel's number_id to choose the sending channel.
curl https://derha.net/api/v1/businesses \
-H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret"Response 200 OK
{
"success": true,
"message": "...",
"data": {
"businesses": [
{
"id": "9b1c2d3e-4f5a-6b7c-8d9e-0f1a2b3c4d5e",
"name": "Al Amal Store",
"numbers": [
{
"number_id": "7f2a1c9e-3b4d-5e6f-7a8b-9c0d1e2f3a4b",
"name": "Sales",
"phone": "9665xxxxxxxx",
"channel": "whatsapp",
"status": "connected",
"connected": true
}
]
}
]
}
}Business details
GET/v1/businesses/{id}
Returns one business by id with its channels. A business outside your workspace answers 404 with the code business_not_found.
curl https://derha.net/api/v1/businesses/9b1c2d3e-4f5a-6b7c-8d9e-0f1a2b3c4d5e \
-H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret"Channels
GET/v1/numbers
The workspace's channels: id (the number_id), platform channel, public name handle where there is no phone, whether it may start a conversation can_initiate (any, known or none), and whether it is connected now. /v1/channels returns the same list under the key channels.
curl https://derha.net/api/v1/numbers \
-H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret"Response 200 OK
{
"success": true,
"data": {
"numbers": [
{
"id": "7f2a1c9e-3b4d-5e6f-7a8b-9c0d1e2f3a4b",
"business_id": "9b1c2d3e-4f5a-6b7c-8d9e-0f1a2b3c4d5e",
"name": "Sales",
"phone": "201014642581",
"channel": "whatsapp",
"handle": null,
"can_initiate": "any",
"status": "connected",
"connected": true,
"official": true,
"mode": "conversations",
"created_at": "2026-09-01T10:00:00+00:00"
}
]
}
}Send a message
POST/v1/messages/send
Queues a text message and returns 202 at once. Follow its status afterwards with the "Message status" endpoint or a webhook.
Headers
| Name | Type | Required | Description |
|---|---|---|---|
Idempotency-Key | string | No | A unique value that prevents a duplicate send on retry. |
Body parameters (JSON)
Name the recipient one way only: to with number_id, conversation_id alone, or contact_id with number_id. More than one way answers validation_failed.
| Name | Type | Required | Description |
|---|---|---|---|
number_id | uuid | No | The sending channel id. Required with to or contact_id. |
channel_id | uuid | No | Another name for number_id, for channels that are not phone numbers. |
conversation_id | uuid | No | An existing conversation on any platform. The message goes out on its channel, so number_id is not needed. |
contact_id | uuid | No | A contact with an identity on the chosen channel's platform. Their conversation is opened if needed. |
to | string | No | Recipient number in international format WITH its country code, e.g. 201014642581. A leading + and spaces are accepted and stripped. A local number without a country code (01014642581) is rejected. |
text | string | Yes | Message text, up to 4096 characters. |
type | string | No | Message type. Currently only text, which is the default. |
transactional | boolean | No | Set true when the message is about the customer's own order, booking or account (a receipt, a visit reminder, a code). Needed only for a customer who asked to stop messages. See "Customers who asked to stop". |
curl -X POST https://derha.net/api/v1/messages/send \
-H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4821" \
-d '{
"number_id": "7f2a1c9e-3b4d-5e6f-7a8b-9c0d1e2f3a4b",
"to": "201014642581",
"type": "text",
"text": "Hello, your order #4821 has shipped."
}'curl -X POST https://derha.net/api/v1/messages/send \
-H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret" \
-H "Content-Type: application/json" \
-d '{
"conversation_id": "9c0d1e2f-3a4b-5c6d-7e8f-9a0b1c2d3e4f",
"text": "We received your message."
}'const res = await fetch("https://derha.net/api/v1/messages/send", {
method: "POST",
headers: {
"Authorization": "Bearer " + process.env.MESSAGEHUB_TOKEN,
"Content-Type": "application/json",
"Idempotency-Key": "order-4821",
},
body: JSON.stringify({
number_id: "7f2a1c9e-3b4d-5e6f-7a8b-9c0d1e2f3a4b",
to: "201014642581",
type: "text",
text: "Hello, your order #4821 has shipped.",
}),
})
const data = await res.json()
if (!data.success) throw new Error(data.code)import os, requests
res = requests.post(
"https://derha.net/api/v1/messages/send",
headers={
"Authorization": "Bearer " + os.environ["MESSAGEHUB_TOKEN"],
"Idempotency-Key": "order-4821",
},
json={
"number_id": "7f2a1c9e-3b4d-5e6f-7a8b-9c0d1e2f3a4b",
"to": "201014642581",
"type": "text",
"text": "Hello, your order #4821 has shipped.",
},
timeout=10,
)
data = res.json()
if not data["success"]:
raise RuntimeError(data["code"])$response = Http::withToken(env('MESSAGEHUB_TOKEN'))
->withHeaders(['Idempotency-Key' => 'order-4821'])
->post('https://derha.net/api/v1/messages/send', [
'number_id' => '7f2a1c9e-3b4d-5e6f-7a8b-9c0d1e2f3a4b',
'to' => '201014642581',
'type' => 'text',
'text' => 'Hello, your order #4821 has shipped.',
]);Response 202 Accepted
{
"success": true,
"message": "...",
"data": {
"message": {
"id": "3c9d8e7f-1a2b-3c4d-5e6f-7a8b9c0d1e2f",
"number_id": "7f2a1c9e-3b4d-5e6f-7a8b-9c0d1e2f3a4b",
"to": "201014642581",
"type": "text",
"status": "queued",
"created_at": "2026-07-11T10:00:00+00:00"
}
}
}Custom messages
GET/v1/custom-messages
The workspace's prepared messages and what each slot expects: whether it is filled from the customer's record or by you, and whether it is kept after sending. An id beginning with lib: is a platform starter message and is sent the same way. Messages are created in the dashboard only.
curl https://derha.net/api/v1/custom-messages \
-H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret"Send a custom message
POST/v1/messages/send-custom
Sends a prepared message with its slots filled. Slots bound to the customer's record are resolved on the server and ignore anything you send for them. The recipient is named in the same three ways as the send endpoint.
Headers
| Name | Type | Required | Description |
|---|---|---|---|
Idempotency-Key | string | No | Always send it with verification codes: a retry with the same key does not send a second code. |
Body parameters (JSON)
| Name | Type | Required | Description |
|---|---|---|---|
number_id | uuid | No | The sending channel id. Required with to or contact_id. |
channel_id | uuid | No | Another name for number_id, for channels that are not phone numbers. |
conversation_id | uuid | No | An existing conversation on any platform. The message goes out on its channel, so number_id is not needed. |
contact_id | uuid | No | A contact with an identity on the chosen channel's platform. Their conversation is opened if needed. |
to | string | No | Recipient number in international format WITH its country code, e.g. 201014642581. A leading + and spaces are accepted and stripped. A local number without a country code (01014642581) is rejected. |
custom_message_id | string | Yes | The custom message id, or lib:key for a starter message. |
values | object | No | Slot values keyed by slot NUMBER (key "2", value "482913"). Not a list, because bound slots never appear here. |
curl -X POST https://derha.net/api/v1/messages/send-custom \
-H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: otp-2026-09-03-1" \
-d '{
"number_id": "7f2a1c9e-3b4d-5e6f-7a8b-9c0d1e2f3a4b",
"to": "201014642581",
"custom_message_id": "lib:otp",
"values": { "2": "482913", "3": "10 minutes" }
}'Message status
GET/v1/messages/show/{id}
Full details of a message you sent through the API: status, every timestamp, failure information and the complete delivery history. Messages that did not originate from the API are not visible here (incoming messages have their own endpoint).
curl https://derha.net/api/v1/messages/show/3c9d8e7f-1a2b-3c4d-5e6f-7a8b9c0d1e2f \
-H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret"Response 200 OK
{
"success": true,
"message": "...",
"data": {
"message": {
"id": "3c9d8e7f-1a2b-3c4d-5e6f-7a8b9c0d1e2f",
"send_type": "single",
"number_id": "7f2a1c9e-3b4d-5e6f-7a8b-9c0d1e2f3a4b",
"to": "201014642581",
"type": "text",
"text": "Hello, your order #4821 has shipped.",
"status": "delivered",
"failure_reason": null,
"failure_category": null,
"failure_message": null,
"created_at": "2026-07-11T10:00:00+00:00",
"sent_at": "2026-07-11T10:00:01+00:00",
"delivered_at": "2026-07-11T10:00:04+00:00",
"read_at": null,
"failed_at": null,
"history": [
{ "at": "2026-07-11T10:00:00+00:00", "event": "queued" },
{ "at": "2026-07-11T10:00:01+00:00", "event": "attempt", "attempt": 1 },
{ "at": "2026-07-11T10:00:01+00:00", "event": "sent" },
{ "at": "2026-07-11T10:00:04+00:00", "event": "delivered" }
]
}
}
}List sent messages
GET/v1/messages/list
A paginated summary of your API sends, newest first. For one message in full use /v1/messages/show/{id}.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
business_id | uuid | No | Only messages of one business. |
number_id | uuid | No | Only messages of one channel. |
status | string | No | queued, sent, delivered, read or failed. |
page | integer | No | Page number, starting at 1. |
per_page | integer | No | Items per page. Default 25, maximum 100. |
curl "https://derha.net/api/v1/messages/list?number_id=7f2a1c9e-3b4d-5e6f-7a8b-9c0d1e2f3a4b&status=delivered&per_page=25" \
-H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret"Incoming messages
GET/v1/messages/inbound
Messages customers sent to your channels, newest first, in the same shape as message in a webhook. Use it to catch up on events you missed while your server was down, or if you prefer polling to webhooks.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
business_id | uuid | No | One business. |
number_id | uuid | No | One channel (or channel_id). |
channel | string | No | One platform: whatsapp, messenger, instagram, telegram or tiktok. |
since | ISO 8601 | No | Only messages from this time on. |
until | ISO 8601 | No | Only messages up to this time. |
page | integer | No | Page number, starting at 1. |
per_page | integer | No | Items per page. Default 25, maximum 100. |
curl "https://derha.net/api/v1/messages/inbound?since=2026-10-03T00:00:00Z&per_page=50" \
-H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret"Download a file
GET/v1/media/{id}
Returns the bytes of a file attached to a message (image, audio, video, document) at the url given in the attachment. It needs your API key like any other request.
curl -o photo.jpg "https://derha.net/api/v1/media/01a0ff03-16e0-7350-a174-d09c254ee041" \
-H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret"Balance
GET/v1/balance
The message balance every send draws from, and the AI points. Check it before a large batch.
curl https://derha.net/api/v1/balance \
-H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret"Response 200 OK
{
"success": true,
"data": {
"api_enabled": true,
"messages": { "granted": 10000, "used": 2310, "remaining": 7690, "unlimited": false },
"ai_points": { "granted": 500, "used": 120, "remaining": 380, "unlimited": false }
}
}Webhook events
GET/v1/webhooks/events
The available events and what each one means.
curl https://derha.net/api/v1/webhooks/events \
-H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret"List webhooks
GET/v1/webhooks
Every webhook in the workspace: URL, events, channels and whether it is suspended. Secrets are never shown. Webhooks are created and edited in the dashboard only.
curl https://derha.net/api/v1/webhooks \
-H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret"Send a test event
POST/v1/webhooks/{id}/test
Sends a webhook.test event to the given webhook URL. It is sent even while the webhook is suspended, and success resumes it. An id outside your workspace answers webhook_not_found.
curl -X POST https://derha.net/api/v1/webhooks/WEBHOOK_ID/test \
-H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret"Delivery log
GET/v1/webhooks/deliveries
Events sent by the workspace's webhooks, newest first: status, your server's response, attempts and the next retry.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
webhook_id | uuid | No | One webhook only. |
status | string | No | success, failed, pending or queued. |
event | string | No | An event type, such as message.received. |
curl "https://derha.net/api/v1/webhooks/deliveries?status=failed" \
-H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret"Resend an event
POST/v1/webhooks/deliveries/{id}/redeliver
Sends the event again with the same body and the same event id, so your server can tell it already handled it. The event carries the body built when it happened, never a newer version. Limit: 10 resends a minute per webhook.
curl -X POST https://derha.net/api/v1/webhooks/deliveries/DELIVERY_ID/redeliver \
-H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret"Sending rules per platform #
Platforms differ in who may speak first and for how long a reply is allowed. Read can_initiate on channels and reply_window on every message before you send.
| Code | When it happens |
|---|---|
cannot_initiate_on_channel | Instagram, Messenger and TikTok never let the business write first. A send there answers a customer who wrote. |
reply_window_closed | The reply period for this conversation has ended. Wait until the customer writes again. |
human_only_window | Past 24 hours on Messenger and Instagram only a staff member may reply, never the API. |
message_limit_reached | The conversation reached its message limit (10 messages on TikTok) until the customer writes. |
outside_service_window | Official WhatsApp past 24 hours accepts an approved template only. send-custom uses the linked template; otherwise wait for the customer. |
text_too_long_for_channel | The text is longer than the platform allows. |
media_not_supported_on_channel | The platform does not accept this kind of file. |
media_too_large_for_channel | The file is larger than the platform allows. |
text_with_media_not_supported | The platform does not take text with a file. Send them as two messages. |
Text formatting
Write text with WhatsApp's markers: *bold*, _italic_, ~strike~. Telegram receives them as formatting. Instagram, Messenger and TikTok receive the words without the markers. The stored text field stays as you sent it.
Customers who asked to stop
When a customer asks the business to stop, their identity on that platform is marked, and any API send to them is refused with 409 and the code contact_opted_out. The refusal is per platform: a customer who stopped on WhatsApp is still reachable on Telegram. If the message is about their own order, booking or account (a receipt, a visit reminder, a code) send "transactional": true. That is your statement and it does not lift the opt-out. An official WhatsApp template of category MARKETING is always refused for an opted-out customer.
Webhooks #
A webhook sends your server a signed POST request for every event: a new customer message, or a status change of a message you sent. You learn what happens at once, without polling. A webhook needs no API key.
Setup
- In "Developer API", then "Webhooks", add a URL. It must be HTTPS, public, and accept POST.
- Choose the events, then the channels: all of them or selected ones.
- Turn on file download links if your system will download attachments.
- Save the signing secret, shown once, then press Test to check.
Events
You receive only what you subscribe to. New event types may be added later, so ignore a type you do not know instead of rejecting it.
| Event | Sent when |
|---|---|
message.received | A customer wrote to one of your channels. Includes "API only" channels. |
message.recovered | A customer message missed while the channel was disconnected, found when it reconnected. sent_at says when it was sent. Sent only if you subscribe to it. |
message.sent | An outgoing message left: from the platform, from the API, or typed by the owner on their own phone (source = owner_device). |
message.delivered | The message reached the recipient's phone. |
message.read | The recipient read the message. |
message.failed | The message could not be sent. The reason is in failure_reason. |
webhook.test | A test event, sent only when asked for: from the dashboard or POST /v1/webhooks/{id}/test. |
Event shape
Every event is a JSON object with id (the event id), type, api_version, created_at and data. Message events carry data.message, with the same shape for incoming and outgoing messages.
{
"id": "evt_k3j9x2m8q1w7e5r4t6y0u2i8",
"type": "message.received",
"api_version": "v1",
"created_at": "2026-10-03T08:30:00+00:00",
"data": {
"message": {
"id": "01a0ff03-16e0-7350-a174-d09c254ee040",
"direction": "inbound",
"business_id": "9b1c2d3e-4f5a-6b7c-8d9e-0f1a2b3c4d5e",
"number_id": "7f2a1c9e-3b4d-5e6f-7a8b-9c0d1e2f3a4b",
"channel": "whatsapp",
"channel_id": "7f2a1c9e-3b4d-5e6f-7a8b-9c0d1e2f3a4b",
"conversation_id": "9c0d1e2f-3a4b-5c6d-7e8f-9a0b1c2d3e4f",
"from": "201014642581",
"to": "201111229562",
"contact": { "id": "5e6f7a8b-9c0d-1e2f-3a4b-5c6d7e8f9a0b", "name": "Ahmed", "phone": "201014642581", "handle": null },
"reply_window": { "state": "open", "closes_at": "2026-10-04T08:29:58+00:00", "remaining_messages": null, "reason": null },
"source": "contact",
"api_key_id": null,
"type": "image",
"text": "Is this one available?",
"attachments": [
{
"id": "01a0ff03-16e0-7350-a174-d09c254ee041",
"type": "image",
"mime_type": "image/jpeg",
"name": null,
"size": 34191,
"ready": true,
"url": "https://derha.net/api/v1/media/01a0ff03-16e0-7350-a174-d09c254ee041"
}
],
"contacts": [],
"reply_to": null,
"status": "received",
"failure_reason": null,
"sent_at": "2026-10-03T08:29:58+00:00",
"created_at": "2026-10-03T08:30:00+00:00"
}
}
}data.message fields
| Field | Type | Meaning |
|---|---|---|
id | uuid | The message id. |
direction | string | inbound for incoming, outbound for one you sent. |
business_id | uuid | The business the channel belongs to. |
number_id | uuid | The channel that received or sent it. |
channel | string | The platform: whatsapp, messenger, instagram, telegram or tiktok. |
channel_id | uuid | The same value as number_id. |
conversation_id | uuid | The conversation the message belongs to. Use it to reply. |
from | string | The sender as international digits without +. For a customer with no phone, the platform's own id. |
to | string | The recipient, in the same format. |
contact | object | The contact: id, name, phone (a phone number only) and handle. |
reply_window | object | Whether a reply may be sent now: state (open, human_only, template_only or closed), closes_at, remaining_messages and reason. |
source | string | Who sent it: contact for the customer; api, agent, campaign, ai_agent, owner_device and others for outgoing messages. |
api_key_id | uuid|null | The key an API message was sent with, otherwise null. Tells apart two integrations that each use their own key. |
type | string | text, image, video, audio, voice, document, sticker, location, contact_card or unsupported. |
text | string|null | The message text or the file caption. |
attachments | array | Files: id, type, mime_type, size, ready, and url to download with the key. Plus download_url and download_expires_at when file download links are on. |
contacts | array | Contact cards the customer shared. |
reply_to | uuid|null | The id of the message the customer is replying to. |
status | string | received for an incoming message, or the status of an outgoing one. |
failure_reason | string|null | Why it failed, in message.failed. |
sent_at | ISO 8601 | When the customer sent it. Earlier than created_at for a recovered or late message. |
created_at | ISO 8601 | When the platform recorded it. |
Verifying the signature
Verify every request before trusting it. The X-MessageHub-Signature-256 header has the form t=<time>,v1=<signature>, where the signature is the HMAC-SHA256 of "time.body" with your signing secret, in hex. Reject a request whose time is more than 5 minutes from your clock: that stops an old request being replayed. Use the body exactly as received, without re-serializing it.
| Header | Value |
|---|---|
X-MessageHub-Event | The event type. |
X-MessageHub-Delivery | The event id, the same as id in the body. Use it to skip duplicates. |
X-MessageHub-Webhook-Id | The id of the webhook that sent the event. Useful when several webhooks share one URL. |
X-MessageHub-Timestamp | When it was sent, in Unix seconds. |
X-MessageHub-Signature-256 | The recommended signature: t=…,v1=…. It may hold several v1 values while a secret is being rotated. |
X-MessageHub-Signature | The original sha256=… signature of the body alone. Kept for existing receivers; prefer the 256 header. |
User-Agent | MessageHub-Webhooks/1.0 |
import crypto from 'node:crypto'
// Read the RAW body: app.post('/webhook', express.raw({ type: 'application/json' }), handler)
export function verifyMessageHub(rawBody, header, secret, toleranceSeconds = 300) {
const parts = header.split(',').map((p) => p.split('='))
const t = parts.find(([k]) => k === 't')?.[1]
const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v)
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
return signatures.some(
(sig) => sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
)
}
// verifyMessageHub(req.body.toString('utf8'), req.get('X-MessageHub-Signature-256'), process.env.MESSAGEHUB_WEBHOOK_SECRET)<?php
function verifyMessageHub(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
$t = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', $part, 2), 2, '');
if ($k === 't') { $t = $v; } elseif ($k === 'v1') { $signatures[] = $v; }
}
if ($t === null || abs(time() - (int) $t) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $t.'.'.$rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
$raw = file_get_contents('php://input');
if (! verifyMessageHub($raw, $_SERVER['HTTP_X_MESSAGEHUB_SIGNATURE_256'] ?? '', getenv('MESSAGEHUB_WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}
http_response_code(200); // answer first, then process the event in a queueimport hashlib, hmac, time
def verify_messagehub(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
t, signatures = None, []
for part in header.split(','):
key, _, value = part.partition('=')
if key == 't':
t = value
elif key == 'v1':
signatures.append(value)
if t is None or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures)
# Flask: verify_messagehub(request.get_data(), request.headers.get('X-MessageHub-Signature-256', ''), SECRET)Rotating the secret
When you generate a new secret the previous one stays valid for 24 hours and every request carries two v1 signatures, one per secret. Update your server within that window and no event is lost.
Delivery and retries
- An event counts as delivered when your server answers any 2xx within 5 seconds.
- 5xx, 429 or a network error: retried, honouring a
Retry-Afterheader if you send one. - Any other 4xx, or a redirect: failed for good, not retried. Redirects are not followed.
- Retries after 10 seconds, then 1 minute, 5 minutes, 30 minutes, 1 hour, 3 hours and 6 hours.
- After 20 consecutive final failures the webhook is suspended and the workspace owner is alerted.
- A successful test event resumes a suspended webhook. Any event can be resent from the log.
Receiving well
- Answer 200 quickly, then process the event in the background.
- The same event can arrive more than once. Store its id and skip duplicates.
- Arrival order is not guaranteed. Rely on
created_atand on the status order: sent, delivered, read. - Check the signature and the time before doing anything else.
- Download files from url with your API key, and retry while ready is false.
- If your server was down, catch up with
GET /v1/messages/inbound, or resend failed events from the log.
Full example: a Node.js receiver
import express from 'express'
import { verifyMessageHub } from './verify.js'
const app = express()
const seen = new Set() // use Redis or a database table in production
app.post('/messagehub', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8')
if (!verifyMessageHub(raw, req.get('X-MessageHub-Signature-256') ?? '', process.env.MESSAGEHUB_WEBHOOK_SECRET)) {
return res.sendStatus(401)
}
res.sendStatus(200) // answer first, within 5 seconds
const event = JSON.parse(raw)
if (seen.has(event.id)) return // the same event can arrive twice
seen.add(event.id)
if (event.type === 'message.received') {
const m = event.data.message
console.log('New message from', m.from, ':', m.text)
}
})
app.listen(3000)File download links
The attachment url needs an API key. If your system receives webhooks without a key, turn on "file download links" for the webhook: each attachment then carries a download_url that opens without a key until download_expires_at (24 hours by default). A signed link cannot be revoked, so whoever gets it opens the file until it expires. That is why it is off by default.
Errors #
Every error returns the same envelope with a stable code you can branch on. The HTTP status alone is not enough, because several codes share one (such as 404 and 422). Branch on code. Validation errors add an errors object with per-field details.
| Code | HTTP | Meaning |
|---|---|---|
unauthenticated | 401 | Invalid or missing API key. |
origin_not_allowed | 401 | Request from an origin not allowed for this key. |
insecure_transport | 403 | The API must be called over HTTPS. |
ip_not_allowed | 403 | Request from an IP address not allowed for this key. |
business_not_found | 404 | No business with this id in your workspace. |
number_not_found | 404 | No channel matches number_id in your workspace. |
conversation_not_found | 404 | No conversation with this id in your workspace. |
contact_not_found | 404 | No contact with this id. |
media_not_found | 404 | No file with this id that this key may open. |
webhook_not_found | 404 | No webhook with this id in your workspace. |
message_not_found | 404 | No message matches the given id. |
custom_message_not_found | 404 | The requested custom message does not exist in your workspace. |
number_not_connected | 409 | The selected channel is not currently connected. |
media_not_ready | 409 | The file has not been fetched from the platform yet. Try again in 30 seconds. |
request_in_progress | 409 | A request with the same Idempotency-Key is still processing. |
contact_opted_out | 409 | The customer asked to stop on this platform. Send transactional=true only when the message is about their own order. |
validation_failed | 422 | The request payload is invalid. |
slot_value_missing | 422 | A value is missing for a required slot of the custom message. |
contact_not_on_channel | 422 | The contact has no identity on this channel's platform. |
cannot_initiate_on_channel | 422 | This platform does not let the business write first. |
reply_window_closed | 422 | The reply period for this conversation has ended. |
human_only_window | 422 | Only staff may reply in this conversation now, not the API. |
message_limit_reached | 422 | The conversation reached its message limit until the customer writes again. |
outside_service_window | 422 | Official WhatsApp past 24 hours takes an approved template only. |
text_too_long_for_channel | 422 | The text is longer than this platform allows. |
media_not_supported_on_channel | 422 | This platform does not accept this kind of file. |
media_too_large_for_channel | 422 | The file is larger than this platform allows. |
text_with_media_not_supported | 422 | This platform does not take text with a file. Send them as two messages. |
idempotency_key_reused | 422 | This Idempotency-Key was already used for a different request payload. |
quota_exhausted | 422 | The workspace's API message balance is empty. Top it up to keep sending. |
api_not_enabled | 422 | The API is not enabled for this workspace. Buy API message quota to enable it. |
rate_limited | 429 | Too many requests. Wait Retry-After, then retry. |
server_error | 500 | An unexpected error occurred. Try again. |
It is safe to retry on 429 and 5xx (with an Idempotency-Key the send is not duplicated). Other 4xx errors come from the request itself: fix it instead of repeating it unchanged.
FAQ #
How do I get an API key?
In the dashboard open "Developer API", then "Keys", and create a key. The token is shown once only, so copy it at once. The workspace also needs API message balance.
Is sending instant?
No. The send endpoint answers 202 at once after queuing the message, and the actual sending follows. For the final result use "Message status" or a webhook.
Why do API messages not show in the inbox?
Because this route is for notifications, verification codes and receipts, not a conversation between staff and a customer. Follow them with the status endpoint or a webhook.
How do I stop a retry sending twice?
Send an Idempotency-Key header with a value unique to each operation, such as the order number. A replay with the same value returns the first result.
Can I write to any customer first?
It depends on the platform. WhatsApp allows it depending on the channel type, while Instagram, Messenger and TikTok never let the business write first. Read can_initiate in GET /v1/numbers.
How do I know a message arrived?
Subscribe to message.delivered and message.read in a webhook, or read status in the "Message status" endpoint.
Can I create webhooks from code?
No. Creating and editing happen in the dashboard only. From code you can list them, test them, read their log and resend their events.
Can I send images or files through the API?
Not yet. The send endpoint supports text (type=text), and custom messages cover prepared messages. Receiving files from customers and downloading them is supported.