Setup links
A setup link is a one-time URL you hand to your end-customer so they can connect their own WhatsApp Business number to your Kirimdev platform. The customer never needs a Kirimdev login.
https://app.kirimdev.com/onboard/csl_3V3EMgPDU4zCTm7Fju7KWvqb └────── plaintext token ──────┘| Resource type | customer_setup_link |
| Public id prefix | csl_ (used for the id AND the token) |
| Scope | One specific customer (cus_…) |
| Lifetime | 1 hour to 30 days; default 7 days |
| Usage | Single-use — consumed once and never re-usable |
Token format
Section titled “Token format”A plaintext token is csl_ followed by 24 base64url characters:
csl_3V3EMgPDU4zCTm7Fju7KWvqbThat’s ~144 bits of entropy (randomBytes(18).toString('base64url')).
Prefix lookups + argon2id verification make brute force impractical
without also bypassing the per-token rate limit.
Fields
Section titled “Fields”| Field | Type | Description |
|---|---|---|
id | string | Public id, csl_…. Different from the token plaintext. Used to revoke or patch the link. |
object | literal | Always 'customer_setup_link' |
customer_id | string | Internal id of the owning customer (not the cus_… public id — historical artifact). |
status | enum | active, consumed, expired, or revoked. |
token_last4 | string | Last 4 chars of the plaintext, for UI hint (”…Wvqb”). |
expires_at | string | ISO 8601. Hard ceiling 30 days from creation. |
consumed_at | string | null | ISO 8601 of successful Embedded Signup completion. Only set when status = 'consumed'. |
success_redirect_url | string | null | See Redirect URLs. |
failure_redirect_url | string | null | See Redirect URLs. |
created_at | string | ISO 8601. |
The create response adds two write-once fields:
| Field | Type | Description |
|---|---|---|
token | string | Plaintext token (csl_…24chars). Persist immediately or discard. |
setup_url | string | Full URL ready to hand to the customer. |
Lifecycle
Section titled “Lifecycle”A setup link is born active and exits into one of three terminal
states. There are no other transitions.
| Status | Meaning | Terminal? |
|---|---|---|
active | Newly created. The customer can resolve and consume it. | No |
consumed | The end-customer completed Embedded Signup. A WhatsApp account row was inserted and its id recorded in consumed_by_account_id. | Yes |
expired | expires_at passed. Flipped lazily on the next resolve attempt. | Yes |
revoked | Operator manually invalidated the link via DELETE. | Yes |
Transitions
Section titled “Transitions”| From | To | Trigger |
|---|---|---|
(new) | active | POST /v1/customers/{id}/setup_links |
active | consumed | End-customer completes Meta Embedded Signup via the link |
active | expired | now >= expires_at at next resolve (or by the background sweep) |
active | revoked | DELETE /v1/customers/{id}/setup_links/{link_id} |
Terminal states are permanent. You cannot revive a link — generate a new one. Patching redirect URLs (see below) is the only mutation allowed on an active link.
Generate
Section titled “Generate”const link = await kirim.customers.createSetupLink(customer.id, { expires_in_hours: 168, success_redirect_url: 'https://yourapp.com/onboarded', failure_redirect_url: 'https://yourapp.com/onboard-failed',})
await sendEmailToTenant(customer.email!, { subject: 'Connect your WhatsApp', body: `Click here to connect: ${link.setup_url}`,})| Param | Default | Limits |
|---|---|---|
expires_in_hours | 168 (7 days) | 1 to 720 (30 days) |
success_redirect_url | none | https only (http://localhost ok in dev). 2048 chars max. No fragment. |
failure_redirect_url | none | (same as success) |
for await (const link of kirim.customers.listSetupLinks(customer.id, { status: 'active' })) { console.log(link.id, link.expires_at, link.token_last4)}Returns up to the 50 most recent links per customer, newest first.
Optional status filter (active, consumed, expired, revoked).
Patch redirect URLs
Section titled “Patch redirect URLs”If you typo’d the URL or your routing changed, patch an active link without rotating the token:
await kirim.customers.updateSetupLink(customer.id, link.id, { success_redirect_url: 'https://yourapp.com/onboarded-v2', failure_redirect_url: null, // clears the redirect})The same validation rules apply (https, no private hosts, no
fragment). Patching a non-active link returns 409 conflict.
Revoke
Section titled “Revoke”await kirim.customers.revokeSetupLink(customer.id, link.id)Revoking is idempotent for race safety — calling DELETE on an already-consumed link returns 409, not an error you need to handle specially.
Security
Section titled “Security”The setup link is a bearer credential. Anyone who has the URL can complete onboarding for that specific customer. That is intentional — the operator sends it to a known tenant — but it shapes how we store and rate-limit it.
What we store
Section titled “What we store”We do not store the plaintext token. The DB columns are:
| Column | What it holds | Purpose |
|---|---|---|
token_prefix | First 16 chars of the plaintext | Indexed lookup for resolution |
token_hash | argon2id(plaintext) | Constant-time verification |
token_last4 | Last 4 chars | UI hint only |
Compromise of the database does not give an attacker usable tokens — argon2id reversal is not feasible at the cost we tune to (19 MiB memory, time cost 2). Compromise of an in-flight email, however, does. Treat setup link URLs like password-reset links in your own threat model.
Per-token rate limit
Section titled “Per-token rate limit”Even if an attacker rotates IPs, the rate-limit bucket is keyed on
the token prefix. The current ceiling is 30 calls per 60s to
either /resolve or /callback. Legitimate users complete onboarding
in 2-4 calls; the cap lets us cut off brute-force prefix probing fast.
Nonce-based CSRF
Section titled “Nonce-based CSRF”After /resolve succeeds, the server sets a short-lived (10 minute)
nonce in Redis keyed by the token prefix. The matching /callback
must echo that nonce, then we consume it. This stops a malicious page
from tricking a logged-in user into finishing the flow with someone
else’s Meta code.
Lazy expiry
Section titled “Lazy expiry”We never serve a token whose expires_at is in the past — even if
the periodic sweep hasn’t flipped the status yet. The resolve handler
does the comparison on every call.
Audit trail
Section titled “Audit trail”consumed_by_account_id references whatsapp_accounts(id) with
ON DELETE SET NULL. If the resulting WhatsApp account is later
deleted, the link row stays addressable for audit; only the pointer
becomes null.
created_by_user_id is recorded on every link. When created via the
Public API, this resolves to the user who originally created the API
key.
See also
Section titled “See also”- Redirect URLs — validation and the query param contract on success/failure redirects.
- Onboarding flow — end-to-end sequence including the public
/onboard/{token}page. - Webhooks — the
customer.setup_link.createdandcustomer.setup_link.consumedevents.