API reference
Send WhatsApp messages from your own systems, follow each one to delivery, and manage templates, contacts and webhooks. The API is HTTPS and JSON throughout: send a JSON body, get a JSON response.
Base URL
Every path in this reference is relative to:
https://connect.makutano.co.tz/api/v1Your first request
- Create an API key in the portal under API & webhooks.
- Connect your WhatsApp number and get a template approved by Meta.
- Send it — the response gives you a message
id; follow it with webhooks or message status.
curl -X POST "https://connect.makutano.co.tz/api/v1/whatsapp/messages" \
-H "Authorization: Bearer $MAKUTANO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "255712345678",
"content": {
"type": "template",
"templateName": "order_ready",
"language": "en",
"components": [
{ "type": "body", "parameters": [ { "type": "text", "text": "Amina" }, { "type": "text", "text": "INV-00123" } ] }
]
}
}'Authentication
Authenticate every request with an API key in the Authorization header. Keys start with mk_live_ or mk_test_, are shown once when created, and belong to one account — the account is always taken from the key,
never from the request.
Authorization: Bearer mk_live_your_key_hereScopes
Each key carries scopes. A request needing a scope the key lacks is refused with INSUFFICIENT_SCOPE (403).
| Scope | Allows | New keys |
|---|---|---|
whatsapp:send | Send messages, refresh templates, manage webhook endpoints and disconnect the number. | included |
whatsapp:read | Read message status, templates, the balance, webhook endpoints and the connection. | included |
customers:read | List and read contacts. | included |
customers:write | Create and update contacts, and record contact events. | included |
conversations:read | List conversations and read their messages. | included |
Responses
Every response is an envelope with success. Successful responses carry data; lists add meta with pagination; failures carry error with a stable code, a readable message and, where useful, details. Every response has an x-request-id header — send your
own to correlate, and quote it when asking for help.
{
"success": true,
"data": { "id": "665acf90-ce51-46ce-831e-8b313427a776" }
}{
"success": true,
"data": [ { "id": "13d04d4a-82bc-4dae-b5e2-4a27b7718224" } ],
"meta": { "page": 1, "limit": 25, "total": 1, "totalPages": 1 }
}{
"success": false,
"error": {
"code": "NO_MESSAGE_CREDITS",
"message": "You have used all your messages. Buy a package to keep sending.",
"details": { "remaining": 0, "queued": 0, "buyUrl": "/app/billing" }
}
}Errors
Branch on error.code, not on the message. These can come from any endpoint; each endpoint below lists the codes
specific to it.
| Code | HTTP | Meaning |
|---|---|---|
API_KEY_INVALID | 401 | No key, a malformed key, or a key that does not exist. |
API_KEY_REVOKED | 401 | The key was revoked in the portal. |
API_KEY_EXPIRED | 401 | The key passed its expiry date. |
INSUFFICIENT_SCOPE | 403 | The key lacks the scope the endpoint needs. The message names it. |
TENANT_SUSPENDED | 403 | The account is suspended. Reads may still work; writes do not. |
FEATURE_NOT_AVAILABLE | 402 | The account's plan does not include this feature (for example the API or webhooks). |
VALIDATION_ERROR | 422 | The body or query failed validation. `details` lists each problem. |
RATE_LIMITED | 429 | Too many requests this minute. Wait until `details.resetAt`. |
INTERNAL_ERROR | 500 | Something failed on our side. Retry with the same Idempotency-Key, and quote the x-request-id if it persists. |
Pagination
List endpoints accept these query parameters and return meta.page, meta.limit, meta.total and meta.totalPages.
Query parameters
pageinteger ≥ 1 default 1- Page number.
limitinteger 1–100 default 25- Items per page.
orderasc | desc default desc- Newest first by default.
qstring (max 200)- Free-text search, where the endpoint supports it.
Idempotency
Send an Idempotency-Key header (up to 255 characters) on POST requests that create something — sending a message,
creating a contact or recording a contact event. Repeat the same key with the same body within 24 hours and you get the original
response back; nothing is sent or created twice. The same key with a different body is refused with IDEMPOTENCY_CONFLICT (409). Keys are per account.
Idempotency-Key: order-INV-00123Rate limits
Requests are limited per account per minute — 60 on Starter, 120 on Business, 300 on Pro. Over the limit, requests are refused
with RATE_LIMITED (429) and details.resetAt says when to try again. Back off and retry; do not loop.
Messages
Send a WhatsApp message
/whatsapp/messagesScope: whatsapp:send
Sends one WhatsApp message (text, template, image, document or interactive) from the account's connected number. By default Connect saves the message and sends it in the background, then returns 202. With dispatch "sync", Connect calls WhatsApp during the request and returns 201 with the real WhatsApp message id.
Headers
Idempotency-Keyheader- Any string up to 255 characters. A longer value is ignored without an error, as if no key was sent. If you repeat a key with the same body, you get the original response (same status code and body) and nothing is sent again. See notes.
Body
tostring required- Recipient phone number in international format, e.g. 255712345678. The schema allows 6–40 characters. Connect then strips everything except digits (+, spaces and dashes are allowed), and the result must be 7–15 digits. No country code is ever added, so a local number like 0712345678 is NOT changed to 255712345678. It would be sent as-is to the wrong destination.
contentobject required- The message itself. Its fields depend on content.type.
content.typeenum: text | template | image | document | interactive required- Which content shape follows. Any other value fails with 422: "Invalid discriminator value. Expected 'text' | 'template' | 'image' | 'document' | 'interactive'" at path content.type.
content.textstring (1–4096) required- type=text only, required there. The message body.
content.previewUrlboolean default false- type=text only. Passed to WhatsApp as text.preview_url (false when omitted).
content.templateNamestring (1–200) required- type=template only, required there. Name of the WhatsApp template, e.g. order_ready.
content.languagestring (max 10) default en- type=template only. WhatsApp language code (e.g. en, en_US, sw). It is also the language Connect uses to look up the template for its approval check. Pass exactly the language the template is stored under (see GET /whatsapp/templates).
content.componentsarray (max 20 items, items not validated)- type=template only. WhatsApp Cloud API template components, forwarded to Meta exactly as given. Example: [{"type":"body","parameters":[{"type":"text","text":"Amina"}]}]. Left out of the Meta call when empty. Body parameters are positional: entry n fills the template's n-th variable (the order of the template's `variables` list). Connect does not fill named variables (e.g. customer.first_name) for API sends, so you must supply every value.
content.linkstring (URL) required- type=image and type=document only, required there. Public URL of the media file. Must be a valid URL.
content.captionstring (max 1024)- type=image and type=document only. Caption shown with the media.
content.filenamestring (max 200)- type=document only. File name shown to the recipient.
content.interactiveobject (record of unknown) required- type=interactive only, required there. WhatsApp Cloud API `interactive` object (buttons, list, …), sent to Meta exactly as given. Connect only checks that it is an object. Meta does the real validation.
conversationIdstring (uuid) | null- Puts the message in an existing conversation. It must belong to the same account (otherwise 404 CONVERSATION_NOT_FOUND). Connect does not check that the conversation's number matches `to`. If omitted, Connect finds or creates the WhatsApp conversation for `to`.
customerIdstring (uuid) | null- Existing contact to link the message to. It must belong to the same account (otherwise 404 CUSTOMER_NOT_FOUND "Contact not found."). Only used when conversationId is not given. If both are omitted, Connect finds or creates a contact by WhatsApp number (source WHATSAPP). A newly created contact fires the customer.created webhook.
dispatchenum: queued | sync default queued- "queued": respond 202 at once and send through the background job queue. "sync": make the Meta call during this request and respond 201 with waMessageId. If Meta refuses, the request fails with 502 META_API_ERROR.
Errors
VALIDATION_ERROR· 422 — Body is not valid JSON ("Request body must be valid JSON."), or fails the schema ("Request validation failed." with details [{path, message}], e.g. {"path":"content.text","message":"String must contain at least 1 character(s)"}). `to` is not 7–15 digits once non-digits are removed: "A valid recipient phone number is required." Authentication template (category AUTHENTICATION) sent without a code as the first body parameter: "An authentication template needs the code as its first body parameter." Or the code is longer than 15 characters: "A verification code can be at most 15 characters."CONVERSATION_NOT_FOUND· 404 — conversationId does not exist in this account: "Conversation not found."CUSTOMER_NOT_FOUND· 404 — customerId does not exist in this account: "Contact not found."NO_MESSAGE_CREDITS· 402 — No messages left to send with. That is, remaining across active grants minus messages already QUEUED is 0. Details are {remaining, queued, buyUrl:"/app/billing"}. The message is one of: "Buy a message package to start sending." / "Your message package has expired. Buy a package to keep sending." / "Your remaining messages are already queued. Buy a package to send more." / "You have used all your messages. Buy a package to keep sending."WHATSAPP_NOT_CONNECTED· 409 — The account has no connected WhatsApp number: "This account has no connected WhatsApp number." Checked at request time in both dispatch modes.WHATSAPP_POLICY_BLOCKED· 422 — Compliance refusal. details.reason is one of: OPTED_OUT (contact sent STOP etc.), SERVICE_WINDOW_CLOSED (non-template content outside the 24-hour window), TEMPLATE_NOT_APPROVED (the template is known to Connect but not APPROVED; details also has template), or TEMPLATE_DISABLED (the template is switched off in the Template Center; details also has template).IDEMPOTENCY_CONFLICT· 409 — The Idempotency-Key was already used with a different body ("This Idempotency-Key was already used with a different request body."), or the first request with that key is still running ("A request with this Idempotency-Key is still being processed.").CONFLICT· 409 — The idempotency record disappeared while it was being looked up: "Idempotency record disappeared; please retry."META_API_ERROR· 502 — dispatch=sync only. WhatsApp refused or could not be reached. The message is Meta's own error text (from the stored errorMessage), or "WhatsApp did not accept the message."
Good to know
- Order of checks: scope whatsapp:send → plan has WhatsApp → body validation → Idempotency-Key → recipient digits → conversationId/customerId belong to this account → message credits (NO_MESSAGE_CREDITS) → WhatsApp connected (WHATSAPP_NOT_CONNECTED) → authentication-template code → compliance (opt-out, 24-hour window, approved template) → message saved as QUEUED.
- dispatch "queued" (default): responds 202 with {id, conversationId, status:"QUEUED", type, to} before WhatsApp is called. The background 'whatsapp.send' job makes the Meta call, which Connect retries itself up to 3 times on 429/5xx/timeout/network. A job that still fails marks the message FAILED and fires message.failed. The queue then retries the job (up to 5 attempts, backoff 2^n seconds capped at 15 min), so a FAILED message can still become SENT later, with errorCode/errorMessage cleared.
- dispatch "sync": responds 201 with one extra field, waMessageId. Example: {"success":true,"data":{"id":"665acf90-ce51-46ce-831e-8b313427a776","conversationId":"632c1072-2645-4bd0-a1cb-74c17505f55b","status":"SENT","type":"text","to":"255712345678","waMessageId":"wamid.HBgMMjU1NzEyMzQ1Njc4FQIAERgSMEE5QzE3RjM2QjU1RDZGMkM0AA=="}}. The request waits for Meta, including Connect's own retries (15 s timeout per attempt), so it can take tens of seconds. If Meta refuses, the message is saved as FAILED, message.failed fires, and the request returns 502 META_API_ERROR. If the send fails before the Meta call, the row stays QUEUED, is never sent, and the request still returns 502.
- Response `to` is the stored recipient digits. `type` is content.type. `conversationId` is the conversation the message was filed in (existing or newly created).
- Idempotency-Key: scoped to account + this endpoint. The fingerprint is the validated body (unknown fields removed, dispatch default filled in). Same key + same body returns the original status (202 or 201) and body, and nothing marks it as a replay. Same key + different body returns 409 IDEMPOTENCY_CONFLICT. If the original request failed with any error, the key is released, so a retry with the same key runs again (after a sync 502 this creates a second message row). Keys are stored with a 24-hour expiry and deleted by a cleanup job. The lookup itself does not check expiry. Without a key, every POST creates and sends a new message.
- 24-hour window rule: text, image, document and interactive messages are only allowed while the contact's customer-service window is open. That means less than 24 hours since Connect last received a message from the contact whose WhatsApp number equals `to`. A number that has never written in has no open window. Outside it you get 422 WHATSAPP_POLICY_BLOCKED, message "The 24-hour customer service window has closed — send an approved template instead.", details {"reason":"SERVICE_WINDOW_CLOSED"}. Templates are allowed at any time.
- Approved-template rule: Connect looks the template up by (templateName, language, defaulting to 'en'). If it finds it, the template must be APPROVED (otherwise TEMPLATE_NOT_APPROVED, message 'Template "<name>" is <status>, not approved by Meta.') and enabled (otherwise TEMPLATE_DISABLED, message 'Template "<name>" is switched off for this account.'). If Connect has no row for that name+language, it does NOT block. The message goes to Meta, which decides. A wrong language therefore skips Connect's check and usually ends in a Meta rejection.
- Opt-out: a contact whose last inbound message was STOP, UNSUBSCRIBE, CANCEL, END, QUIT, SIMAMA or ACHA cannot be messaged, templates included: 422 WHATSAPP_POLICY_BLOCKED {"reason":"OPTED_OUT"}. Opt-in again with START, UNSTOP, SUBSCRIBE or ANZA.
- Credits: the check counts messages already QUEUED (from the last day) against the balance. One credit is only spent when WhatsApp accepts the message (status SENT). A send Meta refuses costs nothing. Check GET /balance before sending in bulk.
- Authentication templates: put the code once, as the FIRST body parameter. If the template's category is AUTHENTICATION and you sent no 'button' component, Connect adds {"type":"button","sub_type":"url","index":"0","parameters":[{"type":"text","text":"<code>"}]} for the copy-code button. If you include your own button component, Connect does not change it. The code may be at most 15 characters. Example body: {"to":"255712345678","content":{"type":"template","templateName":"verification_code","language":"en","components":[{"type":"body","parameters":[{"type":"text","text":"482913"}]}]}}
- Text example: {"to":"255712345678","content":{"type":"text","text":"Habari Amina, your Serengeti delivery pickup is confirmed for 07:00 tomorrow at Arusha Coffee Lodge.","previewUrl":false}}
- Image example: {"to":"255712345678","content":{"type":"image","link":"https://www.example.co.tz/media/zanzibar-stone-town.jpg","caption":"Your Stone Town walking order starts here at 09:00."},"conversationId":"632c1072-2645-4bd0-a1cb-74c17505f55b"}
- Error envelope example: {"success":false,"error":{"code":"NO_MESSAGE_CREDITS","message":"You have used all your messages. Buy a package to keep sending.","details":{"remaining":0,"queued":0,"buyUrl":"/app/billing"}}}. Error responses also carry the x-request-id header. `details` is left out when there are none.
- Unknown body fields are ignored. They are neither rejected nor sent.
- Delivery tracking: keep data.id, then poll GET /whatsapp/messages/{id} or subscribe to the message.sent / message.delivered / message.read / message.failed webhooks. Their data is {messageId, conversationId, to, waMessageId, status} plus {errorCode, errorMessage} on failure.
- Rate limit: a fixed 60-second window per account, shared by all of its keys. The default is 60 requests/min and is set by the plan (0 = unlimited). On a successful request the code sends no X-RateLimit-* or Retry-After headers. On 429 the only information is error.details {limit, resetAt}.
- Keys starting mk_test_ behave exactly like mk_live_ keys. The code has no sandbox path, so they send real WhatsApp messages and spend credits.
curl -X POST "https://connect.makutano.co.tz/api/v1/whatsapp/messages" \
-H "Authorization: Bearer $MAKUTANO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-INV-00123" \
-d '{
"to": "255712345678",
"content": {
"type": "template",
"templateName": "order_ready",
"language": "en",
"components": [
{
"type": "body",
"parameters": [
{ "type": "text", "text": "Amina" },
{ "type": "text", "text": "INV-00123" },
{ "type": "text", "text": "14 November 2026" }
]
}
]
},
"customerId": "13d04d4a-82bc-4dae-b5e2-4a27b7718224",
"dispatch": "queued"
}'{
"success": true,
"data": {
"id": "665acf90-ce51-46ce-831e-8b313427a776",
"conversationId": "632c1072-2645-4bd0-a1cb-74c17505f55b",
"status": "QUEUED",
"type": "template",
"to": "255712345678"
}
}Get a message's status
/whatsapp/messages/{id}Scope: whatsapp:read
Returns the current delivery status of one message in your account by its Connect id. Use it to poll instead of (or as well as) the message.* webhooks.
Path parameters
idstring (uuid)- The Connect message id, i.e. data.id returned by POST /whatsapp/messages. This is not the WhatsApp wamid.
Errors
VALIDATION_ERROR· 422 — id is not a UUID: "Invalid message id."NOT_FOUND· 404 — No message with that id in this account: "Message not found."
Good to know
- status is one of QUEUED (saved, not yet accepted by WhatsApp), SENT (accepted by WhatsApp, waMessageId set), DELIVERED, READ, FAILED (errorCode and errorMessage set).
- Status updates are written in the order Meta's webhooks arrive, and each one overwrites the last. A late 'delivered' can therefore replace READ. readAt / deliveredAt, once set, are the more reliable signals.
- errorCode is a string. It is Meta's numeric error code as text (e.g. "131047"), or one of Connect's own values: timeout, network, not_configured, send_failed. errorMessage is at most 500 characters.
- A queued message that failed can be retried by the job queue and later turn SENT. A retry that succeeds clears errorCode and errorMessage to null.
- Returns any message in the account, inbound ones included (direction INBOUND). For an inbound message, `to` is the business's Meta phone_number_id, not a phone number.
- All timestamps are ISO 8601 UTC strings. deliveredAt and readAt are null until those statuses arrive.
curl -X GET "https://connect.makutano.co.tz/api/v1/whatsapp/messages/665acf90-ce51-46ce-831e-8b313427a776" \
-H "Authorization: Bearer $MAKUTANO_API_KEY"{
"success": true,
"data": {
"id": "665acf90-ce51-46ce-831e-8b313427a776",
"conversationId": "632c1072-2645-4bd0-a1cb-74c17505f55b",
"direction": "OUTBOUND",
"status": "DELIVERED",
"type": "template",
"to": "255712345678",
"waMessageId": "wamid.HBgMMjU1NzEyMzQ1Njc4FQIAERgSMEE5QzE3RjM2QjU1RDZGMkM0AA==",
"errorCode": null,
"errorMessage": null,
"createdAt": "2026-10-06T07:14:22.481Z",
"deliveredAt": "2026-10-06T07:14:25.102Z",
"readAt": null,
"updatedAt": "2026-10-06T07:14:25.102Z"
}
}Templates
List WhatsApp templates
/whatsapp/templatesScope: whatsapp:read
Lists every WhatsApp template Connect holds for the account, ordered by name. Each one shows its approval status, whether it is switched on, and the variables a sender must fill. Use it to choose a template and build the components for POST /whatsapp/messages.
Good to know
- Not paginated. data is the full array, with no meta. page/limit query parameters are not read.
- Every stored template is returned, whatever its status. status is one of APPROVED, PENDING, REJECTED, PAUSED, DISABLED, DRAFT, SUBMITTED. Only APPROVED templates with enabled=true can be sent.
- enabled=false means the template is switched off in the Template Center. Sends using it are refused with 422 WHATSAPP_POLICY_BLOCKED, reason TEMPLATE_DISABLED.
- variables is the parameter order: entry n is the value you pass as body parameter n in content.components. Templates written in the Template Center keep readable names (e.g. customer.first_name). Templates written in Meta and then synced have Meta's numbers ("1", "2"). Connect does not fill these values for API sends, so supply all of them.
- variableCount is variables.length. For an older row with an empty list, it falls back to the number of distinct {{…}} placeholders in body.
- body is the stored body text and can be null. It shows named placeholders for Template Center templates and positional ones for templates synced from Meta.
- category is free text, null when unknown. Values seen in code are UTILITY, MARKETING and AUTHENTICATION. For AUTHENTICATION templates Connect adds the copy-code button parameter at send time (see POST /whatsapp/messages).
- rejectedReason is Meta's reason, only while status is REJECTED (Meta's placeholder 'NONE' is shown as null). lastSyncedAt is null for a template never synced from Meta.
- eventKey is no longer returned.
curl -X GET "https://connect.makutano.co.tz/api/v1/whatsapp/templates" \
-H "Authorization: Bearer $MAKUTANO_API_KEY"{
"success": true,
"data": [
{
"id": "3ca862b7-37a1-47a0-bee4-7b5ae829410b",
"name": "order_ready",
"language": "en",
"category": "UTILITY",
"status": "APPROVED",
"enabled": true,
"variables": ["customer.first_name", "order_number", "delivery_date"],
"variableCount": 3,
"body": "Habari {{customer.first_name}}, your order {{order_number}} will be delivered on {{delivery_date}}. Karibu sana!",
"rejectedReason": null,
"lastSyncedAt": "2026-10-05T08:12:40.517Z"
},
{
"id": "119d1f5a-6b92-493f-bcc8-65a8b6df2106",
"name": "payment_reminder",
"language": "sw",
"category": "MARKETING",
"status": "REJECTED",
"enabled": true,
"variables": ["1", "2"],
"variableCount": 2,
"body": "Habari {{1}}, tafadhali kamilisha malipo ya TZS {{2}} ili kuthibitisha delivery yako.",
"rejectedReason": "INVALID_FORMAT",
"lastSyncedAt": "2026-10-05T08:12:40.517Z"
},
{
"id": "dad4d343-2626-4502-9431-ef965af2c5d6",
"name": "verification_code",
"language": "en",
"category": "AUTHENTICATION",
"status": "APPROVED",
"enabled": true,
"variables": ["1"],
"variableCount": 1,
"body": "{{1}} is your verification code. For your security, do not share this code.",
"rejectedReason": null,
"lastSyncedAt": "2026-10-05T08:12:40.517Z"
}
]
}Refresh templates from Meta
/whatsapp/templatesScope: whatsapp:send
Queues a background job that re-reads the account's templates from Meta and updates Connect's copy (statuses, categories, rejection reasons). It does NOT create or submit a template, and it takes no request body.
Good to know
- The public API has no way to create or submit templates. 'whatsapp:templates' is not one of the API key scopes (customers:read, customers:write, conversations:read, whatsapp:read, whatsapp:send). Templates are written in the Connect web app's Template Center or in Meta.
- The response is always 202 {queued:true}. At most one sync job is queued per account per clock minute (dedupe key tpl-sync:<tenantId>:<minute>). A second call in the same minute still returns 202 {queued:true}, but no new job is created.
- The sync runs later in the background. Poll GET /whatsapp/templates (watch lastSyncedAt) to see the result. If the account has no connected WhatsApp Business Account, the job does nothing and no error is returned.
- What the sync does: upserts each template Meta lists, matched by (name, language). It refreshes status, category, components and rejectedReason. It fills body/variables only when they are empty, so Template Center names are kept. It deletes templates that Meta no longer lists but once acknowledged (they have a Meta template id). Local drafts never submitted to Meta are kept.
- Not covered by Idempotency-Key. Status changes Meta pushes by webhook update templates without this call and fire the template.status_changed webhook.
curl -X POST "https://connect.makutano.co.tz/api/v1/whatsapp/templates" \
-H "Authorization: Bearer $MAKUTANO_API_KEY"{
"success": true,
"data": {
"queued": true
}
}Balance
Get message balance
/balanceScope: whatsapp:read
Returns how many WhatsApp messages the account can still send and when its message packages expire. Use it to warn your users before sends start failing with NO_MESSAGE_CREDITS.
Good to know
- remaining = sum of (messages − used, never below 0) over all ACTIVE grants (started and not yet expired). This is computed in the database over every grant, not just the ones listed.
- queued = outbound messages still QUEUED that have not been charged yet, counting only those created in the last 24 hours (older ones stop counting).
- available = max(0, remaining − queued). This is the number a new send draws on. A send is refused with 402 NO_MESSAGE_CREDITS when it is 0.
- nextExpiry = earliest expiresAt among active grants that still have messages left, or null.
- grants lists only active grants, taken from the 20 most relevant rows (active first, soonest expiry first). source is PURCHASE, TRIAL or ADMIN. package is the name of the plan the grant came from, or null (e.g. a hand-added ADMIN grant). messages, used and remaining are integers. expiresAt is an ISO 8601 UTC string. used can be higher than messages by a few when sends race on the last credit.
- Sending uses the grant that expires first. A credit is spent only when WhatsApp accepts a message (status SENT). FAILED sends are not charged.
curl -X GET "https://connect.makutano.co.tz/api/v1/balance" \
-H "Authorization: Bearer $MAKUTANO_API_KEY"{
"success": true,
"data": {
"available": 2378,
"remaining": 2380,
"queued": 2,
"nextExpiry": "2026-10-20T09:30:00.000Z",
"grants": [
{
"source": "ADMIN",
"package": null,
"messages": 500,
"used": 120,
"remaining": 380,
"expiresAt": "2026-10-20T09:30:00.000Z"
},
{
"source": "PURCHASE",
"package": "Business",
"messages": 2000,
"used": 0,
"remaining": 2000,
"expiresAt": "2026-11-05T09:30:00.000Z"
}
]
}
}Contacts
List contacts
/customersScope: customers:read
Returns your contacts one page at a time, newest first. Use `q` to search by name, email or phone.
Query parameters
pageinteger default 1- Page number, minimum 1. The string value is converted to a number. 0, a negative number or a non-integer returns 422.
limitinteger default 25- Items per page, from 1 to 100. A value over 100 returns 422. It is not reduced to the maximum.
qstring- Trimmed, up to 200 characters. Case-insensitive substring match (ILIKE '%q%') on firstName, lastName, email, phone or whatsappPhone. '%' and '_' are not escaped, so they work as wildcards. Phones are stored as digits without '+', so search for '255712' and not '+255712'. An empty value after trimming applies no filter.
sortstring- Accepted and parsed, but ignored. Results are always ordered by createdAt, newest first.
orderstring (enum: asc | desc) default desc- Checked against the enum, so any other value returns 422, but otherwise ignored. The order is always createdAt descending.
Errors
VALIDATION_ERROR· 422 — A query parameter fails validation (page < 1, limit outside 1-100, order not asc/desc, q over 200 characters). The message is 'Query validation failed.' and error.details is [{path, message}].
Good to know
- The response uses the list envelope: {success: true, data: [...], meta: {page, limit, total, totalPages}}. totalPages = max(1, ceil(total / limit)), so it is 1 even when total is 0.
- Each item is the full customer row: id (uuid), tenantId (uuid), firstName (string, '' when unset), lastName (string, '' when unset), email (string|null), phone (string|null, E.164 digits without '+'), whatsappPhone (string|null, E.164 digits without '+'), country (string|null), language (string|null), source (enum WEBSITE | WHATSAPP | ADMIN | API | PHONE | EMAIL | MARKETPLACE), notes (string|null), externalReference (string|null), whatsappOptedOut (boolean), whatsappOptedOutAt (ISO timestamp|null), lastInboundAt (ISO timestamp|null), createdAt, updatedAt (ISO timestamps), deletedAt (ISO timestamp|null).
- Only contacts with deletedAt IS NULL are returned. No current code path soft-deletes a customer, so in practice deletedAt is always null.
- The response carries no rate-limit headers (no X-RateLimit-* or Retry-After). The only signal is the 429 body's details.resetAt.
curl -X GET "https://connect.makutano.co.tz/api/v1/customers" \
-H "Authorization: Bearer $MAKUTANO_API_KEY"{
"success": true,
"data": [
{
"id": "b2d4f8a1-6c3e-4f9b-a7d2-1e5c8b9f3a60",
"tenantId": "3f6c2a91-8d4e-4b7a-9c15-2e8f0d7b6a43",
"firstName": "Amina",
"lastName": "Juma",
"email": "amina.juma@example.co.tz",
"phone": "255712345678",
"whatsappPhone": "255712345678",
"country": "TZ",
"language": "sw",
"source": "API",
"notes": "Interested in a 5-day Serengeti delivery in December.",
"externalReference": "CRM-10482",
"whatsappOptedOut": false,
"whatsappOptedOutAt": null,
"lastInboundAt": "2026-10-05T14:22:09.118Z",
"createdAt": "2026-10-01T07:45:12.503Z",
"updatedAt": "2026-10-01T07:45:12.503Z",
"deletedAt": null
},
{
"id": "e7a1c3d5-9b2f-4e8a-b6c4-0d1f2a3b4c5d",
"tenantId": "3f6c2a91-8d4e-4b7a-9c15-2e8f0d7b6a43",
"firstName": "Baraka",
"lastName": "Mwakyusa",
"email": null,
"phone": null,
"whatsappPhone": "255754987321",
"country": null,
"language": null,
"source": "WHATSAPP",
"notes": null,
"externalReference": null,
"whatsappOptedOut": false,
"whatsappOptedOutAt": null,
"lastInboundAt": "2026-09-28T10:03:41.776Z",
"createdAt": "2026-09-28T10:03:41.702Z",
"updatedAt": "2026-09-28T10:03:41.702Z",
"deletedAt": null
}
],
"meta": {
"page": 1,
"limit": 25,
"total": 2,
"totalPages": 1
}
}Create a contact
/customersScope: customers:write
Creates one contact for your account. If the WhatsApp number or email is already used by a contact, it returns 409 CONFLICT instead of creating a duplicate.
Headers
Idempotency-Keyheader- Optional, up to 255 characters. A longer key is silently ignored and the request is treated as if no key was sent. A retry with the same key and the same body within 24 hours replays the stored 201 response. The same key with a different body returns 409 IDEMPOTENCY_CONFLICT. If the first request failed, the key is released so a corrected retry can run.
Body
firstNamestring default ""- Up to 120 characters. Not nullable: null returns 422. Stored as '' when omitted.
lastNamestring default ""- Up to 120 characters. Not nullable: null returns 422. Stored as '' when omitted.
emailstring (email) | null default null- Must be a valid email address. Stored trimmed and lowercased. Unique per tenant, ignoring case.
phonestring | null default null- Up to 40 characters. Normalized to E.164 digits without '+' using only the `country` in this request body. If the result has fewer than 7 or more than 15 digits, null is stored without an error.
whatsappPhonestring | null default same as phone- Up to 40 characters. When omitted or null, the value of `phone` is used. Normalized the same way as phone. Unique per tenant, and this uniqueness is what normally triggers the 409.
countrystring (2 chars) | null default null- ISO 3166 alpha-2 code, e.g. 'TZ'. Stored exactly as sent, without upper-casing. Phone normalization uses it to turn a leading 0, or a national number of up to 9 digits, into the dial code (e.g. TZ → 255). Dial codes are known for TZ, KE, UG, RW, BI, ZM, MW, MZ, ZA, NG, GH, ET, US, GB, DE, FR, IT, ES, NL, IN, CN and AE. Any other code leaves the digits unchanged.
languagestring | null default null- Up to 10 characters, e.g. 'sw' or 'en'.
notesstring | null default null- Up to 5000 characters.
externalReferencestring | null default null- Up to 200 characters. Your own system's id. It does not have to be unique.
Errors
CONFLICT· 409 — A contact in this tenant already has the same normalized whatsappPhone, or the same email ignoring case. This is caught from Postgres unique-violation 23505. The message is 'A contact with this WhatsApp number or email already exists.'IDEMPOTENCY_CONFLICT· 409 — The Idempotency-Key was already used with a different body, or the first request with that key is still being processed.VALIDATION_ERROR· 422 — The body is not valid JSON ('Request body must be valid JSON.') or fails the schema ('Request validation failed.' with details [{path, message}]). Examples: email is not a valid address, country is not 2 characters, firstName is null, or a value is too long.
Good to know
- Writable fields are firstName, lastName, email, phone, whatsappPhone, country, language, notes and externalReference. Read-only fields are id, tenantId, source, whatsappOptedOut, whatsappOptedOutAt, lastInboundAt, createdAt, updatedAt and deletedAt. Unknown body keys are silently dropped, including `source`.
- source is always set to 'API' for contacts created here.
- No field is required. An empty body `{}` creates a contact with firstName/lastName '' and every other field null.
- Duplicates are detected only by the database unique indexes on (tenant, whatsapp_phone) and (tenant, lower(email)). The plain `phone` column is not unique. Because whatsappPhone defaults to phone, sending the same phone twice without a separate whatsappPhone returns 409.
- Duplicates only match when the normalized values are exactly equal. Without `country`, '0712345678' is stored as '0712345678' and does not collide with '255712345678'. Send numbers in international form (e.g. '+255712345678' or '255712345678'), or include country.
- Known normalization gap: when country is set, a short fragment of up to 9 digits gets the dial code added. For example, '12345' with TZ becomes '25512345'.
- On success a 'customer.created' webhook event is queued. Its data is {customerId, firstName, lastName, whatsappPhone, source, externalReference}. If queuing the webhook fails, the request does not fail.
- Success uses the single-object envelope {success: true, data: {...}} with HTTP 201, and an idempotent replay returns the same body and status.
curl -X POST "https://connect.makutano.co.tz/api/v1/customers" \
-H "Authorization: Bearer $MAKUTANO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-INV-00123" \
-d '{
"firstName": "Amina",
"lastName": "Juma",
"email": "amina.juma@example.co.tz",
"phone": "+255 712 345 678",
"country": "TZ",
"language": "sw",
"notes": "Interested in a 5-day Serengeti delivery in December.",
"externalReference": "CRM-10482"
}'{
"success": true,
"data": {
"id": "b2d4f8a1-6c3e-4f9b-a7d2-1e5c8b9f3a60",
"tenantId": "3f6c2a91-8d4e-4b7a-9c15-2e8f0d7b6a43",
"firstName": "Amina",
"lastName": "Juma",
"email": "amina.juma@example.co.tz",
"phone": "255712345678",
"whatsappPhone": "255712345678",
"country": "TZ",
"language": "sw",
"source": "API",
"notes": "Interested in a 5-day Serengeti delivery in December.",
"externalReference": "CRM-10482",
"whatsappOptedOut": false,
"whatsappOptedOutAt": null,
"lastInboundAt": null,
"createdAt": "2026-10-06T08:15:22.481Z",
"updatedAt": "2026-10-06T08:15:22.481Z",
"deletedAt": null
}
}Get a contact
/customers/{id}Scope: customers:read
Returns one contact by id. Contacts that belong to another tenant return 404.
Path parameters
idstring (uuid)- The contact id. A value that is not a UUID returns 422 'Invalid customer id.'
Errors
VALIDATION_ERROR· 422 — The id is not a UUID. The message is 'Invalid customer id.'CUSTOMER_NOT_FOUND· 404 — No contact has this id in your tenant, or it is soft-deleted. The message is 'Customer could not be found.'
Good to know
- The response is the full customer row, with the same fields as an item in GET /customers.
- A contact in another tenant gets the same 404 as a contact that does not exist.
curl -X GET "https://connect.makutano.co.tz/api/v1/customers/665acf90-ce51-46ce-831e-8b313427a776" \
-H "Authorization: Bearer $MAKUTANO_API_KEY"{
"success": true,
"data": {
"id": "b2d4f8a1-6c3e-4f9b-a7d2-1e5c8b9f3a60",
"tenantId": "3f6c2a91-8d4e-4b7a-9c15-2e8f0d7b6a43",
"firstName": "Amina",
"lastName": "Juma",
"email": "amina.juma@example.co.tz",
"phone": "255712345678",
"whatsappPhone": "255712345678",
"country": "TZ",
"language": "sw",
"source": "API",
"notes": "Interested in a 5-day Serengeti delivery in December.",
"externalReference": "CRM-10482",
"whatsappOptedOut": false,
"whatsappOptedOutAt": null,
"lastInboundAt": "2026-10-05T14:22:09.118Z",
"createdAt": "2026-10-01T07:45:12.503Z",
"updatedAt": "2026-10-01T07:45:12.503Z",
"deletedAt": null
}
}Update a contact
/customers/{id}Scope: customers:write
Partially updates a contact. Only the fields present in the body change. Send null to clear a nullable field.
Path parameters
idstring (uuid)- The contact id. A value that is not a UUID returns 422 'Invalid customer id.'
Body
firstNamestring- Up to 120 characters. Not nullable, but '' is allowed.
lastNamestring- Up to 120 characters. Not nullable, but '' is allowed.
emailstring (email) | null- Valid email, stored trimmed and lowercased. null clears it. '' is rejected with 422.
phonestring | null- Up to 40 characters. Normalized using only the `country` in this same PATCH body, not the stored country. null clears it. Changing phone does not change whatsappPhone.
whatsappPhonestring | null- Up to 40 characters, normalized like phone. null clears it. Unlike POST, it does not fall back to phone.
countrystring (2 chars) | null- ISO alpha-2 code, stored as sent. Changing it does not re-normalize the phones already stored.
languagestring | null- Up to 10 characters.
notesstring | null- Up to 5000 characters. It replaces the existing notes; nothing is appended.
externalReferencestring | null- Up to 200 characters.
Errors
VALIDATION_ERROR· 422 — The id is not a UUID ('Invalid customer id.'), the body is not valid JSON ('Request body must be valid JSON.'), or the body fails the schema ('Request validation failed.' with details [{path, message}]).CUSTOMER_NOT_FOUND· 404 — No contact has this id in your tenant, or it is soft-deleted.
Good to know
- The response is the full updated customer row.
- Writable fields are the same as on POST. source, whatsappOptedOut, whatsappOptedOutAt, lastInboundAt, createdAt and deletedAt cannot be changed through this endpoint, and unknown keys are dropped. updatedAt is always set to the current time.
- An empty body `{}` is valid. It changes only updatedAt and returns the contact.
- Phone normalization uses only the country in this PATCH body. Without it, '0712345678' is stored as '0712345678', not '255712345678'. Send international format or include country.
- An invalid phone (fewer than 7 or more than 15 digits after normalization) is stored as null without an error.
- PATCH does not accept an Idempotency-Key, and it does not emit any webhook event.
- The duplicate check is a known asymmetry. POST returns 409 CONFLICT for a duplicate WhatsApp number or email, but PATCH returns 500 INTERNAL_ERROR.
curl -X PATCH "https://connect.makutano.co.tz/api/v1/customers/665acf90-ce51-46ce-831e-8b313427a776" \
-H "Authorization: Bearer $MAKUTANO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"lastName": "Juma Mwinyi",
"whatsappPhone": "+255 754 112 233",
"country": "TZ",
"notes": "Booked the Ngorongoro day trip; follow up about Zanzibar add-on."
}'{
"success": true,
"data": {
"id": "b2d4f8a1-6c3e-4f9b-a7d2-1e5c8b9f3a60",
"tenantId": "3f6c2a91-8d4e-4b7a-9c15-2e8f0d7b6a43",
"firstName": "Amina",
"lastName": "Juma Mwinyi",
"email": "amina.juma@example.co.tz",
"phone": "255712345678",
"whatsappPhone": "255754112233",
"country": "TZ",
"language": "sw",
"source": "API",
"notes": "Booked the Ngorongoro day trip; follow up about Zanzibar add-on.",
"externalReference": "CRM-10482",
"whatsappOptedOut": false,
"whatsappOptedOutAt": null,
"lastInboundAt": "2026-10-05T14:22:09.118Z",
"createdAt": "2026-10-01T07:45:12.503Z",
"updatedAt": "2026-10-06T09:02:47.310Z",
"deletedAt": null
}
}Contact events
Record a contact event
/contact-eventsScope: customers:write
Tells Connect that something happened with a person, such as a subscription, consent or cancellation. In one call it finds or creates the contact by phone, finds or opens their WhatsApp conversation, writes your note into it and, optionally, notifies staff. It never sends anything to the contact.
Headers
Idempotency-Keyheader- Optional, up to 255 characters (a longer key is ignored). A retry with the same key and body within 24 hours replays the stored 201 response without writing a second note. The same key with a different body returns 409 IDEMPOTENCY_CONFLICT.
Body
phonestring required- 6-40 characters. Used to match the person. Normalized to E.164 digits without '+' using your tenant's country, so a leading 0 gets that country's dial code. Returns 422 'A valid phone number is required.' if fewer than 7 or more than 15 digits remain.
firstNamestring | null- Up to 120 characters. Used for a new contact, or to fill an empty first name on an existing one. It never overwrites a name already stored.
lastNamestring | null- Up to 120 characters. Same fill-blanks-only rule as firstName.
emailstring (email) | null- Valid email, up to 200 characters. Used to match the contact if nothing matched by phone. On an existing contact it is written only when their email is empty.
notestring required- 1-1000 characters. Written into the conversation as one message and shown in the inbox exactly as sent.
titlestring- Up to 120 characters. Title of the staff push notification. Defaults to the contact's full name, or '+<phone>' when they have no name. Not nullable.
notifyboolean default true- true increments the conversation's unread count and sends a push notification to staff. false records it quietly, with no push and no change to unread.
Errors
VALIDATION_ERROR· 422 — The body is not valid JSON, or fails the schema (missing phone or note, note over 1000 characters, invalid email, notify not a boolean), returning 'Request validation failed.' with details [{path, message}]. Also returned when the phone normalizes to nothing usable, with 'A valid phone number is required.' and no details.IDEMPOTENCY_CONFLICT· 409 — The Idempotency-Key was already used with a different body, or the first request with that key is still in progress.
Good to know
- It never sends anything to the contact. The route has no path to Meta or WhatsApp, and it uses no message credits, no template and no 24-hour window. The contact's opt-out state and lastInboundAt are neither checked nor changed.
- Find-or-create the contact: matching tries whatsappPhone, then phone, then email, one at a time. Each lookup takes the oldest matching contact. It never creates a duplicate and never returns 409.
- On an existing contact, only empty fields are filled (whatsappPhone, phone, email, firstName, lastName). A different value already stored is never overwritten. The disagreement is silently discarded and not reported in the response.
- A new contact gets phone = whatsappPhone = the normalized phone, and source 'WEBSITE', not 'API', because the route does not pass a source. Creating a new contact queues a 'customer.created' webhook.
- Find-or-open the conversation: the key is (tenant, channel WHATSAPP, externalId = normalized phone). A new conversation is open, has visibility TEAM, and is assigned to the tenant's longest-standing active OWNER (or nobody if there is none). On an existing conversation, customerId is set only if it was empty.
- A closed conversation is not reopened. The note is added and lastMessageAt/updatedAt move, but isOpen stays false.
- The note is stored as a message with direction INBOUND, status DELIVERED, type 'text', channel WHATSAPP, body = note, fromAddress = normalized phone, deliveredAt = now, and payload {}. waMessageId, toAddress, sentByUserId and creditGrantId are null. It appears in GET /conversations/{id}/messages.
- No 'message.received' (or any message.*) webhook event is emitted for the note.
- When notify is true, the push goes to the conversation's assignee. With no assignee and visibility not PRIVATE, it goes to every tenant member with role OWNER, ADMIN or BOOKING_AGENT; a PRIVATE unassigned thread notifies nobody. The push body is the first 140 characters of the note, with data {type: 'contact_event', conversationId}. The push is fire-and-forget: a push failure never fails the request, and nothing is sent if push (FCM) is not configured.
- Local-format numbers such as '0712345678' expand correctly only if your tenant's country is set (e.g. TZ → '255712345678'). Sending international format avoids depending on that.
- If the contact was matched by email but has a different WhatsApp number on file, the conversation is still keyed by the phone in this request.
- messageId is typed as nullable in the response (`message?.id ?? null`), but an insert that succeeds always returns an id.
curl -X POST "https://connect.makutano.co.tz/api/v1/contact-events" \
-H "Authorization: Bearer $MAKUTANO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-INV-00123" \
-d '{
"phone": "+255712345678",
"firstName": "Amina",
"lastName": "Juma",
"email": "amina.juma@example.co.tz",
"note": "Subscribed to the Zanzibar & Serengeti travel newsletter via the website form.",
"title": "New newsletter subscriber",
"notify": true
}'{
"success": true,
"data": {
"customerId": "b2d4f8a1-6c3e-4f9b-a7d2-1e5c8b9f3a60",
"conversationId": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"messageId": "3e4f5a6b-7c8d-4e9f-a0b1-c2d3e4f5a6b7"
}
}Conversations
List conversations
/conversationsScope: conversations:read
Returns your inbox conversations one page at a time, most recently active first. Each one includes a short summary of the linked contact. Filter with `open` to get only open or only closed threads.
Query parameters
openstring (enum: true | false)- 'true' returns only open threads and 'false' only closed ones. Omit it to get both. Any other value (e.g. '1' or 'yes') returns 422.
pageinteger default 1- Page number, minimum 1.
limitinteger default 25- Items per page, from 1 to 100. A value over 100 returns 422.
qstring- Checked (trimmed, up to 200 characters) but ignored. There is no conversation search.
sortstring- Accepted but ignored.
orderstring (enum: asc | desc) default desc- Checked against the enum but ignored. The order is always the most recent activity first.
Errors
VALIDATION_ERROR· 422 — `open` is not 'true' or 'false', or a pagination parameter is invalid. The message is 'Query validation failed.' with details [{path, message}].
Good to know
- Each item is the full conversation row with a `customer` object added. Fields: id (uuid), tenantId (uuid), channel (enum WHATSAPP | WEB | EMAIL | MANUAL), customerId (uuid|null), leadId (uuid|null), bookingRequestId (uuid|null), whatsappConnectionId (uuid|null), externalId (string|null; for WhatsApp, the contact's number as E.164 digits without '+'), subject (string|null), isOpen (boolean), visibility (enum TEAM | ASSIGNED | PRIVATE), assignedToUserId (uuid|null), sharedWithUserIds (uuid[]), lastMessageAt (ISO timestamp|null), unreadCount (integer), createdAt, updatedAt (ISO timestamps), customer ({id, firstName, lastName, whatsappPhone} or null when no contact is linked).
- Ordered by coalesce(lastMessageAt, createdAt) descending.
- The API key is not limited by inbox visibility rules. Threads marked ASSIGNED or PRIVATE, which staff without permission cannot see in the app, are returned too.
- Reading conversations through the API does not reset unreadCount.
- `open` is matched exactly. The code deliberately does not use boolean coercion, so ?open=false returns closed threads.
curl -X GET "https://connect.makutano.co.tz/api/v1/conversations" \
-H "Authorization: Bearer $MAKUTANO_API_KEY"{
"success": true,
"data": [
{
"id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"tenantId": "3f6c2a91-8d4e-4b7a-9c15-2e8f0d7b6a43",
"channel": "WHATSAPP",
"customerId": "b2d4f8a1-6c3e-4f9b-a7d2-1e5c8b9f3a60",
"leadId": null,
"bookingRequestId": null,
"whatsappConnectionId": "4a5b6c7d-8e9f-4a0b-9c1d-2e3f4a5b6c7d",
"externalId": "255712345678",
"subject": null,
"isOpen": true,
"visibility": "TEAM",
"assignedToUserId": "1f2e3d4c-5b6a-4978-8d6c-5b4a39281706",
"sharedWithUserIds": [],
"lastMessageAt": "2026-10-05T14:25:40.902Z",
"unreadCount": 0,
"createdAt": "2026-10-01T07:45:13.020Z",
"updatedAt": "2026-10-05T14:25:40.902Z",
"customer": {
"id": "b2d4f8a1-6c3e-4f9b-a7d2-1e5c8b9f3a60",
"firstName": "Amina",
"lastName": "Juma",
"whatsappPhone": "255712345678"
}
}
],
"meta": {
"page": 1,
"limit": 25,
"total": 1,
"totalPages": 1
}
}List messages in a conversation
/conversations/{id}/messagesScope: conversations:read
Returns the messages of one conversation one page at a time. Page 1 holds the newest messages, sorted oldest first within the page. Inbound and outbound messages and contact-event notes are all included.
Path parameters
idstring (uuid)- The conversation id. A value that is not a UUID returns 422 'Invalid conversation id.'
Query parameters
pageinteger default 1- Page number, minimum 1. Page 1 is the most recent `limit` messages; higher pages go further back in time.
limitinteger default 25- Items per page, from 1 to 100. A value over 100 returns 422.
qstring- Checked (trimmed, up to 200 characters) but ignored.
sortstring- Accepted but ignored.
orderstring (enum: asc | desc) default desc- Checked against the enum but ignored. See the notes for the fixed ordering.
Errors
VALIDATION_ERROR· 422 — The id is not a UUID ('Invalid conversation id.'), or a pagination parameter is invalid ('Query validation failed.' with details).CONVERSATION_NOT_FOUND· 404 — No conversation has this id in your tenant. The message is 'Conversation could not be found.'
Good to know
- Ordering: the code selects by createdAt DESC with limit/offset, then reverses each page. Page 1 is therefore the newest `limit` messages, sorted oldest to newest within the page, and page 2 is the `limit` messages before those. meta.total counts every message in the conversation.
- Each item is the full message row. Fields: id (uuid), tenantId (uuid), conversationId (uuid), direction (enum INBOUND | OUTBOUND), channel (enum WHATSAPP | WEB | EMAIL | MANUAL), status (enum QUEUED | SENT | DELIVERED | READ | FAILED), type (string; outbound uses text | template | image | document | interactive, while inbound stores Meta's message type string), body (string|null; the text, or a rendered preview for templates), payload (object), waMessageId (string|null; Meta's wamid), fromAddress (string|null), toAddress (string|null), errorCode (string|null), errorMessage (string|null), sentByUserId (uuid|null), creditGrantId (uuid|null), deliveredAt (ISO timestamp|null), readAt (ISO timestamp|null), createdAt, updatedAt (ISO timestamps).
- payload differs by message source. For inbound WhatsApp it is Meta's raw message object. For outbound it is the content object that was sent (e.g. {type: 'text', text}). For a POST /contact-events note it is {}.
- For inbound WhatsApp messages, toAddress is the business's Meta phone_number_id, not a phone number. For outbound messages fromAddress is null.
- A contact-events note appears as direction INBOUND, status DELIVERED, type 'text', with waMessageId null and payload {}.
- The conversation lookup is not limited by inbox visibility rules, so any conversation in the tenant can be read. Reading messages does not reset unreadCount or mark anything as read.
curl -X GET "https://connect.makutano.co.tz/api/v1/conversations/665acf90-ce51-46ce-831e-8b313427a776/messages" \
-H "Authorization: Bearer $MAKUTANO_API_KEY"{
"success": true,
"data": [
{
"id": "6d7e8f90-a1b2-4c3d-9e4f-5a6b7c8d9e0f",
"tenantId": "3f6c2a91-8d4e-4b7a-9c15-2e8f0d7b6a43",
"conversationId": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"direction": "INBOUND",
"channel": "WHATSAPP",
"status": "DELIVERED",
"type": "text",
"body": "Habari, bei ya delivery ya siku 5 Serengeti ni kiasi gani?",
"payload": {
"from": "255712345678",
"id": "wamid.HBgMMjU1NzEyMzQ1Njc4FQIAEhgUM0E5QjdDMkQ0RTFGNjA4QjI1QTcA",
"timestamp": "1759674129",
"type": "text",
"text": { "body": "Habari, bei ya delivery ya siku 5 Serengeti ni kiasi gani?" }
},
"waMessageId": "wamid.HBgMMjU1NzEyMzQ1Njc4FQIAEhgUM0E5QjdDMkQ0RTFGNjA4QjI1QTcA",
"fromAddress": "255712345678",
"toAddress": "109876543210987",
"errorCode": null,
"errorMessage": null,
"sentByUserId": null,
"creditGrantId": null,
"deliveredAt": "2026-10-05T14:22:09.140Z",
"readAt": null,
"createdAt": "2026-10-05T14:22:09.140Z",
"updatedAt": "2026-10-05T14:22:09.140Z"
},
{
"id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"tenantId": "3f6c2a91-8d4e-4b7a-9c15-2e8f0d7b6a43",
"conversationId": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"direction": "OUTBOUND",
"channel": "WHATSAPP",
"status": "READ",
"type": "text",
"body": "Karibu Amina! Delivery ya siku 5 Serengeti inaanzia TZS 4,200,000 kwa mtu mmoja.",
"payload": {
"type": "text",
"text": "Karibu Amina! Delivery ya siku 5 Serengeti inaanzia TZS 4,200,000 kwa mtu mmoja."
},
"waMessageId": "wamid.HBgMMjU1NzEyMzQ1Njc4FQIAERgSN0QzRTJBMUI5QzhGNDZBMDEyAA==",
"fromAddress": null,
"toAddress": "255712345678",
"errorCode": null,
"errorMessage": null,
"sentByUserId": "1f2e3d4c-5b6a-4978-8d6c-5b4a39281706",
"creditGrantId": "7b8c9d0e-1f2a-4b3c-ad4e-5f6a7b8c9d0e",
"deliveredAt": "2026-10-05T14:25:43.517Z",
"readAt": "2026-10-05T14:26:02.004Z",
"createdAt": "2026-10-05T14:25:40.902Z",
"updatedAt": "2026-10-05T14:26:02.004Z"
}
],
"meta": {
"page": 1,
"limit": 25,
"total": 2,
"totalPages": 1
}
}Webhooks
List webhook endpoints
/webhooksScope: whatsapp:read
Lists every webhook endpoint registered for the account, with its delivery health, and returns the full list of event names you can subscribe to.
Good to know
- The list is not paginated. It returns every endpoint the account has, oldest first (createdAt ascending). Neither data nor the envelope has a meta block.
- The signing secret is never included. It is returned only once, in the POST /webhooks response.
- This endpoint does not check the webhooks.enabled plan feature, so it still lists endpoints if the plan no longer includes webhooks.
- An empty events array means the endpoint receives every event.
- availableEvents is the server's fixed EVENTS list: customer.created, message.received, message.sent, message.delivered, message.read, message.failed, template.status_changed.
- isActive defaults to true, and no code path in this repo sets it to false. Endpoints are not auto-disabled after failures. consecutiveFailures goes up by 1 on each failed delivery attempt and resets to 0 on a successful one. lastSuccessAt and lastFailureAt are ISO-8601 UTC timestamps, or null.
curl -X GET "https://connect.makutano.co.tz/api/v1/webhooks" \
-H "Authorization: Bearer $MAKUTANO_API_KEY"{
"success": true,
"data": {
"endpoints": [
{
"id": "8f03046e-6216-4812-a1e5-4e80973b6912",
"url": "https://hooks.dukalaamina.co.tz/makutano",
"description": "Order system (production)",
"events": [
"message.delivered",
"message.read",
"message.failed",
"message.received"
],
"isActive": true,
"lastSuccessAt": "2026-10-06T09:14:03.118Z",
"lastFailureAt": "2026-10-04T17:40:22.905Z",
"consecutiveFailures": 0,
"createdAt": "2026-09-30T08:05:41.274Z"
}
],
"availableEvents": [
"customer.created",
"message.received",
"message.sent",
"message.delivered",
"message.read",
"message.failed",
"template.status_changed"
]
}
}Create a webhook endpoint
/webhooksScope: whatsapp:send
Registers an HTTPS URL to receive signed event deliveries and returns the endpoint together with its signing secret. The secret is returned only in this response.
Body
urlstring required- Absolute URL, at most 500 characters. The scheme must be https unless the hostname is exactly 'localhost'. The URL is stored in its normalized WHATWG form.
descriptionstring | null default null- Free-text label, at most 300 characters.
eventsstring[] default [] (all events)- Up to 30 event names, each at most 80 characters, each one of: customer.created, message.received, message.sent, message.delivered, message.read, message.failed, template.status_changed. Omit it or send [] to receive every event.
Errors
VALIDATION_ERROR· 422 — The body is not valid JSON. Message: 'Request body must be valid JSON.' The body fails the schema: url is missing or not an absolute URL, url is over 500 characters, description is over 300, events has more than 30 items, or an event name is over 80 characters. Message: 'Request validation failed.' details: [{"path": "url", "message": "Invalid url"}]. The URL scheme is not https and the hostname is not 'localhost'. Message: 'Webhook URL must use HTTPS.' An entry in events is not a known event. Message: 'Unknown webhook events: <comma-separated names>'.
Good to know
- The secret is returned once. It is 'whsec_' followed by 32 base64url characters (24 random bytes). The server stores it encrypted, and no endpoint ever returns it again. There is no rotate endpoint. To change it, create a new endpoint and delete the old one.
- HTTPS rule, taken from the code: the request is refused unless the parsed URL's protocol is 'https:' OR its hostname is exactly 'localhost'. Any scheme passes for localhost, http included. 127.0.0.1 is not exempt. Deliveries are sent from Connect's server, so 'localhost' means that server.
- The URL is stored and returned as new URL(url).toString(). For example, a bare origin gets a trailing '/' and the host is lowercased, so data.url can differ from what you sent.
- Event validation: every name in events must appear in EVENTS. Unknown names are listed in the 422 message. Duplicate names are not removed. An empty or omitted events array means all events.
- Request handling order: scope check, then the webhooks.enabled feature check, then body parsing and schema validation, then the HTTPS check, then event-name validation.
- No Idempotency-Key is supported, there is no per-account cap on the number of endpoints, and the same URL can be registered more than once. Retrying this POST creates another endpoint, and every event is then delivered to each copy.
- Unknown body fields are ignored.
- How the secret is used on delivery (src/lib/server/webhooks/deliver.ts): each event is POSTed as JSON {"id": "evt_<uuid>", "event", "tenantId", "occurredAt", "occurred_at", "data"}. The headers are content-type: application/json, user-agent: MakutanoConnect/1.0, x-makutano-event, x-makutano-delivery (the uuid), x-makutano-signature 't=<unix>,v1=<hex>', x-connect-timestamp '<unix>' and x-connect-signature 'sha256=<hex>'. Both signatures are the same HMAC-SHA256 of '<timestamp>.<raw body>', keyed with this secret, in lower-case hex.
- Delivery rules: the request times out after 10 seconds and any 2xx counts as success. A delivery gets at most 6 attempts, and the retry delay is 2^attempt × 30 s (about 1, 2, 4, 8 and 16 minutes). After the 6th failure the delivery is marked DEAD.
- An audit record webhook_endpoint.created is written, with the API key as the actor.
curl -X POST "https://connect.makutano.co.tz/api/v1/webhooks" \
-H "Authorization: Bearer $MAKUTANO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.dukalaamina.co.tz/makutano",
"description": "Order system (production)",
"events": [
"message.delivered",
"message.read",
"message.failed",
"message.received"
]
}'{
"success": true,
"data": {
"id": "8f03046e-6216-4812-a1e5-4e80973b6912",
"url": "https://hooks.dukalaamina.co.tz/makutano",
"description": "Order system (production)",
"events": [
"message.delivered",
"message.read",
"message.failed",
"message.received"
],
"isActive": true,
"lastSuccessAt": null,
"lastFailureAt": null,
"consecutiveFailures": 0,
"createdAt": "2026-10-06T09:12:44.512Z",
"secret": "whsec_8a01NkSHowsdtr2-__Mt2eC8g_Tr9usD"
}
}Delete a webhook endpoint
/webhooks/{id}Scope: whatsapp:send
Permanently removes one of the account's webhook endpoints. Its delivery history and any deliveries waiting to be retried are removed with it.
Path parameters
idstring (uuid)- The endpoint id returned by POST /webhooks or GET /webhooks.
Errors
VALIDATION_ERROR· 422 — The id is not a UUID. Message: 'Invalid endpoint id.'NOT_FOUND· 404 — No endpoint with this id belongs to the key's account. This covers an id owned by another account and an endpoint that is already deleted. Message: 'Webhook endpoint not found.'
Good to know
- This is a hard delete, scoped to the key's account.
- The endpoint's webhook_deliveries rows are deleted by ON DELETE CASCADE. Pending retries therefore stop silently, because the queued delivery job finds no row and returns.
- The call is not idempotent. Deleting the same id a second time returns 404 NOT_FOUND.
- This endpoint does not check the webhooks.enabled plan feature, so an endpoint can still be removed after the plan loses webhooks.
- There is no request body.
- An audit record webhook_endpoint.deleted is written.
curl -X DELETE "https://connect.makutano.co.tz/api/v1/webhooks/665acf90-ce51-46ce-831e-8b313427a776" \
-H "Authorization: Bearer $MAKUTANO_API_KEY"{
"success": true,
"data": {
"deleted": true
}
}WhatsApp connection
Create a WhatsApp connect session
/whatsapp/connect-sessionScope: whatsapp:read
Your server calls this to get a short-lived, single-use link to hand to the business owner. Opening the link runs Meta's WhatsApp Embedded Signup and connects the chosen number to the API key's account, without the owner signing in to Connect. The response holds two links: Connect's own launcher page (launchUrl) and Meta's hosted signup page (hostedLaunchUrl).
Body
redirectUrlstring | null default null- Absolute URL, at most 500 characters, stored on the session. It is meant as the 'Continue' link shown after a successful connection; see the notes for whether it is actually shown. Validated by format only .url(), so https is not required.
Errors
NOT_CONFIGURED· 503 — The deployment is missing META_APP_ID, META_APP_SECRET, WHATSAPP_CONFIG_ID or CREDENTIALS_ENCRYPTION_KEY. Message: 'WhatsApp onboarding is not configured on this deployment.'VALIDATION_ERROR· 422 — The body is empty or is not valid JSON. Message: 'Request body must be valid JSON.' Send {} when you have no fields. redirectUrl is not an absolute URL or is over 500 characters, or the body is JSON null. Message: 'Request validation failed.' details: [{"path": "redirectUrl", "message": "Invalid url"}].
Good to know
- Purpose: call this server to server, then send the business owner (the person who controls the Meta Business / WhatsApp Business Account) to one of the two links. launchUrl is Connect's launcher page, PUBLIC_APP_URL + '/connect/whatsapp?session=<token>', which opens Meta's Embedded Signup popup with the JS SDK. hostedLaunchUrl is Meta's own hosted page (business.facebook.com/messaging/whatsapp/onboard/), which needs no popup. It carries the token as state and redirect_uri = PUBLIC_APP_URL + '/connect/whatsapp', and Meta returns the browser to the launcher, which finishes the connection on the server.
- The token is the only thing that binds the link to your account, and nobody has to sign in to Connect. Anyone who opens the link within its lifetime can connect a WhatsApp number to the API key's account, so treat token, launchUrl and hostedLaunchUrl as secrets.
- Lifetime is 15 minutes (expiresAt = creation time + 15 min). Opening the link does not use it up, so it can be opened again until it is used or expires. The session is consumed when the launcher posts Meta's authorization code back, and this happens BEFORE the server exchanges the code with Meta. If the exchange then fails, the link cannot be retried and you must create a new session.
- Only sha256(token) is stored. sessionId and nonce are returned for reference only: no public API endpoint accepts them, and the launcher page looks them up itself.
- data.meta is a field inside data, not the envelope's top-level meta. It holds only Meta's public identifiers: appId (META_APP_ID), configId (WHATSAPP_CONFIG_ID) and graphVersion (META_GRAPH_VERSION, default 'v23.0'). The app secret never appears in the response.
- hostedLaunchUrl is typed string | null. It is null only when the app id or config id is empty, and the NOT_CONFIGURED check already rules that out, so in practice a 201 always includes it.
- Although this is a POST, the required scope is whatsapp:read.
- Each call creates a new session row, and earlier sessions stay valid until they are used or expire.
- The checks on the number itself run at exchange time, on the launcher page, and are never returned to the API caller. They cover the whatsapp.maxNumbers cap, a number already connected to another account, and a WABA with no number.
- No webhook event fires when a connection completes, because EVENTS has no connection event. To detect success, poll GET /whatsapp/connection (or check whatsapp.connected in GET /me).
- redirectUrl may not actually be shown (read from the code, not checked in a browser). The launcher posts Meta's code with a native form POST to '?/exchange'. That URL replaces the query string and drops ?session=. The session is also already consumed by then. So the page's load re-runs without finding the session, data.redirectUrl is not set, and the 'Continue' link that uses it does not appear to render on the success screen.
curl -X POST "https://connect.makutano.co.tz/api/v1/whatsapp/connect-session" \
-H "Authorization: Bearer $MAKUTANO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"redirectUrl": "https://admin.dukalaamina.co.tz/settings/whatsapp"
}'{
"success": true,
"data": {
"sessionId": "547c6730-8fdb-44e0-9523-2d773d6618c6",
"token": "MVl7nEJ8AjbZ1y-OZNttdgplLzoTNwVD0rr6debmnKU",
"nonce": "B-E1j-aXDLwVF25Q",
"expiresAt": "2026-10-06T09:45:00.412Z",
"launchUrl": "https://connect.makutano.co.tz/connect/whatsapp?session=MVl7nEJ8AjbZ1y-OZNttdgplLzoTNwVD0rr6debmnKU",
"hostedLaunchUrl": "https://business.facebook.com/messaging/whatsapp/onboard/?app_id=1187264530918842&config_id=902417358826144&redirect_uri=https%3A%2F%2Fconnect.makutano.co.tz%2Fconnect%2Fwhatsapp&state=MVl7nEJ8AjbZ1y-OZNttdgplLzoTNwVD0rr6debmnKU&extras=%7B%22version%22%3A%22v4%22%2C%22sessionInfoVersion%22%3A%223%22%2C%22featureType%22%3A%22%22%7D",
"meta": {
"appId": "1187264530918842",
"configId": "902417358826144",
"graphVersion": "v23.0"
}
}
}Get WhatsApp connection status
/whatsapp/connectionScope: whatsapp:read
Returns whether the account has a working WhatsApp number, plus that connection's health fields. It never returns a token. Poll it to find out when a connect session has finished.
Good to know
- connected is true only when connection.status is 'CONNECTED'. connection is null when the account has never had a connection row, and the response is then {"connected": false, "connection": null}.
- Only ONE connection is returned, even when the account holds several numbers. The order is: a CONNECTED row first, then the primary row, then the most recently updated.
- status values (enum whatsapp_connection_status) are listed in the next four notes.
- CONNECTED is set by a successful Embedded Signup or reconnect. It is the only status that can send. In any other status, sends fail with WHATSAPP_NOT_CONNECTED (409).
- REAUTH_REQUIRED is set in two cases. (1) Meta answered a send with HTTP 401 or 403, and lastErrorCode then holds Meta's error code. (2) The scheduled token-health job found tokenExpiresAt less than 3 days away, and lastErrorCode is then 'token_expiring'.
- DISCONNECTED is set by POST /whatsapp/disconnect or the portal's disconnect, or by Meta's deauthorize callback when the person removed the app (lastErrorCode 'meta_deauthorized').
- ERROR exists in the database enum, but no code path in this repo writes it.
- lastErrorCode and lastErrorAt can be set while status stays CONNECTED, for example a non-auth Meta error code or 'decrypt_failed'. A later successful send clears lastErrorCode but leaves lastErrorAt.
- All timestamps are ISO-8601 UTC strings or null. tokenExpiresAt is null when Meta gave no expiry. phoneNumberId and wabaId are Meta identifiers, numeric strings and not UUIDs. wabaId can be null.
- The response never includes an access token, key version or ciphertext.
- This endpoint does not check the whatsapp.enabled plan feature.
curl -X GET "https://connect.makutano.co.tz/api/v1/whatsapp/connection" \
-H "Authorization: Bearer $MAKUTANO_API_KEY"{
"success": true,
"data": {
"connected": true,
"connection": {
"id": "0db934a9-2b2d-4b6e-9a63-03a51791aa54",
"status": "CONNECTED",
"displayPhoneNumber": "+255 712 345 678",
"businessName": "Duka la Amina",
"phoneNumberId": "109283746501928",
"wabaId": "102938475610293",
"connectedAt": "2026-09-30T08:01:12.000Z",
"disconnectedAt": null,
"lastWebhookAt": "2026-10-06T09:20:41.512Z",
"lastSuccessfulSendAt": "2026-10-06T09:14:01.330Z",
"lastErrorAt": null,
"lastErrorCode": null,
"tokenExpiresAt": null
}
}
}Disconnect WhatsApp
/whatsapp/disconnectScope: whatsapp:send
Stops outbound sending: the account's selected connection is marked DISCONNECTED and its stored access token is overwritten. Conversations, messages and audit history are kept. Returns the updated connection.
Errors
WHATSAPP_NOT_CONNECTED· 409 — The account has no whatsapp_connections row at all. Message: 'This account has no WhatsApp connection.'
Good to know
- No request body is read.
- data is the connection object itself, in the same shape as data.connection from GET /whatsapp/connection. It is NOT wrapped in {connected, connection}.
- It acts on the same single connection that GET /whatsapp/connection would return (CONNECTED first, then primary, then newest). An account with several CONNECTED numbers loses only one per call.
- Effect: status becomes DISCONNECTED, disconnectedAt becomes now, and the stored token is replaced with an encrypted empty string. connectedAt, lastErrorCode and the other fields are left unchanged.
- It is not an error to call this again. If the selected connection is already DISCONNECTED, it is updated again (disconnectedAt moves forward) and 200 is returned. 409 WHATSAPP_NOT_CONNECTED happens only when the account has never had a connection row.
- The server does not call Meta. The number stays in the business's WhatsApp Business Account, and the row and its phone_number_id are kept. Inbound routing does not check status, so inbound events Meta still delivers for that number are still recorded for this account. Outbound sends fail with WHATSAPP_NOT_CONNECTED (409) until the account reconnects through Embedded Signup, for example via POST /whatsapp/connect-session.
- This endpoint does not check the whatsapp.enabled plan feature.
- An audit record whatsapp.disconnected is written, with the API key as the actor.
curl -X POST "https://connect.makutano.co.tz/api/v1/whatsapp/disconnect" \
-H "Authorization: Bearer $MAKUTANO_API_KEY"{
"success": true,
"data": {
"id": "0db934a9-2b2d-4b6e-9a63-03a51791aa54",
"status": "DISCONNECTED",
"displayPhoneNumber": "+255 712 345 678",
"businessName": "Duka la Amina",
"phoneNumberId": "109283746501928",
"wabaId": "102938475610293",
"connectedAt": "2026-09-30T08:01:12.000Z",
"disconnectedAt": "2026-10-06T10:02:17.448Z",
"lastWebhookAt": "2026-10-06T09:20:41.512Z",
"lastSuccessfulSendAt": "2026-10-06T09:14:01.330Z",
"lastErrorAt": null,
"lastErrorCode": null,
"tokenExpiresAt": null
}
}Account
Get the key's account
/meScope: any valid key
Returns the account the API key belongs to, the key's own scopes and environment, the plan, every effective entitlement, this month's metered usage, and whether a WhatsApp number is connected. It is the first call to make to confirm a key works.
Good to know
- No scope is checked. Any valid key, even one with only whatsapp:send, can call this endpoint.
- entitlements holds the effective value of every key in the server's entitlement registry, in registry order: platform.maxUsers, api.enabled, api.maxKeys, api.requestsPerMinute, api.maxRequestsPerMonth, webhooks.enabled, whatsapp.enabled, whatsapp.maxNumbers, whatsapp.templatesEnabled, whatsapp.maxTemplates, package.messages, package.validityDays, ai.enabled, ai.maxMonthlyRequests. Each value is the tenant override if one exists, otherwise the plan's value, otherwise the registry fallback. Values are booleans or numbers, and a numeric 0 means unlimited. The numbers in the example are illustrative because the real values come from the plan row in the database. The result is cached for up to 15 seconds.
- usage has one row per metered entitlement. Today the only metered entitlement is api.maxRequestsPerMonth (metric api_requests, counted per UTC calendar month). When the limit is 0 (unlimited), limit and percent are null. Otherwise percent is round(used / limit × 100), capped at 100.
- used counts /api/v1 requests whose handler succeeded. The count is written asynchronously after the response is built, and errors are not counted. No code path blocks requests when api.maxRequestsPerMonth is reached, so the value is reported but not enforced.
- plan.code and plan.name are 'NONE' and 'No plan' when the account has no plan. plan.status is the status of the most recent subscription row (TRIALING, ACTIVE or PAST_DUE), or null if there is none. CANCELLED and EXPIRED cannot appear here, because the auth hook rejects those accounts with SUBSCRIPTION_INACTIVE first.
- apiKey.environment is 'live' or 'test'. The code never reads it to change behaviour, so a test key acts exactly like a live key.
- whatsapp.connected is true only when the account's selected connection has status CONNECTED. The selected connection is the same one GET /whatsapp/connection returns. displayPhoneNumber is null when the account has no connection.
- Rate limiting is a fixed 60-second window per account, sized by api.requestsPerMinute (registry fallback 60; 0 means unlimited). The code sets no X-RateLimit-* or Retry-After headers. The reset time appears only in error.details.resetAt on a 429.
- Every response, success or error, includes an x-request-id header.
curl -X GET "https://connect.makutano.co.tz/api/v1/me" \
-H "Authorization: Bearer $MAKUTANO_API_KEY"{
"success": true,
"data": {
"tenant": {
"id": "d60bb49b-65d5-4a29-85c7-a7a0725abac7",
"name": "Duka la Amina"
},
"apiKey": {
"id": "bf839635-d2b7-47f8-993e-a3ab6f5c16ac",
"scopes": [
"customers:read",
"customers:write",
"conversations:read",
"whatsapp:read",
"whatsapp:send"
],
"environment": "live"
},
"plan": {
"code": "BUSINESS",
"name": "Business",
"status": "ACTIVE"
},
"entitlements": {
"platform.maxUsers": 10,
"api.enabled": true,
"api.maxKeys": 5,
"api.requestsPerMinute": 120,
"api.maxRequestsPerMonth": 0,
"webhooks.enabled": true,
"whatsapp.enabled": true,
"whatsapp.maxNumbers": 1,
"whatsapp.templatesEnabled": true,
"whatsapp.maxTemplates": 0,
"package.messages": 10000,
"package.validityDays": 30,
"ai.enabled": false,
"ai.maxMonthlyRequests": 200
},
"usage": [
{
"key": "api.maxRequestsPerMonth",
"used": 1240,
"limit": null,
"percent": null
}
],
"whatsapp": {
"connected": true,
"displayPhoneNumber": "+255 712 345 678"
}
}
}Webhooks
Events
Register an HTTPS endpoint (see Create a webhook endpoint) and Connect POSTs these events to it. Subscribe to specific events, or to none to receive them all.
message.sentWhatsApp accepted a message you sent.
{ "messageId": "665acf90-ce51-46ce-831e-8b313427a776", "conversationId": "632c1072-2645-4bd0-a1cb-74c17505f55b", "to": "255712345678", "waMessageId": "wamid.HBgMMjU1NzEyMzQ1Njc4FQIAERgS", "status": "SENT" }message.deliveredThe message reached the contact's phone.
{ "messageId": "665acf90-…", "conversationId": "632c1072-…", "to": "255712345678", "waMessageId": "wamid.…", "status": "DELIVERED" }message.readThe contact opened it (only if they have read receipts on).
{ "messageId": "665acf90-…", "conversationId": "632c1072-…", "to": "255712345678", "waMessageId": "wamid.…", "status": "READ" }message.failedWhatsApp refused or could not deliver the message.
{ "messageId": "665acf90-…", "conversationId": "632c1072-…", "to": "255712345678", "waMessageId": null, "status": "FAILED", "errorCode": "131026", "errorMessage": "Message undeliverable" }message.receivedA contact sent a message to your number.
{ "messageId": "8f1c…", "conversationId": "632c1072-…", "customerId": "13d04d4a-…", "from": "255712345678", "type": "text", "text": "Habari, nimepokea." }customer.createdA new contact was created — by you, through the API, or by an inbound message.
{ "customerId": "13d04d4a-…", "firstName": "Amina", "lastName": "Juma", "whatsappPhone": "+255712345678", "source": "API", "externalReference": "CRM-0042" }template.status_changedMeta approved, rejected, paused or disabled one of your templates.
{ "name": "order_ready", "language": "en", "status": "APPROVED", "reason": null }Delivery and retries
Each event arrives as a JSON POST in this envelope; data is the event's payload above. Answer with any 2xx status
to acknowledge it. Anything else — or no answer — is retried, up to 6 attempts in total, waiting longer each time and never
more than 6 hours between attempts. id and X-Makutano-Delivery stay the same across retries, so use
them to ignore a delivery you have already handled.
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | MakutanoConnect/1.0 |
X-Makutano-Event | The event name, e.g. message.delivered. |
X-Makutano-Delivery | This delivery’s id. The same on every retry of the same event. |
X-Makutano-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> |
X-Connect-Timestamp | The same unix timestamp, on its own. |
X-Connect-Signature | sha256=<the same hex HMAC>, for receivers that read the timestamp header. |
{
"id": "evt_5b6f0c1e-3a2d-4f7b-9c1e-0d2a6b8e4f10",
"event": "message.delivered",
"tenantId": "7c1e2f90-4b3a-4d5e-8f6a-1b2c3d4e5f60",
"occurredAt": "2026-10-06T09:30:12.481Z",
"occurred_at": "2026-10-06T09:30:12.481Z",
"data": { "messageId": "665acf90-ce51-46ce-831e-8b313427a776", "status": "DELIVERED" }
}Verifying signatures
Every delivery is signed with your endpoint's secret (the whsec_… value shown once when you created it). X-Makutano-Signature is t=<timestamp>,v1=<signature>, where the signature is the hex
HMAC-SHA256 of <timestamp>.<raw body>. Compute it over the body exactly as received — before parsing
JSON — compare in constant time, and reject timestamps more than a few minutes old.
import crypto from 'node:crypto';
// secret: the whsec_… value shown once when you created the endpoint.
// rawBody: the request body as received — before any JSON parsing.
export function verifyMakutano(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.split('=')));
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`, 'utf8').digest('hex');
const given = Buffer.from(parts.v1 ?? '', 'hex');
const wanted = Buffer.from(expected, 'hex');
return given.length === wanted.length && crypto.timingSafeEqual(given, wanted);
}