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.
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.
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.
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.
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).
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.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.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.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.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.Zooming into the "Lambda: whatsapp-send" box above — this is the exact decision path every POST /v1/send call goes through, in order.
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).
/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.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)."dryRun": true first if you want to validate the key/allowlist without actually sending.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 +.
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 ✅"}'
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"]}'
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":"…"}.
| Result | Meaning |
|---|---|
200 | { messageId, to } — accepted by Meta |
403 | bad key, or recipient not in this key's allowlist |
429 | daily quota exceeded for this key |
422 | Meta rejected the message — see error codes below |
400 | malformed request body |
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"})
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');
429/5xx.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.
| Code | Meaning |
|---|---|
131047 | Re-engagement needed → use a template (notify already handles this automatically) |
132001 / 132015 | Template or its locale doesn't exist |
131030 | Recipient not in Meta's test allowlist (dev/sandbox numbers) |
131049 | Dropped for ecosystem/frequency reasons (template reclassified as marketing) |
131026 | Message undeliverable (recipient can't receive WhatsApp messages) |
Everything below lives on the dashboard at send.customapp.co.za/dashboard.html once your account is active.
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.
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.
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.
| Method | Path | What |
|---|---|---|
GET | /api/me | your identity, account, admin flag |
POST | /api/accounts | create your account (if you already had a session but no account row) |
GET / POST | /api/keys | list your keys / create a new one |
DELETE | /api/keys/:id | revoke a key |
POST | /api/keys/:id/allowlist | replace a key's allowlist |
GET | /api/usage | 7-day sent/error counts + recent rows |
GET / POST | /api/tokens | list / create personal API tokens |
DELETE | /api/tokens/:id | revoke a personal token |
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).
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.
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".
whatsapp/sender-keys) from the token itself (whatsapp/sender-token).wa_platform Postgres schema has Row-Level Security enabled and is only ever accessed with the Supabase service-role key from inside the portal Lambda — it's never exposed to the browser directly, and the anonymous/authenticated Postgres roles have no grants on it at all./api/signup, which uses the Auth admin API server-side instead.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.
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.
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.
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.
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.