Registration
Butt-Dial includes self-service registration. Users create an account, verify their email, and get API credentials — no manual admin setup needed.
Flow
- Visit the landing page and click Get Started
- Fill out the registration form (name, email, team, password)
- Receive a verification email with a 6-digit code
- Enter the code to verify
- Get a team token and start using the API
Endpoints
| Endpoint | Method | Description |
|---|---|---|
/auth/login | GET | Login/register page |
/auth/register | POST | Create account |
/auth/verify | GET/POST | Email verification |
/auth/login | POST | Login with existing credentials |
Email Verification
- 6-digit code sent to the user's email
- Codes expire after 15 minutes
- Requires Resend to be configured
- In demo mode, the code is shown on screen instead of emailed
Workspace Token (`teamToken`)
After verification, every organization gets a single teamToken — the
canonical name for the workspace bearer token. Pass it as
Authorization: Bearer on every authenticated request.
GET /sse?token=<teamToken>&agentId=<agent-id> (agentId required for team tokens)
Legacy aliases — deprecated, will be removed
The same value is also returned as ownerToken, team_token, and
owner_token in the POST /api/orgs/verify response. **These are
deprecated** — use teamToken. Snake_case aliases across the auth
surface will be removed; the OpenAPI spec marks individual properties
deprecated: true where the rename has shipped.
The migration is in flight across the rest of the auth surface:
| Canonical (camelCase) | Deprecated (snake_case alias) |
|---|---|
teamToken | team_token, owner_token, ownerToken |
orgId | org_id |
agentId | agent_id |
displayName | display_name |
callbackUrl | callback_url |
retryAfter | retry_after |
Hosts should migrate to camelCase. The snake_case keys still ship for
one release alongside the camelCase canonicals.
> Note (2026-05-08): earlier sprints set response-level
> Deprecation: true and Sunset: headers on every endpoint that
> echoed snake_case aliases. That was misleading — RFC 8594 describes
> the *resource* as deprecated, not specific body keys. Those headers
> have been removed from POST /agents, GET /agents/me,
> GET /agents/{id}, and POST /api/orgs/register; those endpoints
> are NOT being sunset. See "Endpoint deprecations" below for the
> three surfaces that genuinely are.
Endpoint deprecations
The following endpoints are scheduled for removal. Each carries
Deprecation: true, Sunset: , and
Link:
on every response — RFC 8594 + draft-ietf-httpapi-deprecation-header
compliant. SDK clients can auto-migrate by following the
successor-version link.
| Deprecated endpoint | Successor | Sunset |
|---|---|---|
POST /api/v1/provision | POST /api/v1/agents (with channels + dns in the body) | 2026-07-27 |
POST /api/v1/onboard | POST /api/v1/agents (with channels + dns in the body) | 2026-07-27 |
GET /api/orgs/verify?token=... | /verify#token=... (browser-fragment route — token never appears in the URL bar or referer) | server-boot + 90 days |
Migration notes:
/provisionand/onboard→POST /api/v1/agents: the
endpoint, plus an optional channels array (replaces capabilities)
and an optional dns block for the email-domain verification step
that /onboard previously bundled. Behavior is identical;
channel-by-channel migration is unnecessary.
GET /api/orgs/verify?token=...→/verify#token=...: the new
network in a Referer header or appears in server logs. Email-link
scanners that probe the link no longer burn the token. Old in-flight
emails will continue to work via the legacy GET until the Sunset
date.
If you need a Sunset extension or successor change, contact BD before
the date in the table.
Verification polling — `pollToken`
POST /api/orgs/register returns a pollToken alongside the orgId.
Call GET /api/orgs/:orgId/verification-status?poll_token=
to watch for the user clicking the verification email. The endpoint
requires either the pollToken (pre-verify) or a Bearer teamToken
(post-verify); unauthenticated probes get 401 AUTH_REQUIRED.
Cross-system mapping — `external_org_id`, `external_user_id`
POST /api/orgs/register accepts external_org_id and external_user_id
(both opaque strings, ≤128 chars). They're stored on the BD-side org row
and echoed in the register response and in GET /api/orgs/me. Use them
to maintain a permanent two-way mapping between your system's
Organisation/User ids and BD's orgId. Both fields also appear in the
org_created audit-log row so the cross-system reconciliation is part
of the hash chain.
Snake_case (external_org_id) and camelCase (externalOrgId) are both
accepted in the request body.
Idempotency — `Idempotency-Key` header
POST /api/orgs/register honors the standard Idempotency-Key header.
Send a fresh UUID per logical register attempt; if the same key arrives
again within the retention window, BD replays the original response
rather than creating a second pending org. Use to make retries safe
against flaky networks or worker restarts.
Constraints: 8–128 chars, alphanumerics + . _ - : only. Missing
header → endpoint runs without caching (idempotency disabled).
Self-service token rotation
POST /api/orgs/me/tokens/rotate (Bearer auth) revokes the org's
current token and mints a fresh one. Response: { token, rotatedAt }.
Also fires a token.rotated lifecycle webhook to the org's
webhook_url (if set), payload includes the new token so the
integrator can update its stored value automatically.
Dev/test convenience — `BD_TEST_PHONES` env
For testing loops where the operator has only one real phone and
wants to register many orgs against it. Set the env var to a
comma-separated list of E.164 numbers; F02 phone-uniqueness is
bypassed for any of them.
# /opt/butt-dial/packages/server/.env
BD_TEST_PHONES=+972526557547,+15551234567
Every bypass logs org_register_phone_collision_bypassed_via_whitelist
at WARN so prod misuse is loud. Numbers not on the whitelist
continue to enforce F02 as normal.
This bypass is independent of force: true — works for anonymous
register calls too. Don't set it on prod-prod; intended for staging /
dev environments only.
Dev/test convenience — `"force": true` on register
Super-admin callers can pass "force": true in the register body.
When set, BD auto-abandons any colliding pending org (email
match OR phone match) before proceeding. Lets you spin up many
fresh test orgs from one real email + phone without manually
calling /api/orgs/abandon between attempts.
Refuses to auto-abandon active orgs — those still throw
409 EMAIL_ALREADY_REGISTERED so a real customer's data is never
destroyed by a typo.
POST /api/orgs/register
Authorization: Bearer <super-admin>
Content-Type: application/json
{
"orgName": "Test #5",
"ownerEmail": "you+test5@example.com",
"ownerPhone": "+15551234567",
"force": true // ← auto-abandon any colliding pending org
}
Audit logs the abandon as org_register_force_abandoned_pending_email
or ..._pending_phone with the previous orgId.
Anonymous callers can't set this flag — force is silently ignored
unless superAdminMode is also true (verified via Bearer token).
Abandon a stuck signup — `POST /api/orgs/abandon`
When an integrator's user retries a signup after a previous attempt
left a pending org holding the unique resources (phone, email),
the integrator hits PHONE_ALREADY_REGISTERED or EMAIL_PENDING
on the retry. To unstick:
POST /api/orgs/abandon
Authorization: Bearer <super-admin OR pollToken>
Content-Type: application/json
{ "externalOrgId": "<integrator-side org id>" } // OR
{ "orgId": "<bd-side org uuid>" }
Two accepted credentials:
Bearer— operator path (BD-internal). Headers
Bearer— integrator path. The pollToken is the value
Persist it alongside the BD org id on your side; it is single-org-scoped
and auto-cleared on /api/orgs/verify, so it can only authenticate
abandon for pre-verified orgs — exactly the scope this endpoint allows.
Required headers on the integrator path (matches /reveal-team-token
for one signing pattern):
X-Request-Timestamp: <epoch seconds, ±60s of BD's clock>
X-Request-Nonce: <unique string, never re-used within 5 min>
Without them you get 401 AUTH_REPLAY_HEADERS_REQUIRED. Reusing a
nonce within 5 min returns 401 AUTH_NONCE_REPLAY.
Effects: status = 'deleted', revokes tokens, consumes pending
verification links. The phone + email become invisible to F02/F04
collision checks, so the next register attempt succeeds.
Refuses with 409 ORG_ACTIVE_USE_DEACTIVATION if the target is
status='active' — for active orgs use the proper deactivation
flow (which notifies members etc.).
Idempotent on already-deleted orgs ({ ok: true, noop: true }).
Audit-logged as org_abandoned with `{ fromStatus, externalOrgId,
orgName, reason }` in the details.
Recommended integrator retry pattern:
1. POST /api/orgs/register → 409 EMAIL_PENDING or PHONE_ALREADY_REGISTERED
(response carries existingOrgId/collidingOrgId
when caller is super-admin)
- integrator checks: is the colliding orgId one I created previously
AND is my side's onboarding state != ACTIVE?
- yes → POST /api/orgs/abandon { externalOrgId: <my-side-org-id> }
- retry /api/orgs/register → succeeds
Security
- Passwords hashed with bcrypt (cost factor 12)
- Registration rate-limited (5 attempts per IP per 15 minutes)
- Email verification required before API access
- Tokens stored as SHA-256 hashes, never plaintext
pollTokenis single-purpose, scoped to one orgId, cleared on verifywebhook_secretdoubles as integrator auth for/reveal-team-token
Recover a missed account.verified webhook — `POST /api/orgs/:orgId/reveal-team-token`
When BD has verified an org but the integrator missed the
account.verified webhook (Cloudflare 521, signature mismatch,
delivery retry exhausted), the integrator can recover the org's
team_token using the per-org webhook secret. No super-admin needed.
POST /api/orgs/<bd-org-id>/reveal-team-token
Authorization: Bearer <webhook_secret> # OR
X-BD-Webhook-Secret: <webhook_secret>
X-Request-Timestamp: <epoch seconds, ±60s>
X-Request-Nonce: <unique string, never re-used within 5 min>
Response:
{ "token": "...", "rotated": false, "orgId": "...", "orgName": "..." }
The webhook_secret is returned exactly once in the /api/orgs/register
response. Persist it on the integrator's Organisation row; it stays valid
for the life of the org and is also what signs inbound webhooks.
Replay defense: the timestamp must be within ±60s of BD's clock and
the nonce is one-shot (cached server-side for 5 minutes). Replaying
the exact same headers returns AUTH_NONCE_REPLAY.
Audit-logged as org_token_revealed with actor=integrator:webhookSecret:.