Embedded Signup
Let an admin connect a WhatsApp number from your app, plus the billing state: what Meta requires, completeEmbeddedSignup, errors and refreshBilling.
Embedded Signup lets an admin connect a WhatsApp number from inside your app: Meta’s popup creates or picks the WhatsApp Business account and number, and Sendo finishes the setup server-side. Nobody copies ids or tokens by hand.
What Meta requires first#
Until the review passes, only people with a role on your Meta app can complete the popup; everyone else gets a permissions error (META_PERMISSION_MISSING, below).
How it works#
- The admin taps Connect WhatsApp on the numbers screen. The Facebook JS SDK opens Meta’s popup with
NEXT_PUBLIC_SENDO_META_APP_IDandNEXT_PUBLIC_SENDO_META_CONFIG_ID. - Meta returns a single-use
codeand, from the session-info event,waba_idandphone_number_id. For a number that stays in the WhatsApp Business app, start the flow withfeatureType: "whatsapp_business_app_onboarding"(Coexistence). - Your server action calls
sendo.accounts.completeEmbeddedSignup({ code, tenantId, wabaId, phoneNumberId, coexistence?, pin?, label? }).
The numbers registry item ships the button, the action and a Supabase Vault storeToken. Requirements before the button: NEXT_PUBLIC_SENDO_META_APP_ID, NEXT_PUBLIC_SENDO_META_CONFIG_ID, SENDO_META_APP_SECRET and the storeToken / deleteToken hooks.
The numbers screen#
npx shadcn add @sendo/numbers installs the numbers screen at /inbox/numbers, in English or Spanish (sendoLocale in lib/sendo-ui.ts, see Languages): connect (new number or the WhatsApp Business app flavour), label, default, agent pause and mode, I added it, check again (“Ya lo agregué, verificar” in Spanish), reconnect, disconnect (typing the last 4 digits), Export conversations and, when you opt in, Delete history (Retention). Only admin (numbers.manage) changes or exports numbers; other roles get a read-only list, or nothing with numbersVisibleToNonAdmins: false.
- Implement
requireUser()inapp/(protected)/inbox/numbers/numbers-config.tsandresolveRoleincreateSendo. - Apply
supabase/migrations/20261012000000_numbers_vault_tokens.sqland uncomment...createVaultTokenStore(supabaseAdmin)andmetainlib/sendo.ts. Tokens are stored asvault:<secret id>;resolveTokenreads those from Vault and every othertokenReffromprocess.env. Single-tenant apps can skip Vault and add their number withsendo accounts add --token-env. - Add your HTTPS domain to the Meta app’s Facebook Login for Business settings (allowed JavaScript SDK domains).
- A
PIN_CONFLICTspends the code, so the screen asks for the PIN and opens Meta’s popup again. - Invalid token · reconnect: when the screen loads it re-checks every connected number with
refreshBilling; Meta error 190 emitsaccount.error, and those numbers are marked. Nothing extra is stored. Turn it off withcheckTokensOnLoad: false.
Configuration#
completeEmbeddedSignup throws ConfigError without storeToken, meta.appId or the app secret. The business token goes to your secret store through storeToken; Sendo only saves the reference it returns as tokenRef, and your resolveToken reads it back.
// Keep the business token in your secret store; Sendo only saves the returned reference.
const storeToken: StoreToken = async ({ account, token }) => {
const id = await saveSecret(
`sendo/${account.tenantId ?? "default"}/${account.phoneNumberId}`,
token,
);
return `vault:${id}`;
};
// Called on disconnect (and to undo a failed signup). Ignore refs you didn't create.
const deleteToken: DeleteToken = async (tokenRef) => {
if (tokenRef.startsWith("vault:")) await deleteSecret(tokenRef.slice("vault:".length));
};
export const sendo = createSendo({
adapter: memoryAdapter(), // your adapter
appSecret: process.env.SENDO_META_APP_SECRET ?? "",
verifyToken: process.env.SENDO_VERIFY_TOKEN ?? "",
resolveToken: (account) =>
account.tokenRef.startsWith("vault:")
? readSecret(account.tokenRef.slice("vault:".length))
: process.env[account.tokenRef],
storeToken,
deleteToken,
meta: {
appId: process.env.NEXT_PUBLIC_SENDO_META_APP_ID,
configId: process.env.NEXT_PUBLIC_SENDO_META_CONFIG_ID,
},
});What completeEmbeddedSignup does#
On Graph GRAPH_API_VERSION:
- Exchanges the code:
GET /oauth/access_token?client_id&client_secret&code→ the business token. - Subscribes your app to the WABA’s webhooks:
POST /{waba_id}/subscribed_apps. - Registers the number:
POST /{phone_number_id}/registerwith the two-step PIN. Skipped for coexistence numbers. The PIN is the one the admin typed or a random 6 digits; it is never stored (two-step verification can be reset from WhatsApp Manager). - Reads
display_phone_numberandverified_name. - Calls
storeToken({ account, token })→tokenRef. - Inserts the account with
onboarded_via = "embedded_signup", or reconnects the same row when the number was disconnected in the same tenant (Numbers and tenants). - Makes it the tenant’s default if it is the tenant’s first number.
- Checks billing (below).
A number already connected (in any tenant), or disconnected in another tenant, throws DuplicateAccountError before the code is used. If steps 1–5 fail, nothing is stored. If anything fails after storeToken, Sendo calls deleteToken(tokenRef), unsubscribes the app from the WABA (best effort, skipped when another connected number uses the same WABA) and leaves no account row. The token never appears in logs, errors, rows or events.
Errors#
Branch on code; each one has a message your UI can show.
code | Class | Meaning | Suggested message |
|---|---|---|---|
SIGNUP_CODE_EXPIRED | EmbeddedSignupError | The code expired or was already used. | “El enlace con Meta expiró, intenta de nuevo.” |
META_PERMISSION_MISSING | EmbeddedSignupError | The app lacks a permission (app review not done) or access to the WABA/number. | Link to the requirements. |
PIN_CONFLICT | EmbeddedSignupError | The number already has a two-step PIN and it was missing or wrong (or too many attempts). | Ask for the existing PIN and retry with pin. |
SIGNUP_FAILED | EmbeddedSignupError | Anything else; step says where (exchange_code, subscribe_app, register_phone, read_phone, store_token, save_account). | Generic retry. |
ACCOUNT_EXISTS | DuplicateAccountError | The number is connected in another organization. | “Este número ya está conectado en otra organización.” |
CONFIG_ERROR | ConfigError | Missing storeToken, meta.appId or app secret. | A setup problem, not a user one. |
EmbeddedSignupError also carries step, metaCode, subcode, fbtraceId and details from Meta. A popup the admin closed never reaches the server: show a neutral toast and create nothing.
type ConnectResult = { ok: true; account: Account } | { ok: false; message: string };
/** Server action called by the "Connect WhatsApp" button with what Meta returned. */
export async function connectWhatsApp(input: {
tenantId: string;
code: string;
wabaId: string;
phoneNumberId: string;
coexistence?: boolean;
pin?: string;
}): Promise<ConnectResult> {
// Check your actor's `numbers.manage` capability first.
try {
const account = await sendo.accounts.completeEmbeddedSignup(input);
return { ok: true, account };
} catch (error) {
if (error instanceof DuplicateAccountError) {
return { ok: false, message: "Este número ya está conectado en otra organización." };
}
if (error instanceof EmbeddedSignupError) {
const messages: Record<EmbeddedSignupError["code"], string> = {
SIGNUP_CODE_EXPIRED: "El enlace con Meta expiró, intenta de nuevo.",
META_PERMISSION_MISSING:
"Tu app de Meta aún no tiene los permisos necesarios. Revisa sendo.quami.dev/docs/embedded-signup.",
PIN_CONFLICT:
"Este número tiene un PIN de verificación en dos pasos. Escríbelo para continuar.",
SIGNUP_FAILED: "No pudimos conectar el número. Intenta de nuevo.",
};
return { ok: false, message: messages[error.code] };
}
throw error;
}
}Billing state#
As a Tech Provider you don’t pay for your customers’ messages: each customer adds a payment method to their own WABA in Meta. Until they do, sends fail. account.billingReady tracks it:
| Value | Shown as | Set by |
|---|---|---|
true | Connected | Meta reports a payment method. |
false | Connected · payment method missing | Meta reports none, or a send failed with error 131042. |
null | Connected · payment status unknown | Not checked yet, or Meta didn’t say. The first failed send resolves it. |
sendo.accounts.refreshBilling(id)reads the WABA’s funding field (GET /{waba_id}?fields=primary_funding_id): a funding id →true, none →false, a Meta error → the value is kept. It always setsbillingCheckedAtand emitsaccount.billingwhen the value changed. Wire it to an I added it, check again button.- Sends never skip Meta because of
billingReady = false: Meta is the source of truth. When Meta answers131042, Sendo setsbillingReady = false, emitsaccount.billingand rethrows theMetaApiErrorwithhint: "billing_not_ready"(“Este número aún no tiene método de pago en Meta”). sendo doctoraddsaccount:<id>:billing, a warning (not a failure) with a Meta Business Settings link when the value isfalseornull.
MetaApiError.hint is one of billing_not_ready (131042), token_invalid (190), window_closed (131047), rate_limited (4, 17, 32, 613, 80007, 130429, 131056), or null.
export async function sendReminder(tenantId: string, phone: string): Promise<string | null> {
try {
await sendo.send.template(phone, "recordatorio_cita", { tenant: tenantId, language: "es" });
return null;
} catch (error) {
if (error instanceof MetaApiError && error.hint === "billing_not_ready") {
// Meta error 131042: the number is now billingReady = false and account.billing fired.
return "Este número aún no tiene método de pago en Meta";
}
throw error;
}
}
// "I added it, check again" on the numbers screen.
export async function verifyBilling(accountId: string) {
const account = await sendo.accounts.refreshBilling(accountId);
return account.billingReady; // true, false, or null (Meta didn't say)
}