Appearance
Agent prompt: build the provisioning flow
Paste the prompt below into Claude Code, Cursor, Copilot or any coding agent running inside your product's codebase. It builds the full provisioning flow: connect a customer's WhatsApp number from your UI, store their credentials, send messages and handle webhooks.
Before you paste it, fill in the gateway URL in the Connection details section (or leave the placeholder and the agent will ask). Use the copy button at the top right of the block.
Keep the key out of chat history
The prompt only needs the gateway URL. Put the provisioning key in your environment file yourself instead of pasting it to the agent. See how to get a provisioning key.
markdown
# Task: let our customers connect their own WhatsApp number (via WA Gateway provisioning)
You are a senior engineer working in this codebase. Add a "Connect WhatsApp" feature that uses our self-hosted **WA Gateway** provisioning API. Each of our customers (a tenant, account, workspace or organisation in this app, whatever the codebase calls it) gets their own gateway "app", linked to their own WhatsApp number by QR code. Read the codebase first, follow its conventions (structure, config, HTTP client, ORM and migrations, background jobs, auth, tests, UI kit) and keep the change focused. If anything here conflicts with how the codebase works, prefer the codebase and tell me what you changed.
## Connection details
- **Gateway base URL:** `<https://gateway.example.com>`
- **Provisioning key:** I will set it as `WA_PROVISIONING_KEY` in the environment. Never ask me to paste it into chat, never log it, never commit it.
- **Docs for the gateway:** `<base URL>/docs/` (API reference at `/docs/api/provisioning`, webhooks at `/docs/guide/webhooks`)
Add to the app's config and to `.env.example` (empty values, never real ones):
```
WA_GATEWAY_URL=
WA_PROVISIONING_KEY=
WA_WEBHOOK_URL= # public URL of OUR webhook route below, e.g. https://app.example.com/webhooks/whatsapp
```
Validate them at startup and fail fast with a clear message if missing.
## How the gateway works (read this, it drives the design)
Two credentials exist:
1. **Provisioning key**: header `x-provisioning-key`. Ours, one for the whole product. Creates and manages gateway apps. It only ever sees apps it created (others answer `404`).
2. **App `apiKey`**: header `x-api-key`. One per customer, returned when the app is created. Used to send messages and read status through the tenant API.
Provisioning endpoints (all under `WA_GATEWAY_URL`, header `x-provisioning-key`):
| Method | Path | Purpose |
|---|---|---|
| POST | `/api/provision/apps` | Create. Body `{ name (1-100 chars), externalRef? (<=200), webhookUrl? (http/https) }`. `201` returns the app plus `apiKey` and `webhookSecret`, **shown only in this response**. Pairing starts immediately. |
| GET | `/api/provision/apps?externalRef=` | List this key's apps: `{ "apps": [...] }` with live `status`. |
| GET | `/api/provision/apps/:id` | One app with live `status` (and `info.phone` when connected). |
| PATCH | `/api/provision/apps/:id` | Update `{ name?, webhookUrl? }`. |
| GET | `/api/provision/apps/:id/qr` | `200 { status:"qr", qr:"data:image/png;base64,..." }`, or `202 { qr:null }` while starting, or `200 { status:"ready" }` when linked. |
| POST | `/api/provision/apps/:id/pairing-code` | Body `{ phoneNumber }` returns `{ pairingCode, expiresIn }`. For users on the same phone. |
| POST | `/api/provision/apps/:id/webhook-secret` | Rotate. The new secret is returned only here. |
| POST | `/api/provision/apps/:id/unlink` | Log out the number and start a fresh QR. |
| DELETE | `/api/provision/apps/:id` | Delete the app, its session and messages. |
Tenant endpoints (header `x-api-key: <that customer's apiKey>`):
- `GET /api/status`, `POST /api/messages` `{ to, text, replyTo? }`, `POST /api/messages/media`, `GET /api/numbers/:number`, `GET /api/messages`.
- Sends return `409 NOT_READY` until `status` is `ready`, `422 NOT_REGISTERED` if the number is not on WhatsApp (never retry), and `429` with `Retry-After` when over the per-app limit (30/min by default).
- `status: "unconfirmed"` with `messageId: null` means the message may still arrive: **do not resend automatically**.
Errors are `{ error, code }`. Creating apps is limited: `409 APP_LIMIT` when the key has reached its app cap, `429 RATE_LIMITED` (with `Retry-After`) when too many were created this hour.
## What to build
### 1. Gateway client (server-only)
A small module with typed functions for the provisioning endpoints above and for the tenant calls we need. Use the project's HTTP client, a 35 second timeout, and map gateway `{ error, code }` responses to a typed error carrying `status`, `code` and `retryAfter`. Never import this from browser code.
### 2. Data model and migration
Per customer, store: `gatewayAppId`, `gatewayApiKey` (**encrypted at rest**, using whatever secret storage or encryption helper the codebase already has), `gatewayWebhookSecret` (**encrypted**), `whatsappStatus` (`not_connected | connecting | connected | disconnected`), `whatsappPhone` (nullable), `connectedAt`. Add one row per customer, unique on `gatewayAppId`. Write a reversible migration in the project's migration tool.
### 3. Connect flow (backend routes, behind our normal customer auth and permissions)
- `POST /integrations/whatsapp/connect`: **idempotent**. Look up an existing gateway app by `externalRef = <our customer id>` first (`GET /api/provision/apps?externalRef=`); only create when none exists. On create, save `apiKey` and `webhookSecret` encrypted **in the same step, before returning**, because they are never shown again. Use our customer id as `externalRef` and the customer's display name as `name`. Pass `webhookUrl = WA_WEBHOOK_URL`. If it exists but we lost its credentials, return a clear error telling an admin to delete and recreate it. Do not invent a recovery path.
- `GET /integrations/whatsapp/qr`: proxy `GET /api/provision/apps/:id/qr` for the current customer only. Pass through the `qr` data URL and `status`. Never return keys or secrets.
- `POST /integrations/whatsapp/pairing-code`: proxy with `{ phoneNumber }` for customers who cannot scan.
- `GET /integrations/whatsapp/status`: current status and phone, refreshed from the gateway.
- `POST /integrations/whatsapp/reconnect`: call `unlink` then return to the QR step.
- `DELETE /integrations/whatsapp`: delete the gateway app, then clear our stored credentials. Also call this when the customer account is deleted.
Authorization is the main risk here: every route must resolve the gateway app id **from the signed-in customer's own record**, never from a request parameter, so one customer can never read another's QR or send as another's number.
### 4. UI
A "WhatsApp" settings section in the existing UI kit, with these states:
1. **Not connected**: a "Connect WhatsApp" button.
2. **Connecting**: shows the QR image from `/qr`. Poll every 2 to 3 seconds and always render the latest image (the code rotates about every 20 seconds). Steps shown to the user: *WhatsApp > Linked devices > Link a device > scan*. Include a "Use phone number instead" option that calls the pairing-code route and shows the code with: *Linked devices > Link with phone number*. Stop polling on unmount.
3. **Connected**: shows the linked number, a "Send test message" action, and "Disconnect".
4. **Disconnected / needs re-scan**: a "Reconnect" button (the gateway drops stored logins of apps offline for 7 days).
Handle loading, errors and the `409 APP_LIMIT` / `429` cases with clear messages. Follow the app's accessibility and i18n conventions.
### 5. Sending
A `sendWhatsApp(customerId, to, text)` service that loads that customer's decrypted `apiKey` and calls `POST /api/messages`. Send from a background job or queue, never inline in a user request. Retry `409 NOT_READY`, `429` (honour `Retry-After`) and `5xx` with backoff. Never retry `422 NOT_REGISTERED`, and never auto-resend after `unconfirmed`. If the customer is not connected, fail with a typed "not connected" error the caller can surface.
### 6. Webhook receiver
One route at the path in `WA_WEBHOOK_URL` for **all** customers:
- Read the **raw body bytes** (configure the framework so JSON parsing does not run first).
- Find the customer by header `x-wa-gateway-app` (equals the gateway app id). Unknown app: respond `404`.
- Verify header `x-wa-gateway-signature` (`sha256=<hex>` HMAC-SHA256 of the raw body) with that customer's `webhookSecret`, compared in constant time. Invalid: `401`.
- De-duplicate on header `x-wa-gateway-delivery` (delivery is at-least-once).
- Respond `200` quickly, then process asynchronously. Handle `message.received` (fields: `messageId`, `chatId`, `from`, `body`, `type`, `hasMedia`, `isGroup`, `timestamp`; reply to `chatId`), `message.status` (`sent | delivered | read | error`), and ignore unknown events. Use `webhook.test` for a quick check.
- On a status change that implies the account was logged out or the session was lost, update `whatsappStatus` so the UI shows "Reconnect".
### 7. Operational touches
- A scheduled job (hourly is fine) that reconciles `whatsappStatus` for connected customers via `GET /api/provision/apps/:id`.
- Log gateway failures with the app id and error code, never keys, secrets or message bodies.
- Document the new env vars and the feature in the README or the project's docs.
## Security rules (do not skip)
- Provisioning key, per-customer `apiKey` and `webhookSecret` stay on the server, are never sent to the browser, logged, or committed, and are encrypted at rest.
- Every customer-facing route derives the gateway app from the authenticated customer.
- Webhook signatures are verified over the raw body in constant time before any processing.
- Only message people who expect to hear from us; WhatsApp restricts numbers that send spam.
## Tests
Add tests in the project's framework with the gateway HTTP calls mocked:
- connect is idempotent (second call does not create a second app) and stores encrypted credentials
- QR route returns the QR for the right customer and `404`/`403` for another customer's data
- webhook accepts a correctly signed body, rejects a bad or missing signature, ignores a repeated delivery id, and routes by `x-wa-gateway-app`
- send retries `NOT_READY`/`429`, and does not retry `NOT_REGISTERED` or `unconfirmed`
- disconnect and customer deletion call the gateway delete
## Acceptance checklist (verify before you finish)
- [ ] A new customer can click Connect, see a QR, scan it, and the UI flips to Connected without a manual refresh
- [ ] Refreshing the page mid-pairing resumes at the QR step and does not create a second gateway app
- [ ] A test message sent from the UI arrives on WhatsApp, and the delivery status updates from webhooks
- [ ] A reply from WhatsApp reaches the webhook handler and is stored or acted on
- [ ] No key or secret appears in client bundles, API responses, logs or git
- [ ] Tests pass and the new env vars are in `.env.example`
When done, summarise the files you changed, any assumptions, and anything I must set up by hand (env vars, the public webhook URL, running the migration).Using the prompt well
- Tell the agent your stack. It detects the framework from the repo. If you want a specific approach (for example a particular queue or ORM), add one line at the top.
- Give it a reachable webhook URL. The gateway has to be able to call
WA_WEBHOOK_URLover the internet. For local development use a tunnel and update the app withPATCH /api/provision/apps/:id. - Review the authorization code yourself. The prompt calls it out, but the "gateway app comes from the signed-in customer" rule is the part worth reading line by line.
- Want the messaging side too? The dashboard has a second prompt generator for sending features. Open an app, choose Integrate, then Set up with your AI agent.