Skip to content
YeboID / Developer documentation

REST API Reference ​

The first-party REST API behind YeboID's phone + OTP + PIN login and the identity/account surface the YeboID app is built on.

For the OAuth 2.0 / OpenID Connect endpoints your app integrates against (/oauth/*, discovery, JWKS), see the OAuth & OIDC reference — that is the surface third-party products should use.

Which surface should I use?

If you are integrating YeboID into your product, you almost certainly want the OAuth & OIDC endpoints, not the routes on this page. The /auth/* and /users/* routes are the account-holder API: they authenticate a YeboID user directly with a first-party token and are what the YeboID login/account app itself calls. They do not accept OAuth access tokens issued to your client_id.

Base URLs ​

EnvironmentURL
Productionhttps://api.yeboid.com
Developmenthttps://dev-api.yeboid.com

Authentication ​

Public endpoints ​

No authentication required:

  • GET /health, GET /ready
  • POST /auth/start, POST /auth/otp/send, POST /auth/otp/verify
  • POST /auth/login/pin, POST /auth/pin/reset
  • POST /auth/refresh, POST /auth/logout
  • POST /auth/recovery/*
  • GET /auth/clients/:clientId/otp-mode
  • GET /users/@:handle, GET /users/handle/check, GET /users/handle/suggestions

Protected endpoints ​

Everything else takes a first-party access token from /auth/otp/verify or /auth/login/pin:

http
Authorization: Bearer <accessToken>

Missing or malformed header → 401 { "success": false, "error": "Missing or invalid authorization header" }. Invalid or expired token → 401 { "success": false, "error": "Invalid or expired token" }.

Request format ​

http
Content-Type: application/json
Authorization: Bearer <accessToken>

Response format ​

Responses are flat — fields sit at the top level next to success. There is no data envelope.

Success ​

json
{
  "success": true,
  "user": { "id": "..." }
}

Error ​

error is a human-readable string, not an object. Machine-readable code is present on the errors that define one; retryAfterMs appears on rate-limit and lockout responses.

json
{
  "success": false,
  "error": "Too many OTP requests. Try again later.",
  "code": "OTP_SEND_RATE_LIMITED",
  "retryAfterMs": 840000
}

Body validation failures (Zod) return 400 with a details array instead of a code:

json
{
  "success": false,
  "error": "Validation failed",
  "details": [{ "field": "phone", "message": "Invalid phone format" }]
}

Unhandled server faults return a deliberately generic 500 { "success": false, "error": "Internal server error" } — the detail goes to Sentry and the server logs, never to the client.


Health ​

GET /health ​

Liveness. Does not touch the database.

json
{ "status": "ok", "service": "yeboid", "version": "1.0.0" }

GET /ready ​

Readiness — runs SELECT 1 against Postgres.

json
{ "status": "ready", "service": "yeboid", "version": "1.0.0" }

When the database is unreachable, 503:

json
{
  "status": "unavailable",
  "service": "yeboid",
  "version": "1.0.0",
  "error": "database_unreachable"
}

Authentication ​

Login is always two calls: /auth/start decides which credential to ask for, then either /auth/login/pin or /auth/otp/verify exchanges it for tokens. Both token-minting endpoints also set the yeboid_session SSO cookie, which is what lets /oauth/authorize mint codes for this user later without a second login.

POST /auth/start ​

First step of every login. Returns pin for a returning user who has set a PIN; otherwise sends an OTP and returns otp, so the client can render the code screen immediately.

Request:

json
{ "phone": "+26876000000" }
FieldTypeRequiredDescription
phonestringYesE.164, + followed by 10–15 digits

Response — PIN user:

json
{ "success": true, "mode": "pin" }

Response — OTP user (code already sent):

json
{
  "success": true,
  "mode": "otp",
  "expiresIn": 300,
  "deliveredVia": "whatsapp"
}

deliveredVia is "whatsapp" or "sms". The pin branch never burns the OTP send budget.

Errors: 429 OTP_SEND_RATE_LIMITED (with retryAfterMs).


GET /auth/clients/:clientId/otp-mode ​

Lets a login screen know, before collecting a phone number, whether the requesting OAuth client forces or forbids WhatsApp delivery — so it can label the input correctly on first paint.

Response:

json
{ "mode": "default" }
modeMeaning
defaultWhatsApp first, SMS fallback
whatsapp-requiredWhatsApp only; SMS fallback refused
sms-onlySMS only

Always 200 — an unknown or deactivated clientId returns default rather than an error, so this endpoint can't be used to enumerate the client registry. A throttled caller gets 429 { "mode": "default" }.


POST /auth/otp/send ​

Send a login code. Usually unnecessary — /auth/start already sends one on the otp branch. Use this to resend.

Request:

json
{
  "phone": "+26876000000",
  "clientId": "yebo_1a2b3c...",
  "channel": "sms"
}
FieldTypeRequiredDescription
phonestringYesE.164
clientIdstringNoOAuth client, so its WhatsApp flags apply
channelstringNoOnly "sms" is accepted — the sole channel a client may force

Response:

json
{
  "success": true,
  "message": "OTP sent",
  "expiresIn": 300,
  "deliveredVia": "sms"
}

Errors:

StatuscodeMeaning
400WHATSAPP_REQUIREDClient requires WhatsApp; channel: "sms" refused
429OTP_SEND_RATE_LIMITEDPer-phone or per-IP send budget spent

POST /auth/otp/verify ​

Exchange a code for tokens. Creates the account if the phone number is new — this is the signup path; there is no separate signup endpoint.

Request:

json
{ "phone": "+26876000000", "otp": "123456" }
FieldTypeRequiredDescription
phonestringYesE.164
otpstringYesExactly 6 digits

Response:

json
{
  "success": true,
  "user": {
    "id": "clx...",
    "phone": "+26876000000",
    "handle": "example_developer",
    "name": "Example Developer",
    "avatar": "https://...",
    "kycStatus": "VERIFIED"
  },
  "hasPin": false,
  "accessToken": "eyJ...",
  "refreshToken": "abc123...",
  "expiresIn": 900
}

expiresIn is the access token's lifetime in seconds (15 minutes).

Errors:

StatuscodeMeaning
401—Invalid or expired OTP. Adds attemptsRemaining only at ≤2 left
409PIN_REQUIREDAccount has a PIN — use /auth/login/pin or /auth/pin/reset
429OTP_LOCKEDToo many wrong codes; the code is dead, request a new one

POST /auth/login/pin ​

Returning-user login with phone + PIN.

Request:

json
{ "phone": "+26876000000", "pin": "123456" }

PINs are exactly 6 digits.

Response: identical shape to /auth/otp/verify, with hasPin: true.

Errors:

StatuscodeMeaning
401PIN_INVALIDWrong PIN, or no such account. Adds attemptsRemaining at ≤2 left
423PIN_LOCKED5 wrong attempts in 15 min. Recover via /auth/pin/reset

Unknown phone and wrong PIN return the same error, so this endpoint can't be used to test whether a number is registered.


POST /auth/refresh ​

Rotate tokens.

Request:

json
{ "refreshToken": "abc123..." }

Response:

json
{
  "success": true,
  "accessToken": "eyJ...",
  "refreshToken": "def456...",
  "expiresIn": 900
}

Token rotation

Refresh tokens rotate on every use — the token you sent is invalidated. Persist the new refreshToken from the response or the next refresh fails.

Errors: 401 { "error": "Invalid or expired refresh token" }.


POST /auth/logout ​

Revokes the refresh token (when supplied) and destroys the SSO session, clearing the yeboid_session cookie.

Request: refreshToken is optional — {} is valid and still ends the SSO session, which is what a browser-side "sign out" wants.

json
{ "refreshToken": "abc123..." }

Response:

json
{ "success": true, "message": "Logged out" }

PIN management ​

GET /auth/pin/status ​

Auth required. { "success": true, "hasPin": true }


POST /auth/pin/set ​

Auth required. Sets or replaces the PIN. Body { "pin": "123456" } (exactly 6 digits) → { "success": true, "message": "PIN set successfully" }.


POST /auth/pin/change ​

Auth required. Body { "currentPin": "123456", "newPin": "654321" } → { "success": true, "message": "PIN changed successfully" }.

Errors: 401 PIN_INVALID, 400 PIN_NOT_SET, 429 with retryAfterMs.


POST /auth/pin/verify ​

Auth required. Confirms a sensitive action. Does not issue tokens.

Body { "pin": "123456" } → { "success": true, "valid": true }.

Errors: 401 PIN_INVALID, 400 PIN_NOT_SET, 429 (5 attempts / 15 min).


POST /auth/pin/reset ​

Forgotten-PIN recovery. Call /auth/otp/send first, then post the code with the replacement PIN. No access token needed.

json
{ "phone": "+26876000000", "otp": "123456", "newPin": "654321" }

Errors: 401 invalid/expired OTP, 429 OTP_LOCKED.


Account recovery (lost phone number) ​

For a user who no longer controls their login number. Both numbers must be proven, and the request is either completed by an email code or queued for human review.

POST /auth/recovery/start ​

json
{
  "phone": "+26876000000",
  "newPhone": "+26876543210",
  "note": "Lost my SIM"
}

phone is the lost number (the account identity), newPhone the one the user controls now. note is optional free text for the reviewer.

Response:

json
{
  "success": true,
  "requestId": "rec_abc123",
  "method": "EMAIL",
  "message": "Verification code sent to the new number.",
  "expiresIn": 300
}

method is EMAIL when the account has a verified email (recovery can complete self-service) and ADMIN otherwise (an operator must approve).

Errors: 400 SAME_PHONE, 403 ACCOUNT_DISABLED, 404 no account, 409 PHONE_TAKEN, 429 RATE_LIMITED / OTP_SEND_RATE_LIMITED.


POST /auth/recovery/verify-phone ​

Proves control of newPhone with the code sent above. Returns status: "awaiting_email" when an email code follows, or status: "awaiting_review" when it goes to an operator.


POST /auth/recovery/verify-email ​

Final step of the EMAIL path — the code sent to the account's verified address.


Users ​

The /users/* routes — profile, handles, email verification, sessions, connected apps, phone-number change and account deletion — have their own page: User Management.

EndpointAuthPurpose
GET /users/meUser tokenAuthenticated user's profile
PATCH /users/meUser tokenUpdate name, email, avatar, handle
DELETE /users/meUser token + fresh re-authPermanent hard delete
GET /users/@:handlePublicPublic profile card
GET /users/handle/checkPublicHandle availability
GET /users/handle/suggestionsPublicSuggested available handles
POST /users/me/email/verify/requestUser tokenEmail a 6-digit code
POST /users/me/email/verify/confirmUser tokenConfirm the code
GET /users/me/sessionsUser tokenList active sessions
DELETE /users/me/sessionsUser tokenLog out everywhere
DELETE /users/me/sessions/:idUser tokenRevoke one session
GET /users/me/connected-appsUser tokenAuthorized OAuth apps
DELETE /users/me/connected-apps/:clientIdUser tokenDisconnect an app
POST /users/me/phone/change/requestUser tokenStart a phone change
POST /users/me/phone/change/confirmUser token + fresh re-authCommit it

KYC ​

KYC is served by the /verification/* routes. See KYC with YeboVerify for the full flow.

EndpointAuthPurpose
POST /verification/startUser tokenOpen a verification session, returns verifyUrl
GET /verification/statusUser tokenPoll kycStatus + decrypted kycData
POST /verification/webhookHMACInbound from YeboVerify — not for your app

Third-party OAuth apps should read KYC state from GET /oauth/userinfo with the kyc / kyc:full scope, or subscribe to the user.kyc.verified and user.kyc.rejectedwebhooks — not by calling /verification/*.


Error codes ​

Codes actually emitted by these routes. Errors without a code carry only error (a message) — always branch on the HTTP status first.

CodeHTTPDescription
WHATSAPP_REQUIRED400Client mandates WhatsApp; SMS fallback refused
NO_EMAIL400No email address on file to verify
INVALID_CODE400Wrong or expired email verification code
SAME_PHONE400New number equals the current one
HANDLE_INVALID400Handle fails format/length rules
PIN_NOT_SET400Action needs a PIN the user hasn't set
PIN_REQUIRED400PIN required to confirm this action
OTP_REQUIRED400Verification code required to confirm this action
PIN_INVALID401Wrong PIN
OTP_INVALID401Wrong or expired code on a re-auth step
PIN_REQUIRED409On /auth/otp/verify: account has a PIN, use PIN login
ACCOUNT_DISABLED403Account is banned
account_disabled403Same condition on the OAuth paths (OAuth-style casing)
HANDLE_TAKEN409Handle already taken or reserved
PHONE_TAKEN409Phone already belongs to another account
ALREADY_VERIFIED409Email already verified / user already KYC-verified
PIN_LOCKED423PIN login locked after repeated failures
OTP_SEND_RATE_LIMITED429Send budget spent (per phone or per IP)
OTP_LOCKED429Code locked after too many wrong guesses
RATE_LIMITED429Generic per-user attempt limiter

Rate limits ​

SurfaceLimitWindow
OTP send, per phone315 min
OTP send, per IP1515 min
OTP verify, per code5 wrong guessescode locked 15 min
POST /auth/login/pin, per phone515 min → 423 PIN_LOCKED
POST /auth/pin/verify + /pin/change, per user515 min
Email verify send, per user315 min
Email verify confirm, per user515 min
Account delete + phone-change confirm, per user515 min
GET /auth/clients/:clientId/otp-mode, per IP605 min

The OTP send budget is shared across /auth/start, /auth/otp/send and /users/me/phone/change/request, so one number can't be bombed by spreading sends across endpoints.

No rate-limit headers

The API does not emit X-RateLimit-* headers. Read retryAfterMs from the 429 body instead.

Most limiters are in-memory per Cloud Run instance, so the effective ceiling across N instances is limit × N. The brute-force-critical control — the OTP verify lockout — is database-backed and therefore exact.


SDKs ​

Delegated permissions ​

Use the permissions matrix before calling personal or business endpoints. App tokens cannot administer global sessions, credentials, OAuth applications, or webhook subscriptions. Profile writes require profile:write, and verification status does not disclose full identity data without kyc:full. A KYC-owned name is read-only.

Identity for Africa that understands Africa.