Sendo Connect
The optional paid service: Quami as your Meta Tech Provider. Configuration, the signed webhook relay, secret rotation, the test event, signup through Connect, disconnecting numbers, suspension, doctor checks and what passes through Quami.
Sendo is free and MIT licensed. The only paid piece is Sendo Connect: Quami acts as the Meta Tech Provider for your app, so you can offer “Connect your WhatsApp” without going through Meta’s business verification and app review yourself. Price: coming soon (per connected number, per month; see Pricing).
What changes with Connect#
- Configuration only. The package, the migrations and the registry components are identical; you add
connect: { apiKey, signingSecret }tocreateSendo. - Signup goes through Quami’s Meta app: the number’s token is returned to your server once and never stored by Connect.
- Webhooks arrive through Quami’s relay, signed with your own secret (
X-Sendo-Signature), and are verified fail-closed by your app. - Sending still goes directly from your server to Meta with the customer’s token.
Setup#
- In the Connect panel, create a project and copy the API key and the signing secret into
SENDO_CONNECT_API_KEYandSENDO_CONNECT_SIGNING_SECRET(.env.localand your deployment). - Register your webhook URL in the panel:
https://<your-domain>/api/sendo/webhook(thewebhookroute). You don’t configure a webhook in Meta. - In
lib/sendo.ts, removeappSecretand addconnect. Setmeta.appId/configId(andNEXT_PUBLIC_SENDO_META_APP_ID/NEXT_PUBLIC_SENDO_META_CONFIG_IDfor the connect button) to Quami’s values from the panel. - Run
npx @quami/sendo doctor --url https://<your-domain>/api/sendo/webhook(checks below).
export const sendo = createSendo({
adapter: memoryAdapter(), // your adapter
// No appSecret: with `connect`, webhooks are verified with X-Sendo-Signature only.
verifyToken: process.env.SENDO_VERIFY_TOKEN ?? "",
resolveToken: (account) => readSecret(account.tokenRef),
storeToken, // Embedded Signup tokens still go to your secret store
connect: {
apiKey: process.env.SENDO_CONNECT_API_KEY ?? "",
signingSecret: process.env.SENDO_CONNECT_SIGNING_SECRET ?? "",
},
});baseUrl defaults to https://connect.sendo.quami.dev. apiKey and signingSecret are registered as secrets: the logger and errors redact them like tokens.
Webhook signature#
With connect set, sendo.handleWebhook (and toNextHandlers) accepts a POST only when X-Sendo-Signature is valid:
X-Sendo-Signature: t=<unix seconds>,v1=<hex hmac_sha256(signingSecret, t + "." + rawBody)>[,v1=<hex>]- The timestamp must be within 5 minutes of your server’s clock (either direction), so a captured request can’t be replayed later. Keep the server clock synced.
- The comparison is constant-time. Several
v1=values are allowed (any may match) so the secret can be rotated (rotation). - Anything else (missing, malformed, stale, wrong secret, body changed) is
401and nothing is stored. - Meta’s
X-Hub-Signature-256is not accepted whileconnectis set, even ifappSecretis still configured. Withoutconnect, nothing changes: Meta’s signature is the only one checked.
To try your route locally, sign a fixture the way the relay does with signConnectBody (and verifyConnectSignature checks one):
// Sign a body the way the relay does, to try your webhook route locally.
export function relayHeaders(rawBody: string): Record<string, string> {
return {
"content-type": "application/json",
"x-sendo-signature": signConnectBody(rawBody, process.env.SENDO_CONNECT_SIGNING_SECRET ?? ""),
};
}signConnectBody(rawBody, secret, timestamp?) takes one secret or a list ([old, new]) and adds one v1= per secret, in that order. Connect’s relay signs with the same function.
Rotating the signing secret#
A project can have two active signing secrets. While both are active, the relay sends one v1= per secret, and your app accepts the request when any of them matches its SENDO_CONNECT_SIGNING_SECRET. To rotate without dropping a webhook:
- In the panel, add a second signing secret. Deliveries now carry two
v1=values; your app keeps verifying with the old secret. - Set
SENDO_CONNECT_SIGNING_SECRETto the new secret in.env.localand your deployment, and redeploy. - Check that deliveries still succeed (
sendo doctor, or Send test event), then revoke the old secret in the panel.
The panel refuses a third active secret.
Test event#
The panel’s Send test event button signs a synthetic body with your project’s active secrets and sends it to your webhook URL:
POST https://<your-domain>/api/sendo/webhook
X-Sendo-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]
{"object":"sendo_test"}With connect set, sendo.handleWebhook checks the signature exactly as for a real webhook. A valid test event gets 200 with Cache-Control: no-store, and nothing is stored, processed or emitted. A missing or wrong signature is 401, so a failed test points at SENDO_CONNECT_SIGNING_SECRET or the server clock. The body’s object is exported as CONNECT_TEST_EVENT_OBJECT. Without connect, the body gets no special handling.
Signup through Connect#
sendo.accounts.completeEmbeddedSignup(input) takes the same input and returns the same Account. With connect, steps 1–4 of the direct sequence are one call to Connect:
POST {baseUrl}/v1/signup/complete
Authorization: Bearer <apiKey>
{ "code", "wabaId", "phoneNumberId", "coexistence", "pin" } → 200 { "token", "displayPhone" }pin is the admin’s PIN or a random 6 digits (null for coexistence numbers, which skip register). Then Sendo continues exactly as in the direct path: storeToken, insert or reconnect the account (onboardedVia: "connect"), first number becomes the default, billing check. A failure after storeToken compensates the same way (no row left, deleteToken, app unsubscribed).
Errors keep the same codes. Connect reports Meta failures as { "error": { "code", "message", "step", "metaCode", "subcode", "fbtraceId" } }, which map to SIGNUP_CODE_EXPIRED, META_PERMISSION_MISSING, PIN_CONFLICT or SIGNUP_FAILED with that step. A rejected API key (401) is CONFIG_ERROR naming SENDO_CONNECT_API_KEY; Connect unreachable or an answer without a token is SIGNUP_FAILED with step: "connect". meta.appId and appSecret are not needed on your side.
Disconnecting a number#
sendo.accounts.disconnect(id) (and sendo accounts disconnect <id>) on a number with onboardedVia: "connect" also tells Connect, so it stops relaying and billing that number:
POST {baseUrl}/v1/numbers/{phoneNumberId}/disconnect
Authorization: Bearer <apiKey>
→ 200 { "ok": true } · 404 NUMBER_NOT_FOUND · 403 PROJECT_SUSPENDEDIt’s best effort: if Connect fails or can’t be reached (30-second timeout), Sendo logs a warning without the key and the number is still disconnected in your app. Disconnect it in the panel if the warning appears. The CLI notifies Connect when SENDO_CONNECT_API_KEY (and SENDO_CONNECT_SIGNING_SECRET) are set.
Suspension#
When a payment fails, Connect gives the account a 7-day grace period (with warnings by email and in the panel). After it, the project is suspended:
- The relay keeps queuing your webhooks for their 24-hour window, but doesn’t deliver them.
- Every
/v1/*call answers403 { "error": { "code": "PROJECT_SUSPENDED", … } }: signup fails and disconnect notifications are only logged. sendo doctorreportsconnect:keyas “Connect project suspended — update payment at app.sendo.quami.dev”, with abillingfix.
Paying un-suspends the project immediately, and anything still inside its 24-hour window is delivered.
Errors from Connect#
Every 4xx and 5xx answer from Connect has the same envelope, parsed into ConnectRequestError (status, errorCode, step, metaCode, subcode, fbtraceId):
{ "error": { "code", "message", "step"?, "metaCode"?, "subcode"?, "fbtraceId"? } }The codes are exported as CONNECT_ERROR_CODES: UNAUTHORIZED (401, missing or invalid key), PROJECT_SUSPENDED (403), NUMBER_NOT_FOUND (404), NUMBER_IN_USE (409, the number is active in another project), EARLY_ACCESS (signup before Meta approval), INVALID_REQUEST (400), META_ERROR (a signup step failed on Meta) and INTERNAL_ERROR (5xx). createConnectClient({ config: { apiKey, baseUrl } }) returns the same client the package uses (completeSignup, getWebhook, disconnectNumber).
Doctor checks#
When SENDO_CONNECT_API_KEY or SENDO_CONNECT_SIGNING_SECRET is set, sendo doctor requires both (and no longer requires SENDO_META_APP_SECRET) and calls GET {baseUrl}/v1/projects/current/webhook (SENDO_CONNECT_BASE_URL overrides the base URL):
| Check | Fails when | Fix |
|---|---|---|
connect:key | Connect rejects the key, or can’t be reached; “Connect project suspended” on 403 PROJECT_SUSPENDED | Copy the current key from the panel; check SENDO_CONNECT_BASE_URL; when suspended, update payment at app.sendo.quami.dev |
connect:webhook | No URL registered, or a different one from --url | Register https://<your-domain>/api/sendo/webhook in the panel |
connect:delivery | The relay’s last delivery got a non-2xx answer (401 = signing secret mismatch or clock skew); warns when nothing was delivered yet | Fix SENDO_CONNECT_SIGNING_SECRET or the route; the relay retries for 24 h |
What stays the same#
- Meta bills message costs to your customer’s WhatsApp Business account, not to Quami (Meta pricing). Connect is billed per connected number.
- Before charging, Connect shows the billing caveat: “Meta bills messages directly to your customer; they need a card on their account.” Until the customer adds a card, sends fail with 131042 and the number shows “payment method missing” (billing).
- Your data stays in your database.
Privacy#
With Connect, every webhook for your customers’ numbers passes through Quami’s relay before it reaches your app. What Connect sees and keeps:
- A webhook body is held encrypted only while its delivery is pending, and deleted on success or when the 24-hour retry window ends.
- Bodies are never logged.
- Only delivery metadata (timestamp, project, number, status code, attempt count, latency) is kept, for 7 days.
- Quami’s Meta app is the one connected to your customers’ WhatsApp accounts. The number’s access token is returned to your server once and never stored by Connect.
The pricing page states exactly this. Apps with sensitive data (health, for example) should use their own Meta app instead, so no third party sits in the message path.
Doing it yourself#
Being your own Tech Provider stays a first-class, free path: see Meta setup and Embedded Signup.