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 |
| Auth scheme | JWT Bearer (Authorization: Bearer <token>) |
| 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",
"token": "<jwt>",
"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)
Stateless — no session is stored server-side. Returns a reminder to clear the token client-side.
Response: 200 { "message": "Logged out successfully. Please clear your token client-side." }
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 |
Returns the authenticated user's full profile.
{
"id": "uuid", "email": "...", "username": "...",
"firstName": null, "lastName": null,
"bio": null, "avatar": null, "walletAddress": null,
"isActive": true, "createdAt": "...", "updatedAt": "..."
}Update profile fields. All fields optional.
Request body (any subset of):
| Field | Type | Constraints |
|---|---|---|
username |
string | 3–30 chars, alphanumeric + underscore |
firstName |
string | max 50 |
lastName |
string | max 50 |
bio |
string | max 500 |
avatar |
string (URL) |
Response: 200 full user object (same as GET /users/me)
Public — no auth needed. Returns a reduced public profile.
{ "id": "...", "username": "...", "firstName": null, "lastName": null, "avatar": null, "role": "learner", "createdAt": "..." }
⚠️ Preview — the service implementation is stubbed. Will return500until completed.
Request body: { "currentPassword": "...", "newPassword": "..." }
Password rules: min 8 chars, must contain uppercase, lowercase, digit, and special character (@$!%*?&). Must differ from current.
⚠️ Preview — the wallet update may not persist to the database until the service layer is completed.
Request body: { "walletAddress": "G..." }
Address must match ^G[A-Z0-9]{55}$.
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": "..." }
}The following routes are wired but not fully implemented:
| Route | Status |
|---|---|
PATCH /users/password |
Service method throws "Not implemented" — returns 500 |
PATCH /users/wallet |
Service method uses mock data — changes do not persist |
These are marked as Preview in the OpenAPI spec (/api-docs).
| 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) |