WhatsApp Send Platform

One shared WhatsApp Business number for every Customapp project — a small HTTP API to send messages, and a self-service portal to get your own key without filing a ticket.

Number: +27 73 383 4160 (CustomApp Solutions Hub) API: wa.customapp.co.za Portal: send.customapp.co.za Data residency: af-south-1 (POPIA)

Overview

The platform is two things that share one goal — making it safe and fast for any Customapp project to send WhatsApp messages without every project owning its own WhatsApp Business Account, Meta token, or webhook.

1. The send API

A single HTTP endpoint, POST /v1/send, that any app, CI job, or AI agent calls with an x-api-key. Each key is scoped to its own recipient allowlist and daily quota, so nothing can text a number it shouldn't and nothing can run away with the account's send reputation.

2. The self-service portal

A small web app (send.customapp.co.za) where a human signs up, gets approved once by a platform admin, and then creates/edits/revokes their own keys and allowlists and watches their own 7-day usage — no ticket, no waiting on whoever owns the Meta token.

Everything downstream of "who is allowed to send what to whom" is enforced the same way whether the key was handed out by an admin's CLI years ago or created by someone in the portal five minutes ago — they both end up as an entry in the same store that the send Lambda checks on every single request.

Architecture

There are two independent paths through the system that only meet at two points: the shared config the portal writes and the send Lambda reads, and the shared database both write their logs/state into. That separation is deliberate — the portal never sees the Meta access token, and the send path never touches the portal's database directly (it only reads the small allowlist config).

SENDING A MESSAGE MANAGING YOUR ACCOUNT Your app / CI / Cursor sends with your x-api-key API Gateway key check · throttle daily quota Lambda whatsapp-send allowlist · sanitize · dispatch Meta WhatsApp Cloud API (external) console.log(evt=send) CloudWatch Logs structured JSON per send log filter Lambda audit-subscriber You (browser or CLI token) CloudFront send.customapp.co.za Lambda whatsapp-portal signup · keys · allowlists · usage · admin lib/provisioning.js API Gateway keys + Secrets Manager whatsapp/sender-keys read on every /v1/send read / write wa_platform insert wa_send_audit Supabase — customapp-services (shared ZA stack) wa_platform: accounts · api keys · allowlists · personal tokens · events wa_send_audit: per-message log (recipient, status, key, timestamp) accessed only via the service-role key — never public
you / Supabase (data at rest) request flow config the send path reads back external (Meta)
The two paths only meet at two points: the allowlist config, and the Supabase database.

Walking through it

  1. Sending a message starts with your app calling POST /v1/send on API Gateway with your x-api-key. API Gateway checks the key exists, isn't disabled, and hasn't blown its daily quota — all before your request ever reaches Lambda code.
  2. The whatsapp-send Lambda looks the key up in a small Secrets Manager secret (whatsapp/sender-keys) to get its allowlist and default country code, checks your recipient against it, sanitizes the message text/template params (Meta rejects raw newlines), and calls the Meta Cloud API with the shared token from a separate secret (whatsapp/sender-token) — kept apart so allowlist churn never touches the actual credential.
  3. Every outcome — sent, denied, or errored — is written as one structured JSON log line. A CloudWatch Logs subscription filter forwards matching lines to the audit-subscriber Lambda, which inserts a row into wa_send_audit. This is entirely decoupled from the send path — if Supabase is slow or down, your send still succeeds; only the usage dashboard lags.
  4. Managing your account is a separate flow: your browser talks to the portal through send.customapp.co.za (a CloudFront distribution in front of a Lambda Function URL), which handles signup, login sessions, key/allowlist CRUD, usage queries, and admin actions — all backed by the wa_platform schema in the same Supabase project.
  5. When you create or edit a key in the portal, it calls the same lib/provisioning.js module a platform admin's CLI (wa-admin) uses, so a key created in the browser and a key created from the command line can never race each other into a lost update. That module writes to both API Gateway (so the key can authenticate at all) and the sender-keys secret (so whatsapp-send knows the allowlist) — the dashed purple arrow above is that secret being read back on every single send.

What happens to a single send request

Zooming into the "Lambda: whatsapp-send" box above — this is the exact decision path every POST /v1/send call goes through, in order.

POST /v1/send received (key already validated by API Gateway) Is the key enabled, and is the recipient in that key's allowlist (whatsapp/sender-keys)? no yes 403 Forbidden unknown-key or recipient-not-allowed Sanitize text / template params strip newlines, collapse whitespace, trim Call Meta WhatsApp Cloud API with the shared token (sender-token secret) accepted rejected 200 OK { messageId, to } returned to caller 422 / 500 { error, code } — see Meta error table below Every one of these four outcomes is logged as one JSON line → CloudWatch → audit-subscriber → wa_send_audit (see architecture diagram)
Deny-fast: an unknown key or disallowed recipient never reaches Meta at all.

Account & key lifecycle

Two related but separate lifecycles: your account (one per organisation/project, needs one-time approval) and your API keys (as many as you want once your account is active, each with its own allowlist).

Sign up /signup.html Pending approval awaiting a platform admin admin approves Active can create keys Create API key choose allowlist + country Sending key live in x-api-key admin suspends Account suspended all its live keys stop working you or admin revokes Key revoked disabled in API Gateway + secret An account can have many keys at once (e.g. one for CI, one for a Cursor MCP integration) — revoking one never touches the others, and suspending the account is the one action that takes all of them down together (used for offboarding or abuse).

Getting started

  1. Sign up at send.customapp.co.za/signup.html with your organisation/project name, email, and a password. This creates your account in pending state.
  2. Wait for approval. A platform admin reviews new accounts in /admin.html. There's no self-approval by design — it's the one manual gate on an otherwise fully self-service platform. Ping whoever manages the platform if it's urgent.
  3. Log in once you're active — you'll land on the dashboard.
  4. Create an API key: give it a name (e.g. production, ci), a comma-separated allowlist of recipient numbers in E.164 without the + (e.g. 27833777500), and a default country code for local-format numbers. The key's actual value is shown once — copy it immediately, the portal never displays it again (only its API Gateway id, for management).
  5. Send a test message — see the next section — with "dryRun": true first if you want to validate the key/allowlist without actually sending.
Need programmatic access instead of clicking around a browser (CI, a script, an agent)? Create a personal API token from the dashboard instead of using your login session — see Managing your account.

Sending messages

Base URL: https://wa.customapp.co.za. Auth header: x-api-key: <your key>. One endpoint, discriminated by mode. to is E.164 without the leading +.

Free-form text ("notify")

Sends free text if you're inside Meta's 24-hour customer-service window, and automatically falls back to an approved template if you're outside it — this is what almost every integration should use.

# curl
curl -X POST "https://wa.customapp.co.za/v1/send" \
  -H "x-api-key: $KEY" -H "content-type: application/json" \
  -d '{"mode":"notify","to":"27833777500","text":"Build #123 passed ✅"}'

Approved template

curl -X POST "https://wa.customapp.co.za/v1/send" -H "x-api-key: $KEY" -H "content-type: application/json" \
  -d '{"mode":"template","to":"27833777500","template":"booking_confirmation","params":["2026-07-18 | 08:20 | FRONT"]}'

Call-to-action button

curl -X POST "https://wa.customapp.co.za/v1/send" -H "x-api-key: $KEY" -H "content-type: application/json" \
  -d '{"mode":"cta","to":"27833777500","body":"Your report is ready","buttonText":"Open","url":"https://…"}'

Add "dryRun": true to any of the above to validate the key/allowlist/payload without actually sending (returns a fake id). GET /v1/health needs no key and returns {"status":"ok","version":"…"}.

ResultMeaning
200{ messageId, to } — accepted by Meta
403bad key, or recipient not in this key's allowlist
429daily quota exceeded for this key
422Meta rejected the message — see error codes below
400malformed request body

Other languages

It's plain HTTPS + JSON — any language works:

# Python
import requests
requests.post(f"{URL}/v1/send", headers={"x-api-key": KEY},
              json={"mode": "notify", "to": "27833777500", "text": "from python"})

Node client

const { createWhatsAppClient } = require('.../clients/whatsapp-node');
const wa = createWhatsAppClient({ url: process.env.WHATSAPP_SEND_URL, apiKey: process.env.WHATSAPP_SEND_KEY });
await wa.notify('27833777500', 'Nightly job done');
Don't blindly retry on a network timeout — WhatsApp sends have no idempotency key, so a retry can double-send. Only retry on an explicit 429/5xx.

MCP (Cursor / Claude Code)

The MCP server sends through the same endpoint with its own allowlist-scoped key, so an AI agent can't text arbitrary numbers. Add to ~/.cursor/mcp.json:

{ "mcpServers": { "whatsapp": {
  "command": "node",
  "args": ["/ABS/PATH/whatsapp-service/mcp/server.js"],
  "env": { "WHATSAPP_SEND_URL": "<url>", "WHATSAPP_SEND_KEY": "<mcp-key>" }
} } }

Exposes whatsapp_notify, whatsapp_send_template, whatsapp_send_cta.

Meta error codes you'll actually see

CodeMeaning
131047Re-engagement needed → use a template (notify already handles this automatically)
132001 / 132015Template or its locale doesn't exist
131030Recipient not in Meta's test allowlist (dev/sandbox numbers)
131049Dropped for ecosystem/frequency reasons (template reclassified as marketing)
131026Message undeliverable (recipient can't receive WhatsApp messages)
Limits that will bite you if ignored: this number is send-only (replies go elsewhere); business-initiated messages outside the 24h window need an approved template and are billed per-message; each key has its own allowlist + daily quota; template params are sanitized (newlines collapsed, trimmed to ~1024 chars); the underlying WhatsApp messaging limit is unique recipients/24h — sending to people who haven't opted in lowers the number's quality for everyone sharing it.

Managing your account

Everything below lives on the dashboard at send.customapp.co.za/dashboard.html once your account is active.

API keys

Create as many as you need (e.g. separate keys per environment or integration). Each has its own name, allowlist, and default country code. Editing a key's allowlist takes effect immediately — no redeploy. Revoking disables it in both API Gateway and the allowlist secret at once.

Usage (7 days)

Sent/error counts and a recent-activity table, sourced from wa_send_audit via the async audit pipeline. It can lag by a few seconds behind an actual send (CloudWatch subscription delivery isn't instant) — it never blocks or slows down sending itself.

Personal API tokens

For CI/scripts/agents that need to manage keys programmatically (not to send messages — that's still x-api-key against /v1/send). Send as Authorization: Bearer <token> to any /api/* portal endpoint. Doesn't expire on logout, unlike your browser session.

Portal API reference

MethodPathWhat
GET/api/meyour identity, account, admin flag
POST/api/accountscreate your account (if you already had a session but no account row)
GET / POST/api/keyslist your keys / create a new one
DELETE/api/keys/:idrevoke a key
POST/api/keys/:id/allowlistreplace a key's allowlist
GET/api/usage7-day sent/error counts + recent rows
GET / POST/api/tokenslist / create personal API tokens
DELETE/api/tokens/:idrevoke a personal token

For platform admins

Admins get an extra admin link to /admin.html. There's deliberately no self-service way to become an admin — it's granted with a direct SQL insert into wa_platform.wa_admins by whoever operates the platform (see docs/PLATFORM-OPERATIONS.md in the repo).

Approve / suspend accounts

New signups sit pending until an admin approves them. Suspending an account disables (not deletes) every live API Gateway key it owns in one action — the fastest way to fully cut off a project.

Reconciliation

Flags drift between the sender-keys secret and what's actually live in API Gateway — e.g. a key deleted from the AWS console by hand but still sitting in the secret, or vice versa. Same check as the wa-admin sync CLI command.

All account/key/allowlist changes — self-service or admin — are recorded in wa_platform.wa_events and shown on the admin page's event log, so there's always an answer to "who changed this and when".

Security & data handling

FAQ

My account has been pending for a while — what do I do?

Approval is a manual, one-time step by a platform admin (see For admins). Reach out to whoever operates the platform if it's been longer than expected.

Can I widen my allowlist to "send to anyone"?

Ask a platform admin for a wildcard (*) allowlist if your use case genuinely needs it (e.g. customer-facing transactional notifications). It's not self-service by default because a leaked wildcard key is a much bigger blast radius than a leaked scoped one.

Do I need to handle replies?

No — this number is send-only from your integration's point of view. Incoming replies and delivery-status webhooks are routed elsewhere. Write your messages as no-reply notifications.

My send succeeded but isn't showing in Usage yet

The usage dashboard is fed by an asynchronous CloudWatch → Lambda → Supabase pipeline (see the architecture diagram) that can lag by a few seconds. It never affects whether your message actually sent — check the 200 response and messageId from /v1/send itself as the source of truth.

Where's the source code?

The whatsapp-service repository — see docs/USAGE.md for the consumer-facing reference this guide is built on, and docs/PLATFORM-OPERATIONS.md for the deploy/operate runbook.