# MessageHub API: MessageHub API documentation: send messages and receive webhooks > A public guide, no login needed, to connect your system to MessageHub: send WhatsApp, Messenger, Instagram, Telegram and TikTok messages, and receive customer replies with signed webhooks, with ready examples in cURL, Node.js, Python and PHP. Base URL: https://derha.net/api/v1 Interactive version: https://derha.net/api-docs/en/ OpenAPI spec: https://derha.net/api-docs/openapi.json ## Quickstart in 5 minutes Five steps: a key, a channel id, your first message, its status, then your customers’ replies. 1. 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. 2. Find the channel you will send from: GET /v1/numbers, then copy the id of a channel whose status is connected. 3. 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. 4. Follow the status: GET /v1/messages/show/{id}. A message goes through queued, sent, delivered and read, or ends as failed. 5. 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 ```bash curl https://derha.net/api/v1/numbers \ -H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret" ``` ### Step 3: your first message ```bash 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 ```bash 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 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-Key with every send so a retry never duplicates the message. - Name the recipient one way only: to with number_id, conversation_id alone, or contact_id with number_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. ```http Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret ``` > The token is equivalent to a password. Never embed it in front-end code or a public repository. Keep it in server environment variables. If it leaks, rotate the key at once from the Keys page. A 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. ```json // 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. ```bash curl https://derha.net/api/v1/businesses \ -H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret" ``` #### Response 200 OK ```json { "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. ```bash 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. ```bash curl https://derha.net/api/v1/numbers \ -H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret" ``` #### Response 200 OK ```json { "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** ```bash 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 (reply to a conversation)** ```bash 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." }' ``` **Node.js** ```js 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) ``` **Python** ```python 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"]) ``` **PHP (Laravel)** ```php $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 ```json { "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" } } } ``` > A missing or invalid field answers 422 with the code validation_failed, an errors object per field, and a missing list naming absent required fields. > Messages sent through the API do not appear in the inbox and open no ticket. This route is for notifications, verification codes and receipts. If the customer replies on a "Conversations + API" channel the reply opens an ordinary ticket. On an "API only" channel no ticket opens and the reply reaches you as message.received. ### 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. ```bash 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. | ```bash 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" } }' ``` > A slot marked as not kept reaches the customer and is never written to the database. A missing value for a required slot answers slot_value_missing, and an unknown id answers custom_message_not_found. ### 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). ```bash curl https://derha.net/api/v1/messages/show/3c9d8e7f-1a2b-3c4d-5e6f-7a8b9c0d1e2f \ -H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret" ``` #### Response 200 OK ```json { "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" } ] } } } ``` > status values: queued, then sent, then delivered, then read, or failed. When failed, failure_reason (a stable slug), failure_category (client, provider or internal) and failure_message (a readable explanation) are filled. client errors are the caller’s and are not retried. ### 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. | ```bash 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. | ```bash 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. ```bash curl -o photo.jpg "https://derha.net/api/v1/media/01a0ff03-16e0-7350-a174-d09c254ee041" \ -H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret" ``` > When ready is false the file is still being fetched from the platform, and the request answers 409 with the code media_not_ready and a Retry-After: 30 header. Try again after that time. ### Balance `GET /v1/balance` The message balance every send draws from, and the AI points. Check it before a large batch. ```bash curl https://derha.net/api/v1/balance \ -H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret" ``` #### Response 200 OK ```json { "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. ```bash 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. ```bash 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. ```bash 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. | ```bash 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. ```bash 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 1. In "Developer API", then "Webhooks", add a URL. It must be HTTPS, public, and accept POST. 2. Choose the events, then the channels: all of them or selected ones. 3. Turn on file download links if your system will download attachments. 4. 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. **message.received** ```json { "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=