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.

CredentialPrefixWho holds itCan do
Sessionpvs_A person, after typing a codeEverything on the account page. 30 days.
API keypvk_live_A person's program, for ONE productRead /me. The product checks key.product is itself.
Access tokenpvt_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 } ]
}
A product trusting a key must check 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
RoleCan
ownerEverything: rename, roles, remove anyone, team keys. A team always keeps at least one owner.
adminInvite, cancel invites, remove members and admins, see and revoke team keys.
memberRead 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.

StatusCodes
400bad_json invalid_identifier invalid_code code_incorrect (with triesLeft) code_expired confirm_required invite_invalid
401sign_in_first — no or dead credential
403forbidden — a key/token tried to manage, or a role can't do that; invite_wrong_account
404not_found team_not_found key_not_found session_not_found
409last_owner owns_team_with_members
422team_name_required invite_email_required role_invalid product_required
429rate_limited (with retryAfter) too_many_attempts
503phone_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