Overview¶
Note
AI Context
Complexity: Low to Medium (unregister/delegate carry authorization and lifecycle side effects)
Cost: Free. No auth endpoint incurs platform billing.
Async: No. All auth operations are synchronous, except that signup/password-forgot/unregister trigger emails sent out-of-band.
All endpoints in this group are served directly at /auth/<name> — not under the /v1.0 prefix used by every other resource in the API. A generated stub exists for each path under /v1.0/auth/... that always returns 404 ROUTE_NOT_FOUND with a message pointing at the correct un-prefixed path; this is intentional routing scaffolding, not a bug.
Endpoint Summary¶
Method |
Path |
Auth |
Purpose |
|---|---|---|---|
POST |
|
None |
Self-service customer registration. |
POST |
|
None |
Verify the signup email token; provisions the first access key. |
GET |
|
None |
Serves an HTML page that auto-submits the verification token (link target from the verification email). |
POST |
|
None |
Send a fresh verification link to an address whose original link expired or was lost. |
POST |
|
Username/password |
Exchange agent credentials for a 7-day JWT. See Authentication quickstart. |
POST |
|
None (direct hash) |
Exchange a direct hash for a 4-hour resource-scoped JWT. |
POST |
|
Direct token |
Reissue a direct token without changing which resource it is bound to. |
POST |
|
None |
Request a password reset email. |
GET |
|
None |
Serves the HTML password reset form (link target from the reset email). |
POST |
|
None (reset token) |
Complete the password reset using the emailed token. |
POST |
|
Token or Accesskey |
Freeze the caller’s own account and schedule (or immediately execute) deletion. |
DELETE |
|
Token or Accesskey |
Cancel a scheduled deletion and restore the account to |
POST |
|
Token (ProjectSuperAdmin) |
Issue a short-lived support/investigation token scoped to another customer. See Authentication quickstart. |
Note
AI Implementation Hint
All unauthenticated /auth/* endpoints (login, signup, email-verify, email-verify-resend, boot, password-forgot, password-reset) share a single IP-based rate limiter: up to approximately 10 requests/second per client IP, burst 20. /auth/unregister and /auth/delegate require authentication first, then are subject to their own, separately-tracked IP-based limiter at the same approximate rate (10 requests/second, burst 20). Exceeding either limiter returns 429 with reason RATE_LIMIT_EXCEEDED (see Error Reason Codes).
/auth/email-verify-resend carries an additional per-account limit on top of that shared IP tier: a 60-second cooldown between sends and a cap of 5 sends per 24 hours, both tracked per customer account rather than per IP. The IP tier alone cannot stop a distributed mailbomb aimed at one address. Requests that exceed the per-account limits still return 200 with an empty body — no email is sent, and nothing in the response distinguishes that outcome.
Note
AI Implementation Hint — response body shape differs by endpoint
/auth/unregister and /auth/delegate sit behind the shared Authenticate() middleware, so authentication/authorization failures at that layer (missing token, expired token, frozen account) return the standard structured error envelope described in Error Reason Codes ({"error": {"status", "reason", "message", "request_id", ...}}). However, validation failures raised inside the auth handlers themselves — e.g. a malformed JSON body on any endpoint, a wrong password on /auth/unregister, an invalid confirmation_phrase, a bad accepted_tos on signup — return a bare HTTP status code (400) with an empty response body, not the JSON error envelope. Do not assume error.reason is present on every 4xx from this group; check for an empty body first.
Auth & Account Lifecycle¶
signup email-verify (set password) login / boot
+----------+ +--------------+ +----------------+ +--------------+
| initial |-->| active |--->| active |--->| JWT / key |
+----------+ +--------------+ +----------------+ +--------------+
| ^ |
| no | POST /auth/email- | POST /auth/unregister
| verify | verify-resend |
| within | then email-verify v
v | +----------------+
+----------+ | | frozen |
| expired |----+ | (30-day grace) |
+----------+ +----------------+
| |
DELETE /auth/unregister (grace expires,
| or immediate:true)
v v
+----------------+ +----------------+
| active | | deleted |
+----------------+ +----------------+
For the full customer status lifecycle (including what gets cascade-deleted), see Customer — Account Deletion Lifecycle.
Signup — POST /auth/signup¶
Creates an unverified customer account and triggers a verification email. See the Signup quickstart for a walkthrough.
Request body
Field |
Type |
Description |
|---|---|---|
|
String, Required |
Email address for the new account. Must be unique across all customers. |
|
Boolean, Required |
Must be |
|
String, Optional |
Display name for the customer account. |
|
String, Optional |
Additional description. |
|
String, Optional |
Contact phone number (E.164 format recommended). |
|
String, Optional |
Mailing address. |
|
String, Optional |
HTTP method for webhook delivery: |
|
String, Optional |
URI where webhook events will be delivered. |
Response — 200 OK (success)
{
"customer": { "id": "...", "email": "...", "status": "initial", ... },
"accesskey": { "id": "...", "token": "vb_...", "name": "...", ... }
}
Response — 200 OK (silent failure, e.g. duplicate email)
{}
Note
AI Implementation Hint
Signup always returns HTTP 200, even on failure, to prevent email-enumeration attacks — a duplicate or invalid email produces an empty {} body rather than an error. The only true 400 is a structurally invalid request (missing email, missing/false accepted_tos, or malformed JSON). The returned accesskey.token is usable immediately; no separate POST /auth/login call is required. Signup also auto-provisions an empty OutboundConfig (blocking all outbound PSTN calls until explicitly configured) and auto-rolls back the entire signup (deletes the customer) if that provisioning step permanently fails.
Errors
Status |
Cause |
|---|---|
400 |
|
Email Verify — POST / GET /auth/email-verify¶
Validates the verification token from the signup email, marks the customer’s email as verified, and transitions the account from initial to active.
POST request body
Field |
Type |
Description |
|---|---|---|
|
String, Required |
64-character lowercase hex verification token from the signup email. |
Response — 200 OK
{
"customer": { "id": "...", "email": "...", "email_verified": true, "status": "active", ... }
}
Errors
Status |
Cause |
|---|---|
400 |
|
GET /auth/email-verify
Serves a static HTML confirmation page (the link target embedded in the verification email). The page reads token from the query string and, when the visitor clicks “Verify Email”, submits it to POST /auth/email-verify via client-side JavaScript. This is a convenience UI for humans following the email link — API integrations should call POST /auth/email-verify directly with the token extracted from the email/webhook.
Parameter |
Location |
Description |
|---|---|---|
|
Query, Required |
64-character lowercase hex string. An invalid/missing token returns |
If the token has expired or was already used, the page reports that the link is no longer valid and offers a form to request a new one, which submits to POST /auth/email-verify-resend.
Email Verify Resend — POST /auth/email-verify-resend¶
Issues a new verification token for an account whose original link expired or was never received, and emails it to the registered address. This is the recovery path out of the expired status: an account that missed the 72-hour verification window is not permanently lost.
Request body
Field |
Type |
Description |
|---|---|---|
|
String, Required |
The email address the account was registered with. |
Response — 200 OK (always)
{}
Note
AI Implementation Hint
This endpoint always returns 200 with an empty body — for a registered address, an unknown address, an already-verified account, a rate-limited request, and even a malformed JSON body. That uniformity is deliberate: any differentiated response would turn an unauthenticated endpoint into an email-existence oracle. Do not treat 200 as confirmation that an email was sent, and do not build retry logic that assumes otherwise. Note that the uniformity covers the response content, not its timing: the work is synchronous, so an unknown address returns after a single database lookup while a recoverable one also performs an agent lookup, two cache writes and an email send, leaving a measurable latency difference. This residual is not specific to this endpoint: /auth/signup and /auth/password-forgot are synchronous and always-200 in the same way, and carry the same timing residual.
A resend is only performed when the account is unverified, is not frozen or deleted, and still has a live agent for the address. Sends are limited to one per 60 seconds and 5 per 24 hours per account, on top of the shared per-IP /auth/* limiter.
The new token is valid for 24 hours, the same as a token issued at signup. Resending does not invalidate an earlier token — any previously issued token stays usable until its own expiry, so a user who later finds the original email can still follow it.
Login (Token) — POST /auth/login¶
Exchanges an agent’s username/password for a JWT valid for 7 days. Fully documented with request/response examples in the Authentication quickstart; summarized here for completeness.
Field |
Type |
Description |
|---|---|---|
|
String, Required |
The agent’s username. |
|
String, Required |
The agent’s password. |
Response: {"username": "...", "token": "eyJ..."}. The token is also set as an HttpOnly, Secure, SameSite=Strict cookie named token on the response.
Errors
Status |
Cause |
|---|---|
400 |
Missing fields, malformed JSON, or invalid credentials. Credential failures return a bare 400 with no response body — a wrong password is deliberately not distinguished from an unknown username, to avoid a username-enumeration oracle. A failure to look up the account’s status (see below) also returns this same bare |
403 |
The credentials were correct, but the account’s status forbids issuing a token: |
Note
AI Implementation Hint — login can now fail on account status
POST /auth/login verifies the password first and only then checks the customer’s account status, so the status check never becomes an enumeration oracle. The status check is a deny-list: only expired and deleted are refused. initial (a normal user inside the 72-hour post-signup verification window) and frozen both still log in successfully — frozen in particular must keep working, because the frozen self-recovery endpoint DELETE /auth/unregister requires authentication, so blocking a frozen login would remove the only route to recovery.
The account-status lookup fails closed: if the customer lookup itself fails, no token is issued (returned as the same opaque 400, since it is not a determination about the account). This is deliberately the opposite of the /v1.0/* gate, which fails open — an outage must not become a window in which credentials can be minted freely.
Boot (Direct Token) — POST /auth/boot¶
Resolves a direct hash into a short-lived, resource-scoped JWT, for use cases where the end user has no VoIPBin account (e.g. an embedded AI voice widget on a customer’s public website). Also documented in the Authentication quickstart.
Request body
Field |
Type |
Description |
|---|---|---|
|
String, Required |
The direct hash link, e.g. |
Response — 200 OK
Field |
Type |
Description |
|---|---|---|
|
String |
JWT for API/WebSocket use. Pass as |
|
String |
Always |
|
String |
The resource type this token is scoped to (e.g. |
|
UUID |
The scoped resource’s ID. |
|
UUID |
The owning customer’s ID. |
|
String (ISO 8601) |
Token expiry. Valid for 4 hours from issuance. |
|
UUID |
The single resource this token may act on, assigned at boot before the resource exists. The resource you create with this token takes this ID. You do not need to send it anywhere: the server takes the target from the token, so two visitors of the same public link cannot reach each other’s conversation. |
|
Integer |
Version of the token’s scope contract. If it is raised, every outstanding token becomes invalid at once and clients must boot again. |
|
Object, Optional |
Present only for resource types with a registered public-display fetcher (currently |
What a direct token can and cannot do
A direct token is bound to exactly one resource: the allowed_resource_id above.
Creating a resource (
POST /aicalls,POST /webchat_sessions) produces a resource with that ID. A second creation attempt with the same token returns409; boot again for a new assignment.Requests that name a resource in the path (e.g.
DELETE /aicalls/{id}) are refused with403unless the ID matches the assignment.Requests that name a resource in the body or query (e.g.
POST /aimessages) ignore the value you send and use the assignment instead.Listing (
GET /aicalls,GET /webchat_sessions) is not available to direct tokens.WebSocket topics of the form
customer_id:<customer_id>:<resource_type>:<resource_id>are accepted only when<resource_id>is exactly the assignment, written as a canonical lower-case UUID. A trailing colon or a partial ID is refused.
Errors
Status |
Cause |
|---|---|
400 |
|
Note
AI Implementation Hint
Boot tokens are scoped: they only grant access to the resource types listed in the token (e.g. aicall for an ai/ai_team direct hash, webchat_session for a webchat_widget direct hash). For WebSocket subscriptions, boot tokens may only subscribe to 4-part topics (customer_id:<uuid>:<resource_type>:<resource_id>); broader topics are rejected.
Boot refresh (Direct Token) — POST /auth/boot/refresh¶
Reissues a direct token while keeping the same allowed_resource_id, so a conversation in progress survives the 4-hour token expiry. Call it before the token expires; the widget SDKs do this automatically.
Unlike POST /auth/boot this endpoint is authenticated: present the current direct token as Bearer <token>. The assignment is copied from that token and is never read from the request body, so a caller cannot name a resource it does not already hold.
Request body
None.
Response — 200 OK
Same shape as POST /auth/boot, except resource_data is omitted (the client already holds it from the original boot). allowed_resource_id and scope_version are unchanged; only expire moves.
Refreshing does not extend a session indefinitely
Each boot fixes an absolute ceiling and every reissued token inherits it unchanged, so refreshing cannot walk it forward. A refresh is also refused, with 401, when any of the following became true since the original boot:
the boot session’s absolute lifetime has elapsed,
the direct link was deleted, or its hash was regenerated (this is how you revoke a leaked public link),
the owning customer is no longer active,
the token predates resource binding and carries no assignment.
Errors
Status |
Meaning |
|---|---|
|
The token can no longer be refreshed. Boot again. |
|
The presented token is not a direct token. |
|
The server could not issue a token. Retrying the refresh may succeed; booting again will not help. |
Password Forgot — POST /auth/password-forgot¶
Generates a password reset token and emails a reset link to the agent, valid for 1 hour.
Request body
Field |
Type |
Description |
|---|---|---|
|
String, Required |
The agent’s username (email address). |
Response — 200 OK: always {}, regardless of whether the username exists, to prevent username-enumeration attacks. The underlying call to bin-agent-manager is made best-effort; a lookup failure is logged but never surfaced to the caller.
Errors
Status |
Cause |
|---|---|
400 |
|
Password Reset — GET / POST /auth/password-reset¶
Completes a password reset using the token emailed by POST /auth/password-forgot.
GET /auth/password-reset — serves the HTML reset form (the link target in the reset email).
Parameter |
Location |
Description |
|---|---|---|
|
Query, Required |
64-character lowercase hex string. An invalid/missing token returns |
POST /auth/password-reset — request body
Field |
Type |
Description |
|---|---|---|
|
String, Required |
64-character lowercase hex reset token from the email. Single-use, expires 1 hour after issuance. |
|
String, Required |
New password. Minimum 8 characters. |
Response — 200 OK: {}.
Errors
Status |
Cause |
|---|---|
400 |
|
Unregister (Self-Service Deletion) — POST / DELETE /auth/unregister¶
Self-service account freeze/deletion and recovery. Requires authentication (Token or Accesskey) and PermissionCustomerAdmin on the caller’s own customer account; direct (boot) tokens cannot call this endpoint. A condensed narrative version of this section, including the full status-lifecycle diagram and the cascade-delete resource list, lives in Customer — Account Deletion Lifecycle; this section is the field-level contract.
POST /auth/unregister — request body
Field |
Type |
Description |
|---|---|---|
|
String, Conditional |
Re-authentication password for password-based accounts. Mutually exclusive with |
|
String, Conditional |
Must be exactly |
|
Boolean, Optional |
Default |
Query parameters (both POST and DELETE)
Parameter |
Location |
Description |
|---|---|---|
|
Query, Optional |
Access-key token, as an alternative to a |
Response — 200 OK: the updated Customer object (see Customer), reflecting status: "frozen" (or "deleted" if immediate was true).
Errors
Status |
Cause |
|---|---|
400 |
Neither or both of |
401 |
Missing/invalid/expired token or access key (returned by the shared |
403 |
The account’s customer status is |
500 |
The customer lookup performed during password re-authentication failed for any other reason (RPC timeout, or the customer-manager circuit breaker being open). That lookup fails closed, unlike the |
Note
403 and 500 are reachable only through the password branch, which re-authenticates by calling AuthLogin; before VOIP-1491 that branch could only ever produce a 400. The confirmation_phrase branch does not re-authenticate and is unaffected.
DELETE /auth/unregister — cancels a scheduled deletion and restores active status. No request body. Only works while the account is frozen within the 30-day grace period.
Response — 200 OK: the restored Customer object.
Errors
Status |
Cause |
|---|---|
400 |
Account is not currently in |
401 |
Missing/invalid/expired token or access key. |
Note
AI Implementation Hint — frozen and deleted accounts are also enforced globally
Once a customer account is frozen, every other authenticated /v1.0/* request from that customer (except from a PermissionProjectSuperAdmin, and except direct/boot tokens, which skip the check entirely) is rejected with 403 ACCOUNT_FROZEN by the shared authentication middleware — not just calls to resource endpoints that would otherwise mutate data. The error’s details[0] carries deletion_scheduled_at, deletion_effective_at (30 days after scheduling), and recovery_endpoint: "DELETE /auth/unregister" so client UIs (admin/talk consoles) can render a consistent “account frozen, recover here” screen. POST and DELETE /auth/unregister themselves are explicitly exempted from this block so a frozen customer can still self-recover.
The same middleware also rejects any authenticated request from a deleted customer with 403 ACCOUNT_DELETED. In the steady state this is unreachable — a deleted customer’s agents/access keys should already have been soft-deleted by the customer_deleted cascade and fail earlier at authentication — but this is a deliberate second layer in case that cascade misses a resource, so credentials belonging to a deleted account can never keep working indefinitely. Unlike frozen, deleted is not recoverable via /auth/unregister.
An expired customer — signup completed but the email address was never verified, so the unverified-account cleanup job moved the account out of initial — is rejected the same way, with 403 ACCOUNT_EXPIRED. Its details[0] carries recovery_endpoint: "POST /auth/email-verify-resend", so a client can point the user at a new verification email without string-matching the message. initial is not blocked: it is the normal state during the 72-hour verification window. Note that requesting a resend requires the account to still have a live agent for the address; if it does not, direct the user to support@voipbin.net rather than telling them to sign up again, because an account expired under the current cleanup behaviour keeps its customer row live and will fail the signup duplicate-email check.
The gate’s customer lookup fails open: if the customer record cannot be fetched, the request is allowed through rather than blocked, because api-manager holds no customer cache and failing closed would take every /v1.0/* route down whenever customer-manager is unreachable. Occurrences are counted by the api_manager_account_status_lookup_failed_total metric, labelled by identity_type and error_class. POST /auth/login deliberately does the opposite and fails closed — see the login section above.
Delegate (Superadmin Support Access) — POST /auth/delegate¶
Lets a platform superadmin (PermissionProjectSuperAdmin) issue a short-lived, audit-logged token that grants PermissionCustomerAdmin-equivalent access scoped to a specific target customer, for support/investigation without needing that customer’s own credentials. Fully documented with request/response examples in the Authentication quickstart; summarized here for completeness.
Request body
Field |
Type |
Description |
|---|---|---|
|
String (UUID), Required |
Target customer to act on behalf of. |
|
String, Required |
Justification, written to the audit log. Must be 10-200 printable-ASCII characters (0x20-0x7E), no control characters. |
Response — 200 OK
{
"token": "eyJ...",
"customer_id": "...",
"expire": "2026-05-19T06:00:00.000000Z"
}
The delegate token is valid for 8 hours and grants customer-admin-equivalent access scoped only to customer_id — no project-level permissions are carried over.
Errors
Status |
Cause |
|---|---|
400 |
Malformed or missing request body. |
401 |
Caller is not authenticated. |
403 |
Caller lacks |
404 |
Target customer does not exist or is already |
422 |
|
Note
AI Implementation Hint
Every delegate token issuance (and denial) is written to the audit log with audit=true, the issuer’s agent ID, target customer, reason, and expiry — this is the audit trail for superadmin account access. Do not build tooling that calls this endpoint without a genuine, specific support reason; the reason field is not free-form filler, it is the compliance record.