Skip to content
Concepts

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 typecustomer_setup_link
Public id prefixcsl_ (used for the id AND the token)
ScopeOne specific customer (cus_…)
Lifetime1 hour to 30 days; default 7 days
UsageSingle-use — consumed once and never re-usable

A plaintext token is csl_ followed by 24 base64url characters:

csl_3V3EMgPDU4zCTm7Fju7KWvqb

That’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.

FieldTypeDescription
idstringPublic id, csl_…. Different from the token plaintext. Used to revoke or patch the link.
objectliteralAlways 'customer_setup_link'
customer_idstringInternal id of the owning customer (not the cus_… public id — historical artifact).
statusenumactive, consumed, expired, or revoked.
token_last4stringLast 4 chars of the plaintext, for UI hint (”…Wvqb”).
expires_atstringISO 8601. Hard ceiling 30 days from creation.
consumed_atstring | nullISO 8601 of successful Embedded Signup completion. Only set when status = 'consumed'.
success_redirect_urlstring | nullSee Redirect URLs.
failure_redirect_urlstring | nullSee Redirect URLs.
created_atstringISO 8601.

The create response adds two write-once fields:

FieldTypeDescription
tokenstringPlaintext token (csl_…24chars). Persist immediately or discard.
setup_urlstringFull URL ready to hand to the customer.

A setup link is born active and exits into one of three terminal states. There are no other transitions.

StatusMeaningTerminal?
activeNewly created. The customer can resolve and consume it.No
consumedThe end-customer completed Embedded Signup. A WhatsApp account row was inserted and its id recorded in consumed_by_account_id.Yes
expiredexpires_at passed. Flipped lazily on the next resolve attempt.Yes
revokedOperator manually invalidated the link via DELETE.Yes
FromToTrigger
(new)activePOST /v1/customers/{id}/setup_links
activeconsumedEnd-customer completes Meta Embedded Signup via the link
activeexpirednow >= expires_at at next resolve (or by the background sweep)
activerevokedDELETE /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.

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}`,
})
ParamDefaultLimits
expires_in_hours168 (7 days)1 to 720 (30 days)
success_redirect_urlnonehttps only (http://localhost ok in dev). 2048 chars max. No fragment.
failure_redirect_urlnone(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).

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.

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.

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.

We do not store the plaintext token. The DB columns are:

ColumnWhat it holdsPurpose
token_prefixFirst 16 chars of the plaintextIndexed lookup for resolution
token_hashargon2id(plaintext)Constant-time verification
token_last4Last 4 charsUI 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.

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.

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.

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.

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.

  • 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.created and customer.setup_link.consumed events.