Makutano Connect
Sign in

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.

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

  1. /signup — full name, work email, password, and acceptance of the Terms and Privacy Policy. Nothing else is asked on the first screen.
  2. /verify-email — a single-use link, valid for 24 hours. It can be resent, and both the account and the network are rate limited.
  3. /onboarding — business name, industry, country, business phone, optional website, and a plan.
  4. /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 + TZ255712345678). 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, customerId
  • GET /booking-requests/{id} — full detail: items, travellers, internal notes, customer
  • PATCH /booking-requests/{id} — move status through NEW → 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 — scope bookings:write. Requires customerId and at least one item; link bookingRequestId to close the loop (the request flips to CONVERTED)
  • GET /bookings?status=CONFIRMED&unpaid=true · GET /bookings/{id} — detail includes items, travellers, payments, status history
  • PATCH /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 — scope quotations:write. Pass customerId, an inline customer object (matched like an enquiry), or a bookingRequestId to inherit its customer
  • POST /quotations/{id}/send — snapshots a version, marks SENT, flips the linked request to QUOTED
  • POST /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 outcome
  • GET /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} — scope payments:read
  • POST /payments — requires the payments feature on your plan and bookings:write. Providers: MANUAL and BANK_TRANSFER today; hosted gateways (Stripe, Flutterwave, Pesapal, AzamPay) return NOT_CONFIGURED until 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 row
  • GET /orders/{id} — full detail: items, payments, status history, customer, conversation
  • PATCH /orders/{id} — edit items/delivery while DRAFT or PENDING_CONFIRMATION only
  • POST /orders/{id}/status{ "status": "DISPATCHED", "reason": "Boda left 14:20" }; illegal jumps are rejected
  • POST /payments with orderId — recording a payment recomputes amountPaid and 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 URLhttps://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.

WhatsApp

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 first
  • GET /conversations/{id}/messages — the thread, oldest-first, with delivery statuses (SENT → DELIVERED → READ, or FAILED with the Meta error)

Templates

  • GET /whatsapp/templates — your approved templates as last synced
  • POST /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 reference
  • booking_confirmed — customer first name · booking reference · business name
  • quotation_ready — customer first name · quotation reference · business name · quotation total
  • payment_reminder — customer first name · amount due · booking reference · business name
  • payment_received — customer first name · payment amount · booking reference
  • trip_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 summary
  • order_confirmed — customer first name · order number · order total · business name
  • order_ready — customer first name · order number · business name
  • order_dispatched — customer first name · order number · delivery address
  • order_delivered — customer first name · order number · business name

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

  1. Connect → TemplatesSync from Meta (status becomes APPROVED).
  2. Set Used for on each template to its event from the tables above.
  3. 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 catalogue
  • POST /webhooks{ "url": "https://example.com/hooks/connect", "events": ["booking_request.created", "message.received"] }. An empty events array subscribes to everything. The response includes the signing secretshown once, store it server-side
  • DELETE /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.

2026 © Makutano Connect