Phuze

API Reference

Developer Docs · Core Concepts · Agent skill: phuze-skill.md

Base URL: https://phuze.edato.me/api/v1. All bodies are JSON. Auth schemes (Publishable / Secret / CLI) are described on the main docs page. Errors use { "error": { "code", "message" } } — see Errors.

Never validate a response with a closed/strict schema (additionalProperties: false, zod .strict(), Pydantic extra="forbid") — new fields may be added. Destructure the fields you need.

Client Management

POST /clients — Register

No auth. Registers a client and emails a 6-digit activation code. name: 1–80 chars; domains: 1–5 valid domains.

POST /api/v1/clients
{ "name": "My App", "email": "you@example.com", "domains": ["myapp.com"],
  "invite_code": "XXXX-XXXX-XXXX" }   // invite_code required unless your email is whitelisted

→ 201 { "client_id": "cl_...", "status": "pending", "message": "Check your email for an activation code." }

Errors: 400 invalid name/email/domains or undeliverable email domain · 403 alpha gate (email not approved and no valid invite code) · 429 per-email client limit reached ({ limit, count }).

POST /clients/activate — Activate

No auth. Exchanges the emailed code for your keys. Returned once.

POST /api/v1/clients/activate
{ "email": "you@example.com", "code": "123456" }

→ 200 {
  "client_id": "cl_...",
  "publishable_key": "pk_live_...",
  "secret_key": "sk_live_...",
  "cli_token": "cli_...",
  "warning": "Store the secret_key and cli_token securely — they will not be shown again."
}

Errors: 404 no pending activation · 400 code already used, expired, or invalid (repeated wrong codes invalidate it — re-register).

GET /clients/me — Client info

Auth CLI or Secret. Returns your client record (never the secret-key hash/prefix).

→ 200 {
  "client_id": "cl_...", "name": "My App", "email": "you@example.com",
  "authorized_domains": ["myapp.com"], "publishable_key": "pk_live_...",
  "status": "active", "type": "general", "plan": "free",
  "from_name": "My App",            // present only if set
  "created_at": "...", "activated_at": "..."
}

PATCH /clients/me — Update

Auth CLI. Any of name (1–80), domains (1–5), from_name. from_name is 1–40 chars (control characters and <>"@ stripped); null clears it; settable on any plan, shown on emails for Builder+.

PATCH /api/v1/clients/me
{ "domains": ["myapp.com", "www.myapp.com"], "from_name": "My App" }

→ 200 { "updated": true }

POST /clients/me/keys/rotate — Rotate secret key

Auth CLI. Issues a new secret_key and invalidates the old one immediately. Rate-limited; owner is emailed.

→ 200 { "secret_key": "sk_live_...",
        "warning": "Store the new secret_key securely — it will not be shown again. The previous key is now invalid." }

GET /clients/me/quota — Quota usage

Auth CLI or Secret. Live effective limits + current-period counters. See Core Concepts → Quotas.

→ 200 {
  "plan": "free",
  "limits": { "mau": 50000, "logins_per_day": 100, "logins_hard_cap_per_day": 100 },
  "current_period": {
    "start": "...", "mau_count": 12, "daily_login_count": 3,
    "daily_reset_at": "...", "overage_count": 0
  },
  "test_accounts": { "limit": 1, "used": 1, "entries": [ { "email": "...", "expires_at": "..." } ] }
}

GET /clients/me/users — List your users

Auth CLI or Secret. Paginated: ?limit=1..25&cursor=<uid>; follow next_cursor until null. last_active_at is the user's last login (the signal behind billing MAU / mau_current). Client-scoped — never reveals a user's other Phuze clients.

→ 200 {
  "users": [ {
    "uid": "u_...", "email": "user@example.com",
    "first_seen_at": "...", "last_active_at": "...", "login_count": 4, "mau_current": true,
    "tags": [ { "tag": "pro", "assigned_at": "...", "issued_by": { "type": "client", "id": "cl_..." } } ]
  } ],
  "next_cursor": null,
  "mau": { "count": 12, "limit": 50000 }
}

Test accounts — POST / DELETE /clients/me/test-accounts

Auth CLI (secret key is rejected here by design). Declares an address of your own for relaxed send limits — 10/min · 60/hr vs the default 1/min · 5/hr — for 14 days (re-adding resets the clock). Plan cap: free 1 · builder 5. Adding an address emails its owner (audited).

POST /api/v1/clients/me/test-accounts        Authorization: CLI cli_...
{ "email": "dev@yourapp.com" }
→ 200 { "email": "dev@yourapp.com", "expires_at": "...", "refreshed": false,
        "test_accounts": { "limit": 1, "used": 1 } }
// at the plan cap: 429 quota_exceeded { limit, used }

DELETE /api/v1/clients/me/test-accounts/dev%40yourapp.com    Authorization: CLI cli_...
→ 200

CLI tokens — GET / POST /clients/me/cli-tokens, DELETE /clients/me/cli-tokens/:prefix

Auth CLI. Manage the CLI tokens for your client. Issuing and revoking are rate-limited, audited, and email the owner. Rotation = issue a new token, then revoke the old one.

GET /api/v1/clients/me/cli-tokens
→ 200 { "cli_tokens": [ { "prefix": "cli_abc123...", "created_at": "...", "last_used_at": "..." } ] }
        // metadata only — token values are never returned; revoked tokens are omitted

POST /api/v1/clients/me/cli-tokens
→ 200 { "cli_token": "cli_...", "prefix": "cli_...",
        "warning": "Store this CLI token securely — it will not be shown again." }

DELETE /api/v1/clients/me/cli-tokens/cli_abc123...
→ 200 { "revoked": true, "prefix": "cli_abc123..." }
        // 404 if no token with that prefix; revocation is enforced in real time

POST /clients/me/dashboard-link — One-time dashboard link

Auth CLI. Mints a one-time URL that opens a logged-in dashboard for this client. Short-lived (60s) and single-use — open it on your own machine. Rate-limited.

→ 200 { "url": "https://phuze.edato.me/auth/handoff?token=...", "expires_in": 60 }

POST /feedback — Feedback & feature interest

Auth CLI or Secret. topic is "custom_domains" or "other"; message (≤2000 chars) is required for other, optional for custom_domains. Lightly rate-limited.

POST /api/v1/feedback
{ "topic": "custom_domains", "message": "Would love to send from auth.myapp.com" }
→ 201 { "message": "Thanks — recorded." }

⚠ Dangerous — Client Account Deletion

Deleting a client is permanent and irreversible. All sessions, CLI tokens, quota records, and the client itself are deleted. Your end-users will no longer be able to sign in via this client. Two steps, both unauthenticated (guarded by an emailed code).

POST /api/v1/clients/me/delete-request
{ "email": "owner@example.com" }
→ 200 { "message": "If a client is registered to this email, a deletion code has been sent to its owner. Run delete-confirm with the code." }
        // always 200 (enumeration-safe) — a code is sent only if the email owns a client

POST /api/v1/clients/me/delete-confirm
{ "email": "owner@example.com", "code": "123456" }
→ 200 { "deleted": true, "client_id": "cl_..." }
        // 400 if the code is wrong, used, or expired

Auth Flow

POST /auth/magic-link — Send a sign-in email

Auth Publishable + Origin. Only email is required. pending_id enables the no-widget session handoff (see Custom login flow); redirect_url must be an authorized domain; locale sets the email + verify-page language.

POST /api/v1/auth/magic-link      Authorization: Publishable pk_live_...   Origin: https://myapp.com
{ "email": "user@example.com",
  "pending_id": "0f9a...-uuid-v4",              // optional — enables cross-browser pickup
  "redirect_url": "https://myapp.com/welcome",   // optional — must be a registered domain
  "locale": "en" }                               // optional

→ 200 { "message": "Check your email for a sign-in link." }

Always returns 200, even for an unregistered email (no account enumeration). The magic link expires after 15 minutes and is single-use.

Errors: 403 forbidden — origin not authorized, or the client account is not active · 429 quota_exceeded — daily login cap reached (Free: at quota; paid: at the hard cap) · 429 rate_limited — send throttle hit.

Rate limits (abuse-tuned): 1/min · 5/hr per target email, 3/min · 20/hr per requesting IP. Every rate_limited body carries retry_after_seconds (plus a standard Retry-After header) and an optional machine-readable reason — branch on reason, never the message text:

reasonMeaningWhat to do
test_account_limitTest-account preset (10/min · 60/hr) hit for a declared addressBack off retry_after_seconds
test_account_expired (+ expires_at)The address's 14-day test grant lapsedRe-POST /clients/me/test-accounts to refresh, then retry
not_test_accountYou have test accounts, but this address isn't oneDeclare the address, or accept default limits
(absent)Ordinary rate limitBack off retry_after_seconds

GET /auth/verify — the emailed link (a page, not an API call)

Auth . This is the URL inside the sign-in email (phuze.edato.me/auth/verify?token=&client_id=) — a browser page, not under /api/v1, and you never request it yourself. It verifies the token, creates the session, and shows a success page with a "Return to app" button linking to redirect_url (no auto-redirect).

Sessions

POST /sessions/verify — Verify a session token

Auth Secret (server-to-server). The primary call your backend makes.

POST /api/v1/sessions/verify      Authorization: Secret sk_live_...
{ "token": "st_..." }

→ 200 {
  "valid": true, "uid": "u_...", "email": "user@example.com",
  "session_id": "s_...", "client_id": "cl_...",
  "created_at": "...", "expires_at": "...", "type": "user"
}

Field notes: email is optional (absent in rare cases, e.g. the user record no longer exists); every other field is present when valid is true. client_id is always your own client. type is always "user" today. When the token is not valid:

→ 200 { "valid": false, "reason": "expired" }   // reason: expired | revoked | not_found

POST /sessions/revoke — Revoke a session

Auth Secret. Force-logout from your backend (account deletion, suspicious activity). Idempotent.

POST /api/v1/sessions/revoke      Authorization: Secret sk_live_...
{ "token": "st_..." }
→ 200 { "revoked": true }

Custom login flow — without the widget

Only for a fully custom login UI. You replicate what the widget does internally: request a magic link with a pending_id, then poll for the session after the user clicks the link.

⚠ Security invariants — the widget enforces these; your flow must too

Generate pending_id with crypto.randomUUID(), never Math.random() — it's the guessable key to an in-flight session. All calls require a registered Origin + HTTPS. Pickup is one-time within a ~5-minute window after the click. Treat the returned session_token like a session cookie.

// 1. Request the link with a random handoff id
POST /api/v1/auth/magic-link      Authorization: Publishable pk_live_...   Origin: https://myapp.com
{ "email": "user@example.com", "pending_id": "<crypto.randomUUID()>" }

// 2. Poll pickup ~every 2s (up to ~10 min). Publishable + Origin.
GET /api/v1/sessions/pickup?pending_id=<same-id>
→ { "ready": false }                                              // until the link is clicked
→ { "ready": true, "session_token": "st_...", "uid": "u_...",     // then once (one-time)
    "email": "...", "expires_at": "..." }
// polling faster than ~2s from one IP → 429 rate_limited { retry_after_seconds: 1 }

// 3. Store session_token, then verify it on your backend (POST /sessions/verify).

Two more Publishable + Origin helpers for a stored token (both via the X-Session-Token header):

GET  /api/v1/sessions/current    → { "active": true, "uid", "email", "expires_at" }  or  { "active": false }
POST /api/v1/sessions/logout     → { "logged_out": true }

The device that clicks the link gets the session. Without pending_id the user is still logged in on whatever device clicks, but there is no way to hand the session back to your original tab.

Users

GET /users/:uid — Look up a user by UID

Auth Secret. Client-scoped: 404 if the user has not logged in through your client (no cross-tenant lookup).

→ 200 {
  "uid": "u_...", "email": "user@example.com",
  "created_at": "...", "email_verified_at": "...",
  "first_seen_at": "...", "last_active_at": "...", "login_count": 4, "mau_current": true,
  "tags": [ { "tag": "pro", "assigned_at": "...", "issued_by": { "type": "client", "id": "cl_..." } } ]
}

The first_seen_at / last_active_at / login_count / mau_current / tags fields are the same client-scoped data as a user-list row (last_active_at = last login).

GET /users/by-email/:email — Look up a user by email

Auth Secret. Same response as above. 404 if the email has not logged in through your client.

POST /users/me/delete-request — End-user self-deletion (trusted clients only)

Auth Publishable + Origin + X-Session-Token. Gated by client type — only trusted clients may call it; general clients get 403 forbidden. Emails the user a confirmation link; clicking it permanently deletes their account and revokes all sessions.

POST /api/v1/users/me/delete-request
  Authorization: Publishable pk_live_...   Origin: https://myapp.com   X-Session-Token: st_...
→ 200 { "message": "Check your email to confirm account deletion." }

Tags

String labels you attach to your own users — tiers, roles, feature flags, cohorts. Phuze stores and serves them; your app owns the gating logic. Names match [a-z0-9-]{1,64}, up to 64 per user, scoped to your client (never visible to other clients). All three calls use Secret auth and return 404 if the uid was never served by your client.

POST   /api/v1/users/:uid/tags        { "tag": "premium" }   → 200 { "tag": "premium", "uid": "u_..." }   // idempotent
GET    /api/v1/users/:uid/tags                               → 200 { "uid": "u_...", "tags": ["beta-tester", "premium"] }   // sorted
DELETE /api/v1/users/:uid/tags/:tag                          → 204   // idempotent, whether or not the tag was present

Errors

{ "error": { "code": "unauthorized", "message": "Missing Authorization header" } }

Codes: unauthorized · forbidden · not_found · rate_limited · quota_exceeded · invalid_request · server_error.

429 rate_limited bodies carry retry_after_seconds (plus a Retry-After header) and an optional reason — branch on those, never the message text (see magic-link rate limits above). Re-sending to your own address while developing? Declare it a test account instead of fighting the throttle.

Phuze · phuze.edato.me · Developer Docs · Core Concepts