Onboarding flow
The onboarding flow has three actors and two server boundaries. Understanding both makes it much easier to debug.
Actors
Section titled “Actors”| Actor | Role |
|---|---|
| Your platform | The Kirimdev customer running the integration. Creates the customer, generates the link, listens for webhooks. |
| End-customer | The tenant on your platform (e.g. an Acme Logistics branch). Opens the link and completes Meta Embedded Signup. |
| Kirimdev | The server hosting app.kirimdev.com/onboard/{token} and the /api/public/onboarding/* endpoints. |
Server boundaries
Section titled “Server boundaries”| Boundary | Auth | Purpose |
|---|---|---|
Public API (/v1/customers/*) | Bearer API key | Your platform creates / lists / patches / revokes setup links here. |
Public onboarding (/api/public/onboarding/*) | No auth — bearer token in body | End-customer’s browser hits these. Token is the credential. |
The flow at a glance
Section titled “The flow at a glance”Six phases, each independently testable. Skip to What runs where for the per-endpoint detail.
| # | Phase | Trigger | Result |
|---|---|---|---|
| 1 | Create customer | POST /v1/customers from your platform | Customer row in pending status. Fires customer.created. |
| 2 | Generate link | POST /v1/customers/{id}/setup_links from your platform | Setup link active. Plaintext token returned once. Fires customer.setup_link.created. |
| 3 | Deliver link | Your platform emails / SMSs / DMs the setup_url to the tenant | Tenant has the URL. |
| 4 | Resolve | Tenant opens the URL. Browser POSTs /api/public/onboarding/resolve | Token validated, CSRF nonce minted, Meta Facebook app config returned. |
| 5 | Meta Embedded Signup | Browser runs FB.login(config_id) | Tenant authorises Kirimdev’s Facebook app, browser receives an OAuth code. |
| 6 | Callback | Browser POSTs /api/public/onboarding/callback with { token, nonce, code } | Meta token exchange, WhatsApp account inserted, link consumed, customer flips pending → active. Fires customer.setup_link.consumed + customer.onboarded. Browser redirected to success_redirect_url. |
What runs where
Section titled “What runs where”/api/public/onboarding/resolve
Section titled “/api/public/onboarding/resolve”Browser-driven. Two side effects: rate-limit increment and nonce mint.
- Per-token-prefix rate limit (Redis
INCR+EXPIRE). Reject 429 if over 30 calls / 60s. - Look up the token via
token_prefixindex, argon2-verify againsttoken_hash. - If the link is revoked / consumed / expired → return 410 with the reason.
- Mint a random 18-byte nonce, store in Redis under
customer-onboard-nonce:<prefix>with 10-minute TTL. - Return
{ customer: { id, name }, facebook: { appId, configId }, nonce, expires_at, success_redirect_url, failure_redirect_url }.
/api/public/onboarding/callback
Section titled “/api/public/onboarding/callback”Browser-driven. Many side effects: Meta calls, DB writes, webhook emits, queue enqueue.
- Per-token-prefix rate limit (same bucket as
/resolve). - Read the nonce from Redis and compare. Reject
400 invalid_nonceif missing or mismatched. Delete the nonce on first use. - Resolve the token (same path as
/resolve). - Exchange Meta’s
codefor anaccess_tokenvia Graph API. - Resolve the WABA id (from
debug_token) and the phone number (from/{waba}/phone_numbers). - Run
checkWhatsAppAccountLimit(team_id). Reject 403 if over quota. - Probe
checkCoexistenceStatusto detect SMB / WhatsApp Business App pairing. - Insert
whatsapp_accountswithcustomer_idset, credentials encrypted, statusconnecting. - Atomically flip the link from
activetoconsumed. If we lose the race to a parallel callback, delete the orphan WhatsApp account and return409 link_already_consumed. - Flip
customers.statusfrompendingtoactiveif it was pending. - Publish
customer.setup_link.consumedandcustomer.onboardedwebhook events. - Enqueue the
onboardingBullMQ job that subscribes the WABA to our app webhook and (if coexistence) kicks off the contact sync. - Build the
success_redirect_urlwith appended query params and return{ accountId, customerId, status: 'pending', redirect_url }.
The browser then window.location.replace(redirect_url) to bounce the
tenant back to your own app.
Why a sync handler instead of an async worker?
Section titled “Why a sync handler instead of an async worker?”The callback is synchronous from /oauth/access_token through the
DB writes because:
- The customer’s browser is waiting for the response — they need an immediate redirect.
- Meta
access_tokenis short-lived; we can’t park the work and pick it up later. - The orphan-cleanup compensation only works inside the same handler.
The slow Meta calls (webhook subscription, contact sync, history
sync) are handed off to the onboarding queue so the callback
typically returns in 2-3 seconds.
Webhook delivery timing
Section titled “Webhook delivery timing”The two relevant events fire after the synchronous DB writes commit but before the slow worker job runs:
| Event | Carries | Use it for |
|---|---|---|
customer.setup_link.consumed | customer_id, setup_link, account_id | Audit trail / link analytics |
customer.onboarded | customer_id, account_id, phone_number_id, phone_number | Business signal. Update your CRM, surface the new number in your UI, kick off welcome automation. |
Both events deliver before the contact sync completes. The account is
status: 'connecting' at the moment the webhook fires; it transitions
to connected once the worker finishes (typically a few seconds
later). If you’re sending immediately on receipt, retry with backoff
until the account is connected.
Failure modes
Section titled “Failure modes”| Symptom | Cause | What we return |
|---|---|---|
410 at /resolve | Token revoked / consumed / expired | { error: 'revoked' | 'consumed' | 'expired' }. Browser shows inline error. No redirect. |
400 invalid_nonce | Browser lost / never received nonce, or tried to replay | Inline error. No redirect. |
400 token_exchange_failed | Meta rejected the OAuth code | { error, redirect_url? }. Redirect to failure_redirect_url if configured. |
400 no_waba_found / phone_lookup_failed / no_phone_found | The granted scope didn’t include a WABA, or the WABA has no phone numbers yet | Same shape. Often resolved by retrying Embedded Signup with the right scopes. |
403 account_limit_reached | Your org has hit its WhatsApp account quota | { error, current, limit, redirect_url? }. Upgrade your plan. |
409 link_already_consumed | A parallel callback won the race | Orphan WA account is rolled back. Generate a new link. |
On every error after the token resolves, the callback returns the
failure_redirect_url (if configured) so the tenant doesn’t see a
dead-end Kirimdev page. See Redirect URLs
for the query param contract.
Race conditions and idempotency
Section titled “Race conditions and idempotency”| Race | Outcome |
|---|---|
| Two browsers open the same link, both complete signup | First-committer wins. Loser sees 409 link_already_consumed; the orphan WhatsApp account row is deleted. |
Browser retries /callback with the same code | Meta returns a fresh access token for the same code (within their grace window). The DB writes idempotency on link status, but the WhatsApp account insert is NOT idempotent — a re-run produces a second account row. Mitigation: clients should not retry; the spec is “exactly once”. |
/resolve called many times | Safe. Only side effects are the rate-limit increment and a new nonce overwriting the previous. |
See also
Section titled “See also”- Customers — what gets created server-side.
- Setup links — token format and lifecycle.
- Redirect URLs — query param contract on the success/failure bounce.
- Webhooks — payload shapes for the two onboarding events.