passvak API
One identity for every product. Base URL https://api.passvak.com. JSON in, JSON out. Credentials go in Authorization: Bearer ….
Overview
passvak holds accounts, sessions, teams, API keys and the "Sign in with passvak" server. It does not send anything itself: codes travel through mailvak (email) and linevak (text), so passvak holds no email or SMS credentials. It does not bill, and it knows nothing product-specific.
| Credential | Prefix | Who holds it | Can do |
|---|---|---|---|
| Session | pvs_ | A person, after typing a code | Everything on the account page. 30 days. |
| API key | pvk_live_ | A person's program, for ONE product | Read /me. The product checks key.product is itself. |
| Access token | pvt_ | An app, after "Sign in with passvak" | Read /me and /oauth/userinfo. 24 h, refreshable. |
Only a session may change anything. A key or token that tries gets 403 forbidden.
Sign in by code
POST /signin
{ "identifier": "ana@example.com", "lang": "es", "product": "CelDrive" }
→ { "ok": true, "channel": "email", "to": "a••@example.com", "expiresIn": 600 }
POST /verify
{ "identifier": "ana@example.com", "code": "482913", "device": "CelDrive iOS" }
→ { "ok": true, "token": "pvs_…", "sessionId": "ses_…", "account": { "id": "acct_…", "email": "ana@example.com", … }, "isNew": true, "expiresIn": 2592000 }
identifier is an email address or a phone number (ten US digits are taken as +1). product is the name shown in the code message. lang picks the message's language and is remembered on a new account. A code works once, expires in 10 minutes, and dies after five wrong guesses. GET /channels says which channels can deliver right now; a channel that can't answers 503.
The /me contract
The one call every product makes. Works with any of the three credentials.
GET /me
Authorization: Bearer pvk_live_…
→ {
"ok": true,
"account": { "id": "acct_…", "email": "ana@example.com", "phone": null, "name": "Ana Pérez", "lang": "es", "createdAt": "…", "lastSignInAt": "…" },
"via": "key",
"key": { "id": "key_…", "product": "celdrive", "scopes": ["*"], "teamId": "team_…", "label": "laptop" },
"teams": [ { "id": "team_…", "name": "Panadería Luna", "role": "owner", "members": 3 } ]
}
key.product equals its own id. A key minted for celdrive is worthless at Unyplex by design.Sessions
GET /sessions → { sessions: [ { id, device, method, ip, createdAt, lastSeenAt, expiresAt, current } ] }
POST /sessions/revoke { "id": "ses_…" }
POST /sessions/revoke-all { "keepCurrent": true } → also revokes every app token
ip is shortened (203.0.113.x); the full address is never stored. GET /me/log returns the last 50 sign-ins and account changes.
Teams and roles
GET /teams → { teams: [ { id, name, role, members } ] }
POST /teams { "name": "Panadería Luna" }
GET /teams/:id → { team, members: [ { accountId, role, name, email, phone, joinedAt } ], invites }
POST /teams/:id/invite { "email": "bob@example.com", "role": "admin" } → emails an invitation (7 days)
POST /teams/:id/cancel-invite { "email" }
POST /teams/:id/role { "accountId", "role": "owner" | "admin" | "member" }
POST /teams/:id/remove { "accountId" }
POST /teams/:id/rename { "name" }
POST /teams/:id/leave
POST /invites/accept { "token" } — by the signed-in account whose email was invited
| Role | Can |
|---|---|
| owner | Everything: rename, roles, remove anyone, team keys. A team always keeps at least one owner. |
| admin | Invite, cancel invites, remove members and admins, see and revoke team keys. |
| member | Read the team. |
API keys
GET /keys → { keys: [ { id, product, scopes, label, teamId, createdAt, lastUsedAt } ] }
POST /keys { "product": "celdrive", "label": "laptop", "teamId": "team_…", "scopes": ["files:read"] }
→ { "ok": true, "key": "pvk_live_…", "id": "key_…", … } — the key is shown once
POST /keys/revoke { "id": "key_…" }
GET /teams/:id/keys — owners and admins
Keys are stored hash-only. product is a slug ([a-z0-9-]); scopes are free-form strings the product interprets, default ["*"].
Export and delete
GET /me/export → a JSON file with the account, sessions, teams, keys, apps and log
POST /me/delete { "confirm": true }
Deletion is refused with 409 owns_team_with_members while the person is the only owner of a team that still has other members. Teams they alone are in are deleted with them.
Sign in with passvak — OAuth 2.1
Authorization Code with PKCE (S256), public clients, refresh tokens. Discovery at /.well-known/oauth-authorization-server.
1. Send the person to
GET https://api.passvak.com/oauth/authorize
?response_type=code&client_id=celdrive
&redirect_uri=https://app.celdrive.com/auth/passvak
&code_challenge=<base64url(sha256(verifier))>&code_challenge_method=S256
&scope=openid email teams&state=<random>&ui_locales=es
passvak shows the consent screen (in their language), signs them in by code
if they aren't already, and redirects to redirect_uri?code=pvcode_…&state=…
2. Exchange the code (form-encoded or JSON)
POST /oauth/token
grant_type=authorization_code&code=pvcode_…&code_verifier=…&client_id=celdrive&redirect_uri=…
→ { "access_token": "pvt_…", "token_type": "Bearer", "expires_in": 86400, "refresh_token": "pvr_…", "scope": "openid email teams" }
3. Who is it?
GET /oauth/userinfo (or GET /me)
→ { "sub": "acct_…", "email": "…", "email_verified": true, "phone_number": null, "name": "…", "locale": "es", "teams": [ … ] }
Later: POST /oauth/token grant_type=refresh_token&refresh_token=pvr_… (rotates both tokens)
POST /oauth/revoke { "token": "pvt_… | pvr_…" }
Scopes: openid profile email phone teams. The person can cut your app off from their account page at any time ("Apps you've allowed"); your token then answers 401 with a WWW-Authenticate header pointing at discovery.
Registering an app
POST /oauth/register (dynamic, RFC 7591; expires after 90 days of no use)
{ "client_name": "My App", "redirect_uris": ["https://myapp.com/cb", "http://localhost:3000/cb"] }
→ { "client_id": "pvc_…", "token_endpoint_auth_method": "none", … }
Our own products are registered by the operator with a fixed client_id (celdrive, unyplex, mailvak, pentasor) and never expire. Redirect URIs must be https://, or http://localhost for development.
userinfo
Standard shape: sub is the account id. teams is the same array /me returns. Nothing else about the person exists to give.
Errors and limits
Every error is { "ok": false, "error": "<code>", "message": "<sentence in the caller's language>" }. Show message to the person; branch on error.
| Status | Codes |
|---|---|
| 400 | bad_json invalid_identifier invalid_code code_incorrect (with triesLeft) code_expired confirm_required invite_invalid |
| 401 | sign_in_first — no or dead credential |
| 403 | forbidden — a key/token tried to manage, or a role can't do that; invite_wrong_account |
| 404 | not_found team_not_found key_not_found session_not_found |
| 409 | last_owner owns_team_with_members |
| 422 | team_name_required invite_email_required role_invalid product_required |
| 429 | rate_limited (with retryAfter) too_many_attempts |
| 503 | phone_unavailable email_unavailable — the rail isn't open yet; not a bug |
Limits: /signin 12 per hour per IP and 5 per 15 minutes per address. Five wrong codes burn a code.
Languages
Every message a person sees exists in English and Spanish. Pass lang in /signin and /verify, or the header x-passvak-lang: es on later calls; otherwise the account's own language is used. The consent screen reads ui_locales, then Accept-Language.
© 2026 Parent Vertical LLC · Terms · Privacy · hello@passvak.com