Live spec:
GET /api-docs(Swagger UI) orGET /api-docs/swagger.json
The Learnault API is a JSON REST API for a decentralized learn-to-earn platform on Stellar.
| Item | Value |
|---|---|
| Base URL (production) | https://api.learnault.io/api/v1 |
| Base URL (local) | http://localhost:3000/api/v1 |
| Refresh scheme | Opaque rotating refresh token (refreshToken body or refresh_token cookie) |
| Content-Type | application/json |
Obtain a JWT from POST /auth/login. Pass it on every protected request:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...JWT payload contains { id, email, role }. Roles are learner, employer, admin.
Varies by endpoint — see individual routes. Most use one of:
{ "message": "...", "data": { ... } }{ "success": true, "data": { ... } }{
"success": false,
"error": {
"message": "Resource not found",
"code": 404
}
}Validation errors from the validate() middleware:
{
"message": "Validation failed",
"errors": {
"body": ["Invalid email format"],
"params": ["Invalid ID format"]
}
}Every response includes:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 97
X-RateLimit-Reset: 2026-07-19T10:15:00.000ZWhen exceeded (HTTP 429):
Retry-After: 60| Limiter | Applies to | Default window | Default max |
|---|---|---|---|
authLimiter |
/auth/login, /auth/resend-verification, /auth/forgot-password |
15 min | 10 |
otpLimiter |
/auth/otp/request, /auth/otp/verify |
15 min | 5 |
employerLimiter |
All /employer/* routes |
15 min | 500 |
authenticatedLimiter |
All /sync/* routes |
15 min | 1000 |
generalLimiter |
Everything else | 15 min | 100 |
All values are overridable via environment variables (RATE_LIMIT_*_WINDOW_MS, RATE_LIMIT_*_MAX).
No authentication. Returns immediately.
{ "status": "ok", "timestamp": "2026-07-19T10:00:00.000Z" }All auth routes are public (no JWT required) unless noted.
Register a new user. Queues a verification email.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
email |
string (email) | ✅ | |
password |
string | ✅ | min 8 chars |
username |
string | ✅ | min 3 chars |
role |
"learner" | "employer" |
❌ | default "learner" |
Responses
| Status | Meaning |
|---|---|
| 201 | User created; JWT and user object returned |
| 400 | Validation failed |
| 409 | Email or username already taken |
{
"message": "User registered successfully",
"accessToken": "<jwt>",
"refreshToken": "<opaque-token>",
"expiresIn": 900,
"tokenType": "Bearer",
"user": { "id": "...", "email": "...", "username": "...", "role": "learner" }
}Rate-limited (10 req / 15 min).
Request body: { "email": "...", "password": "..." }
Responses: 200 (same shape as register), 400, 401 (invalid credentials)
Rotates a refresh token for a new access/refresh pair. The presented token
is consumed; a replayed (already-rotated) token revokes the whole session
family and returns 401 REFRESH_REUSE_DETECTED.
Request body: { "refreshToken": "<opaque-token>" } — or send the token
via an httpOnly refresh_token cookie.
{
"message": "Token refreshed successfully",
"accessToken": "<new-jwt>",
"refreshToken": "<new-opaque-token>",
"expiresIn": 900,
"tokenType": "Bearer"
}Responses: 200 (rotated), 400 (missing token), 401 (REFRESH_INVALID,
REFRESH_EXPIRED, REFRESH_REVOKED, or REFRESH_REUSE_DETECTED)
Logs out the current session by revoking its refresh-token family. Idempotent.
Request body: { "refreshToken": "<opaque-token>" } — or via the
httpOnly refresh_token cookie.
Response: 200 { "message": "Logged out successfully", "revokedCount": 1 }
Logs out every session for the user identified by the refresh token. Idempotent.
Request body: { "refreshToken": "<opaque-token>" } — or via the
httpOnly refresh_token cookie.
Response: 200 { "message": "All sessions logged out", "revokedCount": 3 }
Request body: { "token": "<64-char hex string from email>" }
| Status | Meaning |
|---|---|
| 200 | Verified (or already verified) |
| 400 | Invalid, expired, or revoked token |
Rate-limited (10 req / 15 min). Always returns 200 to avoid leaking whether an account exists.
Request body: { "email": "..." }
Response: 200 { "message": "If the account exists, a verification email has been sent." }
Rate-limited (10 req / 15 min). Token expires after 30 minutes. Always returns 200.
Request body: { "email": "..." }
Response: 200 { "message": "If the account exists, a password reset email has been sent." }
On success, all active sessions are revoked and all pending verification tokens are cancelled.
Request body: { "token": "<64-char hex>", "newPassword": "<min 8 chars>" }
| Status | Meaning |
|---|---|
| 200 | Password reset |
| 400 | Invalid/expired token or weak password |
Requests a 6-digit SMS code. Behavior depends on whether a Bearer token is sent:
- No token →
LOGIN: the phone must already be verified on an existing account. Always returns 200 with the same generic message, whether or not the phone is registered, to avoid leaking phone existence. - With token →
PHONE_VERIFICATION: attaches/verifies this phone number on the caller's own account.
Rate-limited by IP (otpLimiter), by phone (1/min cooldown, 5/hour), and — if deviceId is supplied — by device (10/hour). SMS is sent via a mocked provider (SMS_PROVIDER=mock) until a real carrier is integrated; see docs/decisions/0001-phone-otp-authentication.md.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
phone |
string | ✅ | E.164 format, e.g. +2348012345678 |
deviceId |
string | ❌ | Used for device-level rate limiting |
Responses
| Status | Meaning |
|---|---|
| 200 | Code sent (or silently ignored for an unregistered/unverified LOGIN phone) |
| 400 | Validation failed or phone not in E.164 format |
| 409 | Phone already verified on a different account (PHONE_VERIFICATION only) |
| 429 | IP, phone, or device rate limit reached |
{ "message": "If this phone number is registered, a verification code has been sent." }Verifies the code from otp/request. Codes are single-use, expire after 5 minutes, and the challenge locks after 5 wrong attempts (request a new code to retry).
- No token →
LOGIN: on success, returns the same JWT/user shape asPOST /auth/login, after the same account-status checks (deactivated/pending-deletion/deleted). - With token →
PHONE_VERIFICATION: on success, marks the phone verified on the caller's account.
Request body: { "phone": "+2348012345678", "code": "123456" }
Responses
| Status | Meaning |
|---|---|
| 200 | Verified — login response or { "message": "Phone number verified successfully" } |
| 400 | Invalid/expired code, or validation failed |
| 401 | Invalid credentials (tombstoned account; LOGIN only) |
| 403 | Account deactivated or pending deletion (LOGIN only) |
| 429 | Too many wrong attempts — challenge locked |
Owner-only aggregate: account identity, learner profile, profile completion, onboarding state, and the current consent record per purpose. One read, so a client does not have to fan out across four endpoints to render a settings or "finish setting up" screen.
{
"data": {
"account": {
"id": "uuid", "email": "...", "username": "...",
"role": "LEARNER", "status": "ACTIVE",
"isVerified": true, "phoneVerifiedAt": null,
"walletAddress": null,
"createdAt": "...", "updatedAt": "...", "lastLoginAt": null
},
"profile": {
"id": "uuid", "userId": "uuid",
"displayName": null, "bio": null, "avatarUrl": null,
"country": null, "timezone": null,
"languages": [], "level": "beginner",
"interests": [], "goals": [],
"visibility": "private",
"createdAt": "...", "updatedAt": "..."
},
"completion": { "percent": 0, "missingFields": ["displayName", "bio", "..."] },
"onboarding": {
"version": "v1", "status": "in_progress", "currentStep": "profile_basics",
"completedSteps": ["profile_basics"], "requiredStepsRemaining": ["consent"],
"startedAt": "...", "completedAt": null
},
"consents": [
{ "purpose": "terms_of_service", "status": "granted", "required": true,
"policyVersion": "2026-01", "grantedAt": "...", "withdrawnAt": null }
],
"requiredConsentsGranted": false
}
}The profile row is created on first access, so a brand-new learner gets a
deterministic 0%-complete profile rather than a null. onboarding is null
until the learner starts onboarding. The password hash is never included.
Responses: 200 · 401 no/invalid token · 404 account unknown or tombstoned
Partial learner-profile update. At least one field is required, and the body is
closed: any property outside the table below — including account fields such
as status, isVerified, role, email, password or walletAddress — is a
400, not a silently ignored key. Every accepted change is written together with
its audit event in one transaction.
Request body (any non-empty subset of):
| Field | Type | Constraints |
|---|---|---|
displayName |
string | null | 1–80 chars |
bio |
string | null | max 1000 |
avatarUrl |
string (URL) | null | normally set by the avatar upload flow |
country |
string | null | 2–60 chars |
timezone |
string | null | |
languages |
string[] | max 20 |
level |
enum | beginner | intermediate | advanced | expert |
interests |
string[] | max 50 |
goals |
string[] | max 20 |
visibility |
enum | private | employer | public |
Response: 200 { "message": "...", "data": { … } } — the same aggregate as
GET /users/me, recomputed from the persisted row.
Responses: 200 · 400 validation failed · 401 · 404
PATCH /users/me/profile accepts the same body and returns the profile alone
(without the account aggregate).
Public — no auth needed. Returns the learner's public profile subset, or the
redacted stub { "id": "...", "visible": false }.
{
"data": {
"id": "uuid", "displayName": "Grace H.", "bio": "Learning Soroban",
"avatarUrl": null, "country": "NG", "level": "intermediate",
"interests": ["soroban"], "visible": true
}
}Disclosure requires all of:
profile.visibility === "public",- the account status is
ACTIVE, and data_sharingconsent has not been withdrawn.
Any refusal returns the same stub, so a caller cannot tell a private profile
from a withdrawn consent from a deactivated account. Archived profiles read as
404. Private account data (email, username, wallet address, status,
verification, password) never appears here — not even for the owner, who gets
the same public view as anyone else on this route and uses
GET /users/me or GET /users/:id/profile for their own full record.
Responses: 200 · 400 malformed id · 404 unknown learner
Verifies the current password, stores a new bcrypt hash, and revokes every session and refresh-token family for the account in the same transaction — so the caller must sign in again, and so does anyone holding a stolen session. The change is audited; neither password reaches the audit trail.
Request body: { "currentPassword": "...", "newPassword": "..." }
Password rules: min 8 chars, must contain uppercase, lowercase, digit, and special character (@$!%*?&). Must differ from current.
Response: 200 { "message": "...", "revokedSessionCount": 2 }
Responses: 200 · 400 validation failed · 401 no token, or wrong current
password (code: STEP_UP_FAILED) · 404 account unknown or tombstoned
Sets the learner's Stellar public key on their account. Never accepts or returns a secret seed. The change is audited.
Request body: { "walletAddress": "G..." }
Address must match ^G[A-Z0-9]{55}$.
Re-sending the address already on file is a no-op (200, "Wallet address unchanged", no second audit event). Addresses are unique across accounts.
Responses: 200 · 400 invalid address · 401 · 404 account unknown or
tombstoned · 409 address already claimed by another account
(code: WALLET_ADDRESS_TAKEN)
Optional auth — when a valid token is provided, each module includes userProgress.
Query parameters
| Param | Type | Default |
|---|---|---|
page |
integer | 1 |
limit |
integer | 10 |
category |
string | — |
difficulty |
string | — |
search |
string | — |
Response 200
{
"modules": [
{
"id": "...", "title": "...", "description": "...",
"category": "finance", "difficulty": "beginner",
"reward": 0.25, "createdAt": "...", "updatedAt": "...",
"completionCount": 120,
"userProgress": null
}
],
"pagination": { "page": 1, "limit": 10, "total": 45, "totalPages": 5, "hasNext": true, "hasPrev": false }
}Optional auth. Same userProgress inclusion behaviour.
Response 200: single module object (same fields as list item).
404 if not found.
Creates a progress record. Must be called before complete.
Response 201:
{ "message": "Module started successfully", "completionId": "...", "startedAt": "..." }400 if already started or completed.
Submit quiz answers. Module must have been started first.
A score ≥ 70% qualifies for the XLM reward and triggers a push notification.
Request body:
{
"quizAnswers": [
{ "questionId": "q1", "answer": "B" }
]
}Response 200:
{
"message": "Module completed successfully",
"score": 80,
"isEligibleForReward": true,
"reward": 0.25,
"rewardTransaction": "<uuid>",
"completedAt": "..."
}Query parameters
| Param | Type | Notes |
|---|---|---|
moduleId |
UUID | filter |
fromDate |
ISO datetime | filter |
toDate |
ISO datetime | filter |
page |
integer | default 1 |
limit |
integer | default 10, max 100 |
Response 200:
{
"success": true,
"data": [ { "id": "...", "moduleId": "...", "moduleName": "...", "onChainId": null, "issuedAt": "...", "shareableLink": "..." } ],
"meta": { "page": 1, "limit": 10, "total": 5, "totalPages": 1, "hasNextPage": false, "hasPrevPage": false }
}Public — no auth needed. Looks up by onChainId first, falls back to credential UUID.
Response 200:
{
"success": true,
"data": {
"valid": true,
"credential": { "id": "...", "holderName": "...", "moduleName": "...", "onChainId": "...", "issuedAt": "..." },
"verification": { "verifiedAt": "...", "status": "verified", "message": "This credential is valid and has been verified on-chain" }
}
}404 if not found.
Returns full credential detail. Returns 401 if the credential belongs to another user.
All reward routes require authentication.
{
"success": true,
"data": {
"balance": { "available": 10.5, "pending": 2.0, "lifetime": 25.0 },
"updatedAt": "..."
}
}Query parameters
| Param | Values | Default |
|---|---|---|
type |
module_reward, streak_bonus, referral_reward, withdrawal |
— |
status |
pending, completed, failed |
— |
fromDate |
ISO datetime | — |
toDate |
ISO datetime | — |
limit |
1–100 | 20 |
offset |
≥ 0 | 0 |
Response 200:
{
"success": true,
"data": {
"transactions": [ { "id": "...", "type": "module_reward", "status": "completed", "amount": 0.25, "moduleId": "...", "stellarTxHash": null, "createdAt": "...", "completedAt": "..." } ],
"pagination": { "total": 15, "limit": 20, "offset": 0, "hasMore": false }
}
}Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
walletAddress |
string | ✅ | Stellar address matching ^G[A-Z0-9]{50,55}$ |
amount |
number | ✅ | XLM, must be > 0 |
memo |
string | ❌ |
Response 201:
{
"success": true,
"message": "Withdrawal processed successfully",
"data": { "transactionId": "...", "amount": 5.0, "stellarTxHash": "...", "status": "completed", "requestedAt": "...", "completedAt": "..." }
}400 for invalid address, zero/negative amount, or insufficient balance.
All referral routes require authentication.
Generate (or retrieve existing) referral code for the authenticated user.
- Returns
200if the user already has a code. - Returns
201if a new 8-character hex code was created.
{ "success": true, "message": "...", "data": { "code": "A1B2C3D4" } }Apply a referral code. Cannot apply your own code or apply more than once.
Request body: { "code": "A1B2C3D4" }
| Status | Meaning |
|---|---|
| 201 | Referral applied |
| 400 | Missing code, self-referral, or code not found |
| 409 | Already used a referral code |
{
"success": true,
"data": {
"totalReferrals": 3,
"activeReferrals": 2,
"earnedBonuses": 10.0,
"pendingBonuses": 5.0
}
}activeReferrals = referrees who have completed at least one module.
pendingBonuses = (total − paid) × 5 XLM per referral.
All notification routes require authentication.
Register a Firebase device token for push notifications.
Request body:
| Field | Type | Required |
|---|---|---|
token |
string | ✅ |
platform |
"ios" | "android" | "web" |
✅ |
Response 201: device token record.
At least one field must be provided.
Request body (any subset of):
| Field | Type |
|---|---|
rewardReceipt |
boolean |
quizPassFail |
boolean |
streakReminders |
boolean |
Query parameters: limit (default 20, max 100), status (pending | success | failed | dead-letter).
{
"data": [ { "id": "...", "type": "quizPassFail", "title": "Quiz Passed!", "body": "...", "status": "success", "error": null, "attemptCount": 1, "createdAt": "..." } ],
"count": 1
}All sync routes require authentication and are subject to the authenticatedLimiter (1000 req / 15 min).
Upload batched offline progress events. Each event is deduplicated by idempotencyKey. Events with a stale syncVersion are skipped without error.
Request body:
{
"events": [
{
"idempotencyKey": "device-abc-mod-xyz-1",
"deviceId": "device-abc",
"moduleId": "<uuid>",
"progressPercent": 60,
"clientTimestamp": "2026-07-19T09:00:00.000Z",
"syncVersion": 3
}
]
}Response 200:
{
"success": true,
"data": {
"results": [
{ "idempotencyKey": "device-abc-mod-xyz-1", "status": "applied" }
]
}
}Each result has status: applied | skipped | rejected, plus an optional reason.
Reconcile offline quiz/completion attempts. If the user already has a completion with an equal or higher score, the event is skipped. A higher score updates the existing record.
Request body:
{
"events": [
{
"idempotencyKey": "device-abc-comp-xyz-1",
"deviceId": "device-abc",
"moduleId": "<uuid>",
"score": 85,
"clientTimestamp": "2026-07-19T09:05:00.000Z",
"syncVersion": 1
}
]
}Response shape identical to POST /sync/progress.
All employer routes require authentication with role: employer. An employerLimiter (500 req / 15 min) is applied.
Plan tier is read from the x-employer-plan request header. Valid values: starter (default), pro, enterprise.
Search the learner talent pool.
Query parameters
| Param | Type | Default | Notes |
|---|---|---|---|
page |
integer | 1 | |
limit |
integer | 20 | Capped by plan: starter ≤ 10, pro ≤ 50, enterprise ≤ 100 |
skills |
string | — | Comma-separated keywords, e.g. finance,defi |
location |
string | — | |
credentials |
any | verified | none |
any |
|
search |
string | — | Free-text search on username/email |
Request header: x-employer-plan: starter | pro | enterprise
Response 200:
{
"candidates": [
{
"id": "...", "name": "alice42", "location": "lagos",
"skills": ["finance", "defi"],
"completions": 5, "averageScore": 82.4,
"verifiedCredentialCount": 2
}
],
"pagination": { "page": 1, "limit": 10, "total": 3, "totalPages": 1, "hasNext": false, "hasPrev": false },
"filters": { "skills": ["finance"], "location": null, "credentials": "any" },
"plan": "starter"
}400 if limit exceeds plan maximum.
Full candidate profile with all verified credentials.
403 if candidate profile is private or caller is not an employer.
404 if candidate not found or has no module completions.
Record a candidate outreach attempt. Requires pro or enterprise plan — starter returns HTTP 402.
Request header: x-employer-plan: pro (or enterprise)
Request body:
| Field | Type | Required | Constraints |
|---|---|---|---|
candidateId |
UUID | ✅ | |
subject |
string | ✅ | 3–120 chars |
message |
string | ✅ | 10–3000 chars |
channel |
"platform" | "email" | "both" |
❌ | default "platform" |
Response 201:
{
"message": "Candidate outreach recorded",
"outreach": { "id": "...", "candidateId": "...", "channel": "platform", "status": "recorded", "createdAt": "..." }
}None on /users. PATCH /users/password and PATCH /users/wallet were the last
two stubs here; both are Prisma-backed and audited as of the Profile API work
(see docs/decisions/0004-profile-api.md).
| HTTP | Typical cause |
|---|---|
| 400 | Validation failed, malformed body, business rule violation |
| 401 | Missing, expired, or invalid JWT |
| 402 | Employer plan upgrade required |
| 403 | Authenticated but insufficient role or resource is private |
| 404 | Resource not found |
| 409 | Conflict (duplicate email, already applied referral, etc.) |
| 429 | Rate limit exceeded — see Retry-After header |
| 500 | Internal server error (includes not-yet-implemented stubs) |