Makutano Connect
Sign in
Samples in

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

Your first request

  1. Create an API key in the portal under API & webhooks.
  2. Connect your WhatsApp number and get a template approved by Meta.
  3. Send it — the response gives you a message id; follow it with webhooks or message status.
Send a template message
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.

Header
Authorization: Bearer mk_live_your_key_here

Scopes

Each key carries scopes. A request needing a scope the key lacks is refused with INSUFFICIENT_SCOPE (403).

ScopeAllowsNew keys
whatsapp:sendSend messages, refresh templates, manage webhook endpoints and disconnect the number.included
whatsapp:readRead message status, templates, the balance, webhook endpoints and the connection.included
customers:readList and read contacts.included
customers:writeCreate and update contacts, and record contact events.included
conversations:readList 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
{
  "success": true,
  "data": { "id": "665acf90-ce51-46ce-831e-8b313427a776" }
}
List
{
  "success": true,
  "data": [ { "id": "13d04d4a-82bc-4dae-b5e2-4a27b7718224" } ],
  "meta": { "page": 1, "limit": 25, "total": 1, "totalPages": 1 }
}
Error
{
  "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.

CodeHTTPMeaning
API_KEY_INVALID401No key, a malformed key, or a key that does not exist.
API_KEY_REVOKED401The key was revoked in the portal.
API_KEY_EXPIRED401The key passed its expiry date.
INSUFFICIENT_SCOPE403The key lacks the scope the endpoint needs. The message names it.
TENANT_SUSPENDED403The account is suspended. Reads may still work; writes do not.
FEATURE_NOT_AVAILABLE402The account's plan does not include this feature (for example the API or webhooks).
VALIDATION_ERROR422The body or query failed validation. `details` lists each problem.
RATE_LIMITED429Too many requests this minute. Wait until `details.resetAt`.
INTERNAL_ERROR500Something 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

page integer ≥ 1 default 1
Page number.
limit integer 1–100 default 25
Items per page.
order asc | desc default desc
Newest first by default.
q string (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.

Header
Idempotency-Key: order-INV-00123

Rate 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

POST /whatsapp/messages

Scope: 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-Key header
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

to string 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.
content object required
The message itself. Its fields depend on content.type.
content.type enum: 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.text string (1–4096) required
type=text only, required there. The message body.
content.previewUrl boolean default false
type=text only. Passed to WhatsApp as text.preview_url (false when omitted).
content.templateName string (1–200) required
type=template only, required there. Name of the WhatsApp template, e.g. order_ready.
content.language string (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.components array (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.link string (URL) required
type=image and type=document only, required there. Public URL of the media file. Must be a valid URL.
content.caption string (max 1024)
type=image and type=document only. Caption shown with the media.
content.filename string (max 200)
type=document only. File name shown to the recipient.
content.interactive object (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.
conversationId string (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`.
customerId string (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.
dispatch enum: 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
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"
}'
Response · 202
{
  "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

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

id string (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
curl -X GET "https://connect.makutano.co.tz/api/v1/whatsapp/messages/665acf90-ce51-46ce-831e-8b313427a776" \
  -H "Authorization: Bearer $MAKUTANO_API_KEY"
Response · 200
{
  "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

GET /whatsapp/templates

Scope: 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
curl -X GET "https://connect.makutano.co.tz/api/v1/whatsapp/templates" \
  -H "Authorization: Bearer $MAKUTANO_API_KEY"
Response · 200
{
  "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

POST /whatsapp/templates

Scope: 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
curl -X POST "https://connect.makutano.co.tz/api/v1/whatsapp/templates" \
  -H "Authorization: Bearer $MAKUTANO_API_KEY"
Response · 202
{
  "success": true,
  "data": {
    "queued": true
  }
}

Balance

Get message balance

GET /balance

Scope: 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
curl -X GET "https://connect.makutano.co.tz/api/v1/balance" \
  -H "Authorization: Bearer $MAKUTANO_API_KEY"
Response · 200
{
  "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

GET /customers

Scope: customers:read

Returns your contacts one page at a time, newest first. Use `q` to search by name, email or phone.

Query parameters

page integer default 1
Page number, minimum 1. The string value is converted to a number. 0, a negative number or a non-integer returns 422.
limit integer default 25
Items per page, from 1 to 100. A value over 100 returns 422. It is not reduced to the maximum.
q string
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.
sort string
Accepted and parsed, but ignored. Results are always ordered by createdAt, newest first.
order string (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
curl -X GET "https://connect.makutano.co.tz/api/v1/customers" \
  -H "Authorization: Bearer $MAKUTANO_API_KEY"
Response · 200
{
  "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

POST /customers

Scope: 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-Key header
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

firstName string default ""
Up to 120 characters. Not nullable: null returns 422. Stored as '' when omitted.
lastName string default ""
Up to 120 characters. Not nullable: null returns 422. Stored as '' when omitted.
email string (email) | null default null
Must be a valid email address. Stored trimmed and lowercased. Unique per tenant, ignoring case.
phone string | 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.
whatsappPhone string | 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.
country string (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.
language string | null default null
Up to 10 characters, e.g. 'sw' or 'en'.
notes string | null default null
Up to 5000 characters.
externalReference string | 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
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"
}'
Response · 201
{
  "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

GET /customers/{id}

Scope: customers:read

Returns one contact by id. Contacts that belong to another tenant return 404.

Path parameters

id string (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
curl -X GET "https://connect.makutano.co.tz/api/v1/customers/665acf90-ce51-46ce-831e-8b313427a776" \
  -H "Authorization: Bearer $MAKUTANO_API_KEY"
Response · 200
{
  "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

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

id string (uuid)
The contact id. A value that is not a UUID returns 422 'Invalid customer id.'

Body

firstName string
Up to 120 characters. Not nullable, but '' is allowed.
lastName string
Up to 120 characters. Not nullable, but '' is allowed.
email string (email) | null
Valid email, stored trimmed and lowercased. null clears it. '' is rejected with 422.
phone string | 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.
whatsappPhone string | null
Up to 40 characters, normalized like phone. null clears it. Unlike POST, it does not fall back to phone.
country string (2 chars) | null
ISO alpha-2 code, stored as sent. Changing it does not re-normalize the phones already stored.
language string | null
Up to 10 characters.
notes string | null
Up to 5000 characters. It replaces the existing notes; nothing is appended.
externalReference string | 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
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."
}'
Response · 200
{
  "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

POST /contact-events

Scope: 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-Key header
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

phone string 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.
firstName string | 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.
lastName string | null
Up to 120 characters. Same fill-blanks-only rule as firstName.
email string (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.
note string required
1-1000 characters. Written into the conversation as one message and shown in the inbox exactly as sent.
title string
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.
notify boolean 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
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
}'
Response · 201
{
  "success": true,
  "data": {
    "customerId": "b2d4f8a1-6c3e-4f9b-a7d2-1e5c8b9f3a60",
    "conversationId": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "messageId": "3e4f5a6b-7c8d-4e9f-a0b1-c2d3e4f5a6b7"
  }
}

Conversations

List conversations

GET /conversations

Scope: 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

open string (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.
page integer default 1
Page number, minimum 1.
limit integer default 25
Items per page, from 1 to 100. A value over 100 returns 422.
q string
Checked (trimmed, up to 200 characters) but ignored. There is no conversation search.
sort string
Accepted but ignored.
order string (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
curl -X GET "https://connect.makutano.co.tz/api/v1/conversations" \
  -H "Authorization: Bearer $MAKUTANO_API_KEY"
Response · 200
{
  "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

GET /conversations/{id}/messages

Scope: 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

id string (uuid)
The conversation id. A value that is not a UUID returns 422 'Invalid conversation id.'

Query parameters

page integer default 1
Page number, minimum 1. Page 1 is the most recent `limit` messages; higher pages go further back in time.
limit integer default 25
Items per page, from 1 to 100. A value over 100 returns 422.
q string
Checked (trimmed, up to 200 characters) but ignored.
sort string
Accepted but ignored.
order string (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
curl -X GET "https://connect.makutano.co.tz/api/v1/conversations/665acf90-ce51-46ce-831e-8b313427a776/messages" \
  -H "Authorization: Bearer $MAKUTANO_API_KEY"
Response · 200
{
  "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

GET /webhooks

Scope: 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
curl -X GET "https://connect.makutano.co.tz/api/v1/webhooks" \
  -H "Authorization: Bearer $MAKUTANO_API_KEY"
Response · 200
{
  "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

POST /webhooks

Scope: 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

url string 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.
description string | null default null
Free-text label, at most 300 characters.
events string[] 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
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"
  ]
}'
Response · 201
{
  "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

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

id string (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
curl -X DELETE "https://connect.makutano.co.tz/api/v1/webhooks/665acf90-ce51-46ce-831e-8b313427a776" \
  -H "Authorization: Bearer $MAKUTANO_API_KEY"
Response · 200
{
  "success": true,
  "data": {
    "deleted": true
  }
}

WhatsApp connection

Create a WhatsApp connect session

POST /whatsapp/connect-session

Scope: 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

redirectUrl string | 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
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"
}'
Response · 201
{
  "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

GET /whatsapp/connection

Scope: 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
curl -X GET "https://connect.makutano.co.tz/api/v1/whatsapp/connection" \
  -H "Authorization: Bearer $MAKUTANO_API_KEY"
Response · 200
{
  "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

POST /whatsapp/disconnect

Scope: 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
curl -X POST "https://connect.makutano.co.tz/api/v1/whatsapp/disconnect" \
  -H "Authorization: Bearer $MAKUTANO_API_KEY"
Response · 200
{
  "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

GET /me

Scope: 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
curl -X GET "https://connect.makutano.co.tz/api/v1/me" \
  -H "Authorization: Bearer $MAKUTANO_API_KEY"
Response · 200
{
  "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.

HeaderValue
Content-Typeapplication/json
User-AgentMakutanoConnect/1.0
X-Makutano-EventThe event name, e.g. message.delivered.
X-Makutano-DeliveryThis delivery’s id. The same on every retry of the same event.
X-Makutano-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
X-Connect-TimestampThe same unix timestamp, on its own.
X-Connect-Signaturesha256=<the same hex HMAC>, for receivers that read the timestamp header.
Envelope
{
  "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.

Verify a delivery
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);
}
Questions about an integration: support@makutano.co.tz — include the x-request-id of the request. © 2026 Makutano Connect