Introduction
Makutano Connect is booking, WhatsApp and payment infrastructure for travel businesses. Your website or CMS stays the interface your team and your travellers see; Connect is the system of record behind it — customers, leads, conversations, booking requests, bookings, quotations and payments, all reachable through one authenticated API.
The integration model is deliberately boring: one REST API, server to server. You never embed credentials in a browser, you never talk to Meta yourself, and you never store WhatsApp tokens. Connect owns that layer for every tenant centrally.
Client website / CMS
│ server-to-server API
▼
Makutano Connect ── Meta WhatsApp Cloud API
│
▼
PostgreSQL (per-tenant, isolated)
Getting started
Every business on Connect is a tenant. Your tenant is provisioned for you — there is nothing to sign up for. You receive:
| Item | Example | Notes |
|---|---|---|
| API base URL | https://connect.makutano.co.tz/api/v1 |
All endpoints below are relative to this |
| API key | mk_live_… |
Shown once at creation. Server-side only |
Store both in your backend's environment:
MAKUTANO_API_URL=https://connect.makutano.co.tz
MAKUTANO_API_KEY=mk_live_xxxxxxxxxxxxxxxx
The key must never reach a browser, a mobile app binary, or a public repository. If a key leaks, revoke it in the portal (Developers → API keys → Revoke) — revocation is immediate — and create a new one.
Authentication
Send the key as a bearer token on every request:
curl https://connect.makutano.co.tz/api/v1/me \
-H "Authorization: Bearer $MAKUTANO_API_KEY"
GET /me is the identity probe: it returns your tenant, the key's scopes, your plan's features and limits, and your WhatsApp connection state. It is the first call to make when wiring an integration.
Your tenant is resolved from the key — there is no tenant id parameter anywhere in the API, and nothing you send can address another tenant's data.
Response envelope
Every endpoint returns one of two JSON shapes:
{ "success": true, "data": { }, "meta": { "page": 1, "limit": 25, "total": 128, "totalPages": 6 } }
{ "success": false, "error": { "code": "BOOKING_NOT_FOUND", "message": "Booking could not be found." } }
meta appears on list endpoints. Validation failures include a details array naming each offending field.
Idempotency
Every POST that creates something accepts an Idempotency-Key header (any string up to 255 characters — your own record id is ideal). Retrying with the same key replays the original response instead of creating a duplicate; reusing a key with a different body is rejected with IDEMPOTENCY_CONFLICT. Keys expire after 24 hours.
-H "Idempotency-Key: enquiry-8842"
Use it on every create call made from a web form handler — a double-submit or a network retry then costs nothing.
Pagination, filtering, search
List endpoints share the same query parameters: page (default 1), limit (default 25, max 100), q (server-side search), plus endpoint-specific filters such as status. Results are newest-first.
Rate limits
Limits are per tenant and per plan (starting at 60 requests/minute). Exceeding them returns HTTP 429 with code RATE_LIMITED and a resetAt timestamp in the error details. Back off until then; do not retry in a tight loop.
Scopes
Keys carry scopes and endpoints enforce them; a request without the needed scope fails with INSUFFICIENT_SCOPE:
booking_requests:read/write · bookings:read/write · customers:read/write · leads:read/write · conversations:read · whatsapp:read · whatsapp:send · quotations:read/write · payments:read
Accounts and onboarding
There are two ways a Makutano Connect account comes into existence, and both run through the same provisioning service — the same transaction, the same defaults, the same audit trail. Only the starting lifecycle differs.
Self-service signup
/signup— full name, work email, password, and acceptance of the Terms and Privacy Policy. Nothing else is asked on the first screen./verify-email— a single-use link, valid for 24 hours. It can be resent, and both the account and the network are rate limited./onboarding— business name, industry, country, business phone, optional website, and a plan./app— the dashboard, with a getting-started checklist.
The first user becomes the Owner of exactly one tenant. Self-signup can never create a platform administrator and can never join an existing tenant.
Admin provisioning
Platform Admin → Tenants → Provision tenant creates the tenant immediately in
ACTIVE, optionally creates the owner account with a temporary password, and issues a
first API key. Accounts created this way are trusted by the admin who typed the address,
so they skip email verification.
The Tenants list shows how each account arrived — Self-service, Platform Admin or Import — and can be filtered by it.
Trials and activation
SIGNUP_TRIAL_DAYS |
Tenant status at signup | Subscription |
|---|---|---|
14 (default) |
TRIAL |
TRIALING, with a real trialEndsAt |
0 |
PENDING |
none until an admin activates the account |
A trial is a real subscription state, not a bypass: entitlements, monthly limits, tenant isolation and WhatsApp policy all apply exactly as they do on a paid plan. Nothing in the signup path marks a subscription as paid.
Account states
| Status | Portal | Reads | Writes |
|---|---|---|---|
ACTIVE / TRIAL |
Full access | Yes | Yes |
PENDING |
Redirected to a dedicated screen | Yes | Blocked — SUBSCRIPTION_INACTIVE |
SUSPENDED |
Redirected to a dedicated screen | Yes | Blocked — TENANT_SUSPENDED |
CANCELLED |
Redirected to a dedicated screen | Yes | Blocked — TENANT_SUSPENDED |
Blocked accounts get one clear explanation rather than a failure on every action, and their data is never deleted.
Resuming an unfinished signup
The stage a user belongs to is derived from stored state, not from a cookie or a wizard step counter:
- no verified address and no tenant → verify email
- verified but no tenant → business setup
- a member of any tenant → the portal
So closing the tab, following the link in a different browser, or signing in a week later all land on the right screen. Provisioning is idempotent and serialised per user: a double-clicked submit or a refreshed form resumes the existing tenant instead of creating a second one.
Password reset
/forgot-password issues a single-use link valid for one hour. /reset-password spends
it, sets the new password and signs out every other session for that user. Neither
page reveals whether an address has an account.
What the platform records
Signup and onboarding write these audit events: signup.started, email.verified,
tenant.provisioned, plan.selected, subscription.created, onboarding.completed and
whatsapp.connected. Passwords, verification tokens, WhatsApp access tokens and API
secrets are never written to an audit row.
Configuration
| Variable | Default | Effect |
|---|---|---|
SIGNUP_ENABLED |
on |
off closes /signup; admin provisioning is unaffected |
SIGNUP_DEFAULT_PLAN |
STARTER |
Plan applied when the visitor does not choose one |
SIGNUP_TRIAL_DAYS |
14 |
0 disables trials — new tenants wait in PENDING |
EMAIL_FROM, EMAIL_PROVIDER_KEY |
— | Required. Without them verification email cannot be delivered |
TURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEY |
— | Set both to switch on the bot challenge |
The booking lifecycle
Connect keeps the tourism lifecycle explicit. A form submission is an enquiry, not a confirmed sale:
Traveller enquiry → BOOKING REQUEST → review / conversation / quotation
→ customer accepts → BOOKING → payment → CONFIRMED
Each stage is its own resource with its own statuses, and every record is linked: a request knows its customer, its lead, its WhatsApp conversation, and — once converted — its booking.
Booking requests
The primary integration point for a website. One call does the whole intake: the customer is matched or created (by WhatsApp number, phone, then email — so a returning traveller never duplicates), a sales lead opens, the WhatsApp conversation is linked, and an acknowledgement is sent from your number.
Create a booking request
POST /booking-requests — scope booking_requests:write
curl -X POST "$MAKUTANO_API_URL/api/v1/booking-requests" \
-H "Authorization: Bearer $MAKUTANO_API_KEY" \
-H "Idempotency-Key: enquiry-8842" \
-H "Content-Type: application/json" \
-d '{
"customer": {
"firstName": "Amina", "lastName": "Juma",
"email": "amina@example.com",
"whatsappPhone": "0712345678", "country": "TZ"
},
"adults": 2, "children": 1,
"startDate": "2026-10-14T00:00:00.000Z",
"estimatedTotal": "2400.00", "currency": "USD",
"notes": "Interested in a mid-October safari.",
"items": [{
"title": "3-day Serengeti safari",
"quantity": 2, "unitPrice": "1200.00",
"externalReference": "serengeti-3d", "externalSource": "your-cms"
}]
}'
{
"success": true,
"data": {
"id": "8cd0…", "reference": "GFA-RQ-2026-00007", "status": "NEW",
"customer": { "id": "…", "firstName": "Amina", "whatsappPhone": "255712345678" },
"leadId": "…", "conversationId": "…"
}
}
Phone numbers are normalised to international digits using the customer's country (0712 345 678 + TZ → 255712345678). Keep your own catalog: externalReference / externalSource on the request and on every item let Connect point back at your tour slug or product id — no catalog migration required.
Useful flags: createLead: false skips the pipeline lead; sendAcknowledgement: false suppresses the WhatsApp acknowledgement (use it if your system already sends one).
List, read, update
GET /booking-requests?status=NEW&q=amina&page=1— filters:status,source,customerIdGET /booking-requests/{id}— full detail: items, travellers, internal notes, customerPATCH /booking-requests/{id}— movestatusthroughNEW → UNDER_REVIEW → CONTACTED → QUOTED → ACCEPTED | DECLINED | CANCELLED → CONVERTED, assign, edit dates/notes
Bookings
The confirmed commercial record. Money fields are computed server-side from items — a client cannot post a $0 total for a $5,000 trip — and every status change is written to an auditable history.
POST /bookings— scopebookings:write. RequirescustomerIdand at least one item; linkbookingRequestIdto close the loop (the request flips toCONVERTED)GET /bookings?status=CONFIRMED&unpaid=true·GET /bookings/{id}— detail includes items, travellers, payments, status historyPATCH /bookings/{id}—{ "status": "CONFIRMED", "reason": "Deposit received" }
Statuses: DRAFT → PENDING → AWAITING_PAYMENT → PARTIALLY_PAID → CONFIRMED → IN_PROGRESS → COMPLETED, with CANCELLED and REFUNDED exits. Illegal jumps (e.g. PENDING → COMPLETED) are rejected with VALIDATION_ERROR.
Payments recompute amountPaid / balanceDue automatically, and a fully paid booking advances to CONFIRMED on its own.
Quotations
A quotation can originate from a request, a lead, a conversation or nothing at all.
POST /quotations— scopequotations:write. PasscustomerId, an inlinecustomerobject (matched like an enquiry), or abookingRequestIdto inherit its customerPOST /quotations/{id}/send— snapshots a version, marksSENT, flips the linked request toQUOTEDPOST /quotations/{id}/accept— converts to a booking carrying customer, dates and line items across; idempotent (a second accept returns the same booking)POST /quotations/{id}/decline— records the outcomeGET /quotations?externalReference=GFQ-923025— look up a quotation you mirrored (below)
Mirroring an external quotation system
If your CMS already manages quotations, mirror them instead of migrating:
PUT /quotations/mirror upserts your quotation's state as it is — status (DRAFT | SENT | VIEWED | ACCEPTED | DECLINED | EXPIRED), timestamps, totals, display items — keyed on your externalReference. Call it on every lifecycle event; replays and out-of-order calls are harmless, and acceptance is recorded as agreement without triggering Connect's own convert-to-booking flow.
Payments
Traveller payments (separate from your Connect subscription):
GET /payments?status=SUCCEEDED·GET /payments/{id}— scopepayments:readPOST /payments— requires the payments feature on your plan andbookings:write. Providers:MANUALandBANK_TRANSFERtoday; hosted gateways (Stripe, Flutterwave, Pesapal, AzamPay) returnNOT_CONFIGUREDuntil enabled for your deployment
A successful payment updates the booking's paid/balance figures and emits payment.succeeded to your webhooks.
Orders
For businesses that sell through conversation — WhatsApp sellers, retailers, restaurants, wholesalers. An order records who is buying what, for how much, and how it reaches them. Connect is deliberately not a storefront, cart or inventory system: the order is a managed record, not a checkout.
Fulfilment status and payment status are independent: CONFIRMED does not mean paid, and payments never advance fulfilment on their own.
DRAFT → PENDING_CONFIRMATION → CONFIRMED → PROCESSING → READY → DISPATCHED → DELIVERED
(CANCELLED / REFUNDED as exits)
Payment: UNPAID → PARTIALLY_PAID → PAID (or REFUNDED / FAILED)
Create an order
POST /orders — scope orders:write
curl -X POST "$MAKUTANO_API_URL/api/v1/orders" \
-H "Authorization: Bearer $MAKUTANO_API_KEY" \
-H "Idempotency-Key: wa-chat-5512" \
-H "Content-Type: application/json" \
-d '{
"conversationId": "…",
"source": "WHATSAPP_DIRECT",
"deliveryMethod": "DELIVERY", "deliveryFee": "5.00",
"deliveryLocation": "Kariakoo, Dar es Salaam",
"items": [
{ "title": "Nike Air Max", "variant": "Black / 43", "quantity": 2, "unitPrice": "120.00" }
]
}'
Passing a conversationId links the order to its WhatsApp thread and inherits the customer automatically. Totals are computed server-side from items (+ delivery, − discount). Acquisition sources: WHATSAPP_DIRECT, WHATSAPP_STATUS, WHATSAPP_GROUP, WEBSITE, INSTAGRAM, FACEBOOK, MANUAL, API, OTHER.
Manage
GET /orders?status=CONFIRMED&paymentStatus=UNPAID&source=WHATSAPP_DIRECT— list with an items summary per rowGET /orders/{id}— full detail: items, payments, status history, customer, conversationPATCH /orders/{id}— edit items/delivery while DRAFT or PENDING_CONFIRMATION onlyPOST /orders/{id}/status—{ "status": "DISPATCHED", "reason": "Boda left 14:20" }; illegal jumps are rejectedPOST /paymentswithorderId— recording a payment recomputesamountPaidand the payment status
In the portal, staff open a WhatsApp conversation and click Create order — customer, thread and source are pre-filled; they add items and save as a draft for review. AI never finalises an order; a human confirms.
Catalog
A lightweight quick-pick list (GET/POST /catalog, PATCH /catalog/{id}) so staff and forms don't retype names and prices — name, type, SKU, price, simple variants, active flag. Businesses with an existing catalog skip it entirely and use externalReference on line items.
Hosted forms & the embeddable widget
The no-code layer for businesses whose current pipeline is website form → email. A form is configuration over the same domain services the API uses — never a second engine.
In the portal under Forms & Widgets a tenant creates a form from a template — Booking enquiry, Product order, Quote request or Contact / lead — toggles fields, sets copy and branding, optionally attaches catalog items and an embed-domain allow-list, then copies either:
- the hosted URL —
https://connect.makutano.co.tz/f/{formId}, or - the one-line embed for any website (plain HTML, WordPress, Webflow, React, Svelte…):
<script src="https://connect.makutano.co.tz/widget.js" data-widget="wf_…"></script>
The widget renders in an auto-sizing iframe, so no CSS or JavaScript leaks in either direction.
Security model. The browser only ever holds the form's opaque wf_… id. Submissions go to POST /api/public/widgets/{id}/submit, where Connect resolves the tenant server-side, applies per-visitor and per-form rate limits, a honeypot, payload caps and the origin allow-list — then routes into the normal services: booking/quote forms create booking requests, order forms create PENDING_CONFIRMATION orders (never auto-paid, never auto-fulfilled — and on catalog-backed forms, prices always come from the tenant's catalog, never the visitor), lead forms create customers + leads. No API key exists anywhere in this path; regenerating the form id instantly invalidates every published embed.
Template Center
Under WhatsApp → Template Center, tenants design reusable message templates with named variables instead of Meta's positional {{1}}, {{2}}:
Hello {{customer.first_name}}, your order {{order.number}} has been
confirmed. Total: {{order.total}}.
Available variables include customer.first_name, business.name, order.number, order.total, order.items_summary, delivery.address, booking.reference, quotation.reference, payment.amount, payment.link. Templates support a header, footer and up to three buttons (quick-reply or URL).
Connect converts the design to Meta's format and submits it for approval (DRAFT → SUBMITTED → APPROVED / REJECTED, synced from Meta). Once approved, map it to a business event:
| Event | Fires when |
|---|---|
ORDER_RECEIVED |
An order arrives from a form or the API awaiting confirmation |
ORDER_CONFIRMED / ORDER_READY / ORDER_DISPATCHED / ORDER_DELIVERED |
Fulfilment transitions |
PAYMENT_RECEIVED |
A payment against an order succeeds |
BOOKING_REQUEST_RECEIVED, BOOKING_CONFIRMED, QUOTATION_READY, PAYMENT_REMINDER, TRIP_REMINDER |
Booking-side events |
Your code emits the event; the tenant's mapping decides what the customer receives, from the tenant's own number. Free-form chat in the Inbox stays free-form — templates exist for business-initiated notifications outside the 24-hour window.
Order webhooks
The webhook catalogue gains order.created, order.confirmed, order.processing, order.ready, order.dispatched, order.delivered, order.cancelled, order.refunded — same delivery format, signature and retries as every other event, with externalReference included for reconciliation.
Order batches
For businesses that sell around a delivery day — the fish seller who posts "Fresh fish available Saturday, TZS 14,000/KG" in a WhatsApp group and collects orders in replies — a batch holds the shared details once: item, unit, price, currency and the delivery date. Recording an order inside the batch then takes two fields: the customer and the quantity. The total is calculated automatically.
The batch view shows the numbers that used to live in a pinned WhatsApp message —
customers, total quantity, expected revenue, paid and outstanding — plus a one-tap
operational list (Confirm, Ready, Dispatch, Delivered, Mark paid, open the linked
WhatsApp conversation). Bulk entry accepts pasted lines in the form Name | quantity.
Two things batches are not: inventory (nothing is stocked or reserved) and group
automation. Connect never reads WhatsApp groups — WHATSAPP_GROUP as an order source is
provenance the staff member records by hand, and transactional updates are only sent
through approved templates to customers in a supported direct conversation, subject to
the same compliance checks as every other message.
Portal: Orders → Batches. API: batch orders are ordinary orders carrying a
batchId; existing order.* webhooks and template events fire unchanged.
Connect operates the Meta WhatsApp Cloud API centrally. Your business keeps ownership of its WhatsApp Business Account and number; Connect stores the credential encrypted, sends on your behalf, receives every inbound message, and threads both into conversations your team can work from the portal — or that you can read over the API.
Connect a number
From the portal: WhatsApp → Connect WhatsApp walks through Meta's Embedded Signup (choose or create the business account, pick the number, done). No tokens are ever shown or pasted.
From your own CMS, request a short-lived onboarding link instead:
POST /whatsapp/connect-session — scope whatsapp:read
{
"success": true,
"data": {
"launchUrl": "https://connect.makutano.co.tz/connect/whatsapp?session=…",
"expiresAt": "2026-08-23T12:15:00.000Z",
"meta": { "appId": "…", "configId": "…", "graphVersion": "v23.0" }
}
}
Redirect your signed-in business user to launchUrl. The session is single-use, bound to your tenant, and expires in 15 minutes; the response contains only public Meta identifiers — never a secret.
Connection status
GET /whatsapp/connection — safe health data only:
{
"success": true,
"data": {
"connected": true,
"connection": {
"displayPhoneNumber": "+255 658 001 939", "businessName": "Goldfinch Adventures",
"status": "CONNECTED", "lastWebhookAt": "…", "lastSuccessfulSendAt": "…"
}
}
}
POST /whatsapp/disconnect stops outbound sending but preserves every conversation, message and audit record. Statuses you may observe: CONNECTED, DISCONNECTED, ERROR, REAUTH_REQUIRED (token expired — reconnect from the portal).
Send a message
POST /whatsapp/messages — scope whatsapp:send. You provide recipient and content; Connect resolves which number and credential to send from. You cannot address another tenant's number — the wire format has no field for it.
curl -X POST "$MAKUTANO_API_URL/api/v1/whatsapp/messages" \
-H "Authorization: Bearer $MAKUTANO_API_KEY" \
-H "Idempotency-Key: reminder-booking-1042" \
-H "Content-Type: application/json" \
-d '{
"to": "255712345678",
"content": { "type": "text", "text": "Habari Amina — your safari is confirmed for 14 Oct!" }
}'
Content types: text, template (templateName, language, optional components), image, document, interactive. Free-form messages deliver only inside Meta's 24-hour customer-service window; outside it, use an approved template.
Dispatch modes. By default the call returns 202 with status: "QUEUED" and a background worker performs the Meta call with retries. Pass "dispatch": "sync" to wait for Meta inside the request and receive the real WhatsApp message id (waMessageId) — useful when your own system threads delivery statuses by that id. Sync failures surface immediately as META_API_ERROR.
Conversations
Inbound messages create or extend conversations automatically, matched to the customer and, where possible, the booking request that started the exchange.
GET /conversations?open=true— the inbox, newest activity firstGET /conversations/{id}/messages— the thread, oldest-first, with delivery statuses (SENT → DELIVERED → READ, orFAILEDwith the Meta error)
Templates
GET /whatsapp/templates— your approved templates as last syncedPOST /whatsapp/templates— queue a re-sync from Meta
In the portal you can map templates to lifecycle events (booking request received, quotation ready, payment received…) so automatic notifications use your approved wording.
WhatsApp templates to create in Meta
Outside WhatsApp's 24-hour customer-service window you may only send templates that Meta has approved. Connect refuses anything else — no plan or setting overrides that.
Create these in Meta Business Manager → WhatsApp Manager → Message templates → Create template, then in Connect open Templates, click Sync from Meta, and map each one to its business event. Connect fires them automatically from then on.
Meta numbers template variables positionally ({{1}}, {{2}}). Connect fills them in
the order listed below, so keep the order exactly as written.
Booking / service businesses
| Template name | Category | Event to map | Body |
|---|---|---|---|
booking_request_received |
UTILITY | BOOKING_REQUEST_RECEIVED | Hi {{1}}, thanks for your enquiry with {{2}}. We've received it (reference {{3}}) and will reply here shortly. |
booking_confirmed |
UTILITY | BOOKING_CONFIRMED | Good news {{1}} — your booking {{2}} with {{3}} is confirmed. We look forward to hosting you. |
quotation_ready |
UTILITY | QUOTATION_READY | Hi {{1}}, your quotation {{2}} from {{3}} is ready. Total: {{4}}. Reply here with any questions. |
payment_reminder |
UTILITY | PAYMENT_REMINDER | Hi {{1}}, a friendly reminder that {{2}} is outstanding on your booking {{3}} with {{4}}. |
payment_received |
UTILITY | PAYMENT_RECEIVED | Thank you {{1}} — we've received your payment of {{2}}. Your reference is {{3}}. |
trip_reminder |
UTILITY | TRIP_REMINDER | Hi {{1}}, your trip with {{2}} starts on {{3}}. Reply here if you need anything before then. |
Variables in order
booking_request_received— customer first name · business name · booking referencebooking_confirmed— customer first name · booking reference · business namequotation_ready— customer first name · quotation reference · business name · quotation totalpayment_reminder— customer first name · amount due · booking reference · business namepayment_received— customer first name · payment amount · booking referencetrip_reminder— customer first name · business name · start date
Order / commerce businesses
| Template name | Category | Event to map | Body |
|---|---|---|---|
order_received |
UTILITY | ORDER_RECEIVED | Hi {{1}}, we've received your order {{2}} ({{3}}). We'll confirm shortly. |
order_confirmed |
UTILITY | ORDER_CONFIRMED | Hi {{1}}, your order {{2}} is confirmed. Total: {{3}}. Thank you for shopping with {{4}}. |
order_ready |
UTILITY | ORDER_READY | Hi {{1}}, your order {{2}} is ready for collection at {{3}}. |
order_dispatched |
UTILITY | ORDER_DISPATCHED | Hi {{1}}, your order {{2}} is on its way to {{3}}. |
order_delivered |
UTILITY | ORDER_DELIVERED | Hi {{1}}, your order {{2}} has been delivered. Thank you for choosing {{4}} — reply here if anything isn't right. |
Variables in order
order_received— customer first name · order number · items summaryorder_confirmed— customer first name · order number · order total · business nameorder_ready— customer first name · order number · business nameorder_dispatched— customer first name · order number · delivery addressorder_delivered— customer first name · order number · business name
Buttons (optional but recommended)
Templates become far more useful with quick replies. When creating a template, add Buttons → Quick reply with labels such as Contact us, Track order, View booking. A customer tapping one opens a normal conversation, which re-opens the 24-hour window and lets your team reply freely.
Avoid URL buttons unless the link is stable — Meta re-reviews templates whose URLs change.
Getting approved first time
- Category matters. All of the above are transactional, so choose UTILITY. Marking them MARKETING invites rejection and costs more per message.
- No promotional language in a UTILITY template ("SALE", "discount", "buy now").
- Never start with a variable.
{{1}}, your order…is commonly rejected;Hi {{1}}, your order…passes. - Provide sample values when Meta asks — real-looking ones ("Amina", "MKD-OR-2026-00042", "USD 240.00"). Placeholder junk is a frequent rejection reason.
- One language per template. To serve Swahili and English, create the same template
name twice with different language codes (
en,sw); Connect picks by language. - Approval usually takes minutes, occasionally up to 24 hours.
After approval
- Connect → Templates → Sync from Meta (status becomes APPROVED).
- Set Used for on each template to its event from the tables above.
- Enable the template.
From then on Connect sends them automatically — for example, confirming an order fires
order_confirmed to that customer from your own WhatsApp number, with the variables filled
in. If a template is missing, unapproved or disabled, Connect skips the send and records the
reason rather than failing the underlying business action.
Webhooks to your system
Connect can push events to your backend so your CMS reflects changes without polling.
Manage endpoints
GET /webhooks— your endpoints + the full event cataloguePOST /webhooks—{ "url": "https://example.com/hooks/connect", "events": ["booking_request.created", "message.received"] }. An emptyeventsarray subscribes to everything. The response includes the signingsecret— shown once, store it server-sideDELETE /webhooks/{id}
Events: booking_request.created / updated, booking.created / confirmed / cancelled, lead.created, customer.created, quotation.sent / accepted, payment.succeeded / failed, message.received.
Delivery format and verification
Deliveries are JSON POSTs:
{
"event": "booking_request.created",
"occurredAt": "2026-08-23T09:12:44.000Z",
"data": { "id": "…", "reference": "GFA-RQ-2026-00007", "status": "NEW" }
}
Each carries x-makutano-event, x-makutano-delivery (unique id — deduplicate on it) and x-makutano-signature in the form t=<unix>,v1=<hex>. Verify before trusting:
import crypto from "node:crypto";
export function verifyConnectSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`, "utf8").digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ""));
}
Compute over the raw request bytes, not re-serialised JSON. Respond 2xx quickly; anything else is retried with exponential backoff (up to 6 attempts over ~6 hours). Endpoint health — last success, consecutive failures — is visible in the portal under Developers.
The portal
Everything the API writes is also workable by humans at connect.makutano.co.tz: dashboard, booking requests, bookings, the WhatsApp inbox (read and reply in-thread), quotations, customers, leads, payments, connection health, API keys and webhooks, and tenant settings.
Access is by role, enforced server-side:
| Role | Can |
|---|---|
OWNER / ADMIN |
Everything for the tenant, including API keys and WhatsApp connection |
BOOKING_AGENT |
Sales work plus bookings, payments and traveller passport data |
SALES |
Enquiries, quotations, customers, leads, chat — no passports, no payments |
VIEWER |
Read-only |
Errors
| Code | HTTP | Meaning |
|---|---|---|
API_KEY_INVALID / API_KEY_REVOKED / API_KEY_EXPIRED |
401 | Fix or rotate the key |
INSUFFICIENT_SCOPE |
403 | The key lacks the endpoint's scope |
FORBIDDEN |
403 | Authenticated, but not allowed |
*_NOT_FOUND (BOOKING_, CUSTOMER_, QUOTATION_…) |
404 | Wrong id, or not your tenant's record |
VALIDATION_ERROR |
422 | details lists each offending field |
IDEMPOTENCY_CONFLICT |
409 | Key reused with a different body, or original still running |
WHATSAPP_NOT_CONNECTED |
409 | No live number — connect one first |
META_API_ERROR |
502 | Meta rejected a sync send; message includes Meta's reason |
PLAN_LIMIT_REACHED / FEATURE_NOT_AVAILABLE |
402 | Monthly quota hit, or feature not in plan |
RATE_LIMITED |
429 | Back off until details.resetAt |
Responses never contain stack traces. Every response carries an x-request-id header — include it when reporting an issue.
Support
Integration questions and tenant provisioning: support@makutano.co.tz · Makutano Digital, Dar es Salaam.