MessageHub · Documentation

Connect your system to MessageHub in minutes

A simple JSON REST API to send messages over WhatsApp, Messenger, Instagram, Telegram and TikTok, follow their status, and receive customer replies in real time with webhooks. This documentation is open to everyone and needs no login.

WhatsAppMessengerInstagramTelegramTikTok
POST /v1/messages/send
curl -X POST https://derha.net/api/v1/messages/send \
  -H "Authorization: Bearer mh_live_xxxxxxxxxxxxxxxx_your_secret" \
  -H "Idempotency-Key: order-4821" \
  -d '{ "number_id": "7f2a1c9e-3b4d-5e6f-7a8b-9c0d1e2f3a4b",
        "to": "201014642581",
        "text": "Your order has shipped." }'
202 Accepted
{ "success": true,
  "data": { "message": { "status": "queued" } } }

Everything you need to integrate, in one API

One API for every platform

Send over WhatsApp, Messenger, Instagram, Telegram and TikTok with the same request. Only the channel changes.

Customer replies in real time

A signed webhook reaches your server with every new message and every status change.

Delivery tracking

From queued to sent, delivered and read, with a clear reason when it fails.

Safe retries

An Idempotency-Key prevents duplicate messages, and webhooks are redelivered automatically up to 8 attempts.

Secure from the start

HTTPS only, keys restrictable by IP, and an HMAC signature on every event with replay protection.

Open to AI models

An OpenAPI spec and llms.txt files let any model understand the docs and write the integration accurately.

How it works

1

Create a key

In the dashboard, in seconds.

2

Send a POST request

The recipient and the text. We handle the rest.

3

Receive events

Status and replies arrive at your webhook.

Common uses

Verification codes

Prepared messages with slots, and the code need not be stored in the database.

Order notifications

Shipping, delivery and receipt confirmations, automatically.

CRM sync

Every incoming message reaches your system with the contact details.

Chat bots

Receive a message and reply in the same thread with conversation_id.

Base URLhttps://derha.net/api/v1
Versionv1
Formatapplication/json · UTF-8
AuthAuthorization: Bearer <token>

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

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:

FileWhat it is
https://derha.net/llms.txtA short index of the documentation in the llms.txt standard, linking every file.
https://derha.net/llms-full.txtThe complete documentation as one Markdown file in English. Paste it as it is into a model context.
https://derha.net/llms-full.ar.txtThe same complete documentation in Arabic.
https://derha.net/api-docs/openapi.jsonAn 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-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 #

ConceptMeaning
WorkspaceThe account that owns keys and balance. A key works inside one workspace only and sees nothing else.
BusinessA brand or activity inside the workspace, with its own channels and customers. Id: business_id.
ChannelThe line messages leave from: a WhatsApp number, or a Messenger, Instagram, Telegram or TikTok account. Its id is number_id (also called channel_id).
ContactThe customer. They may have a phone number, or be known only by a platform id. Id: contact_id.
ConversationThe thread between a channel and a contact. Id: conversation_id. To reply to a thread you know, send conversation_id alone.
MessageText or a file, incoming (inbound) or outgoing (outbound).
WebhookAn 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_secret

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.

// 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 #

MethodPathUse
GET/v1/businessesList businesses and channels
GET/v1/businesses/{id}Business details
GET/v1/numbersChannels
POST/v1/messages/sendSend a message
GET/v1/custom-messagesCustom messages
POST/v1/messages/send-customSend a custom message
GET/v1/messages/show/{id}Message status
GET/v1/messages/listList sent messages
GET/v1/messages/inboundIncoming messages
GET/v1/media/{id}Download a file
GET/v1/balanceBalance
GET/v1/webhooks/eventsWebhook events
GET/v1/webhooksList webhooks
POST/v1/webhooks/{id}/testSend a test event
GET/v1/webhooks/deliveriesDelivery log
POST/v1/webhooks/deliveries/{id}/redeliverResend 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

NameTypeRequiredDescription
Idempotency-KeystringNoA 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.

NameTypeRequiredDescription
number_iduuidNoThe sending channel id. Required with to or contact_id.
channel_iduuidNoAnother name for number_id, for channels that are not phone numbers.
conversation_iduuidNoAn existing conversation on any platform. The message goes out on its channel, so number_id is not needed.
contact_iduuidNoA contact with an identity on the chosen channel's platform. Their conversation is opened if needed.
tostringNoRecipient 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.
textstringYesMessage text, up to 4096 characters.
typestringNoMessage type. Currently only text, which is the default.
transactionalbooleanNoSet 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
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)
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
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
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)
$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

NameTypeRequiredDescription
Idempotency-KeystringNoAlways send it with verification codes: a retry with the same key does not send a second code.

Body parameters (JSON)

NameTypeRequiredDescription
number_iduuidNoThe sending channel id. Required with to or contact_id.
channel_iduuidNoAnother name for number_id, for channels that are not phone numbers.
conversation_iduuidNoAn existing conversation on any platform. The message goes out on its channel, so number_id is not needed.
contact_iduuidNoA contact with an identity on the chosen channel's platform. Their conversation is opened if needed.
tostringNoRecipient 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_idstringYesThe custom message id, or lib:key for a starter message.
valuesobjectNoSlot 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

NameTypeRequiredDescription
business_iduuidNoOnly messages of one business.
number_iduuidNoOnly messages of one channel.
statusstringNoqueued, sent, delivered, read or failed.
pageintegerNoPage number, starting at 1.
per_pageintegerNoItems 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

NameTypeRequiredDescription
business_iduuidNoOne business.
number_iduuidNoOne channel (or channel_id).
channelstringNoOne platform: whatsapp, messenger, instagram, telegram or tiktok.
sinceISO 8601NoOnly messages from this time on.
untilISO 8601NoOnly messages up to this time.
pageintegerNoPage number, starting at 1.
per_pageintegerNoItems 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

NameTypeRequiredDescription
webhook_iduuidNoOne webhook only.
statusstringNosuccess, failed, pending or queued.
eventstringNoAn 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.

CodeWhen it happens
cannot_initiate_on_channelInstagram, Messenger and TikTok never let the business write first. A send there answers a customer who wrote.
reply_window_closedThe reply period for this conversation has ended. Wait until the customer writes again.
human_only_windowPast 24 hours on Messenger and Instagram only a staff member may reply, never the API.
message_limit_reachedThe conversation reached its message limit (10 messages on TikTok) until the customer writes.
outside_service_windowOfficial WhatsApp past 24 hours accepts an approved template only. send-custom uses the linked template; otherwise wait for the customer.
text_too_long_for_channelThe text is longer than the platform allows.
media_not_supported_on_channelThe platform does not accept this kind of file.
media_too_large_for_channelThe file is larger than the platform allows.
text_with_media_not_supportedThe 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.

EventSent when
message.receivedA customer wrote to one of your channels. Includes "API only" channels.
message.recoveredA 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.sentAn outgoing message left: from the platform, from the API, or typed by the owner on their own phone (source = owner_device).
message.deliveredThe message reached the recipient's phone.
message.readThe recipient read the message.
message.failedThe message could not be sent. The reason is in failure_reason.
webhook.testA 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
{
  "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

FieldTypeMeaning
iduuidThe message id.
directionstringinbound for incoming, outbound for one you sent.
business_iduuidThe business the channel belongs to.
number_iduuidThe channel that received or sent it.
channelstringThe platform: whatsapp, messenger, instagram, telegram or tiktok.
channel_iduuidThe same value as number_id.
conversation_iduuidThe conversation the message belongs to. Use it to reply.
fromstringThe sender as international digits without +. For a customer with no phone, the platform's own id.
tostringThe recipient, in the same format.
contactobjectThe contact: id, name, phone (a phone number only) and handle.
reply_windowobjectWhether a reply may be sent now: state (open, human_only, template_only or closed), closes_at, remaining_messages and reason.
sourcestringWho sent it: contact for the customer; api, agent, campaign, ai_agent, owner_device and others for outgoing messages.
api_key_iduuid|nullThe key an API message was sent with, otherwise null. Tells apart two integrations that each use their own key.
typestringtext, image, video, audio, voice, document, sticker, location, contact_card or unsupported.
textstring|nullThe message text or the file caption.
attachmentsarrayFiles: 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.
contactsarrayContact cards the customer shared.
reply_touuid|nullThe id of the message the customer is replying to.
statusstringreceived for an incoming message, or the status of an outgoing one.
failure_reasonstring|nullWhy it failed, in message.failed.
sent_atISO 8601When the customer sent it. Earlier than created_at for a recovered or late message.
created_atISO 8601When 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.

HeaderValue
X-MessageHub-EventThe event type.
X-MessageHub-DeliveryThe event id, the same as id in the body. Use it to skip duplicates.
X-MessageHub-Webhook-IdThe id of the webhook that sent the event. Useful when several webhooks share one URL.
X-MessageHub-TimestampWhen it was sent, in Unix seconds.
X-MessageHub-Signature-256The recommended signature: t=…,v1=…. It may hold several v1 values while a secret is being rotated.
X-MessageHub-SignatureThe original sha256=… signature of the body alone. Kept for existing receivers; prefer the 256 header.
User-AgentMessageHub-Webhooks/1.0
Node.js
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
<?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 queue
Python
import 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-After header 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_at and 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

Node.js (Express)
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.

CodeHTTPMeaning
unauthenticated401Invalid or missing API key.
origin_not_allowed401Request from an origin not allowed for this key.
insecure_transport403The API must be called over HTTPS.
ip_not_allowed403Request from an IP address not allowed for this key.
business_not_found404No business with this id in your workspace.
number_not_found404No channel matches number_id in your workspace.
conversation_not_found404No conversation with this id in your workspace.
contact_not_found404No contact with this id.
media_not_found404No file with this id that this key may open.
webhook_not_found404No webhook with this id in your workspace.
message_not_found404No message matches the given id.
custom_message_not_found404The requested custom message does not exist in your workspace.
number_not_connected409The selected channel is not currently connected.
media_not_ready409The file has not been fetched from the platform yet. Try again in 30 seconds.
request_in_progress409A request with the same Idempotency-Key is still processing.
contact_opted_out409The customer asked to stop on this platform. Send transactional=true only when the message is about their own order.
validation_failed422The request payload is invalid.
slot_value_missing422A value is missing for a required slot of the custom message.
contact_not_on_channel422The contact has no identity on this channel's platform.
cannot_initiate_on_channel422This platform does not let the business write first.
reply_window_closed422The reply period for this conversation has ended.
human_only_window422Only staff may reply in this conversation now, not the API.
message_limit_reached422The conversation reached its message limit until the customer writes again.
outside_service_window422Official WhatsApp past 24 hours takes an approved template only.
text_too_long_for_channel422The text is longer than this platform allows.
media_not_supported_on_channel422This platform does not accept this kind of file.
media_too_large_for_channel422The file is larger than this platform allows.
text_with_media_not_supported422This platform does not take text with a file. Send them as two messages.
idempotency_key_reused422This Idempotency-Key was already used for a different request payload.
quota_exhausted422The workspace's API message balance is empty. Top it up to keep sending.
api_not_enabled422The API is not enabled for this workspace. Buy API message quota to enable it.
rate_limited429Too many requests. Wait Retry-After, then retry.
server_error500An 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.

Ready to connect?

Create your key in the dashboard and send your first message today.

Open the dashboard Read the quickstart