API Reference
One host, two APIs. Gatekeeper signs your users in (OAuth 2.0 / OIDC). Aethelos Link verifies who a customer is (Ghana Card) and how they spend (Mobile Money), with their consent. Both return JSON and speak the same error format.
Getting started
The base URL for every example on this page:
https://aethelos-gateway.vercel.appRegister an application in the Gateway dashboard to get a client_id and client_secret. There you also pick which grants and scopes your client may use. Confidential clients keep their secret server-side; public clients (SPAs, mobile apps) use PKCE and never hold a secret.
Every endpoint is also listed in the discovery document, so you can wire clients up dynamically:
curl https://aethelos-gateway.vercel.app/.well-known/openid-configurationErrors
All endpoints return errors in one shape, with an appropriate HTTP status:
{
"error": "invalid_grant",
"error_description": "Authorization code expired"
}| Field | Type | Description |
|---|---|---|
| invalid_request | 400 | A parameter is missing or malformed. |
| invalid_client | 401 | Client credentials are wrong or the client is inactive. |
| invalid_token | 401 | Access token is missing, expired, or fails verification. |
| insufficient_scope | 403 | Token is valid but lacks the scope this endpoint needs. |
| invalid_grant | 400 | Code or refresh token is expired, already used, or mismatched. |
| invalid_scope | 400 | Requested scope is not allowed for this client. |
| unauthorized_client | 400 | Grant type is not enabled for this client. |
| unsupported_grant_type | 400 | Unknown grant_type value. |
| authorization_pending | 400 | Device flow: the user hasn't approved yet. Keep polling. |
| expired_token | 400 | Device flow: the code expired before approval. |
| consent_required | 403 | Link: no active user consent for the requested scope. |
| access_denied | 403 | The user denied the request, or the account is not active. |
| not_found | 404 | No such resource for this client. |
| invalid_state | 409 | The resource exists but is in the wrong state (e.g. revoking an ungranted consent). |
Gatekeeper — sign-in (OAuth 2.0 / OIDC)
Gatekeeper implements the standard authorization code flow with PKCE. Your app sends the browser to Gateway, the user signs in (password, MFA if enforced), and Gateway redirects back with a one-time code you exchange for tokens.
1. Authorize
GET/oauth/authorize
Send the user's browser here. Browser redirect, not a fetch call.
| Field | Type | Description |
|---|---|---|
| client_id * | string | Your client ID. |
| redirect_uri * | string | Must exactly match a URI registered on your client. |
| response_type * | string | Only code is supported. |
| scope | string | Space-separated. Default openid. See scopes below. |
| state | string | Opaque value echoed back. Use it for CSRF protection. |
| nonce | string | Passed through to the ID token to bind it to your session. |
| code_challenge | string | S256 hash of your PKCE verifier. Required when your client enforces PKCE. |
| code_challenge_method | string | S256 (default) or plain. |
Example:
GET https://aethelos-gateway.vercel.app/oauth/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://app.example.com/callback
&scope=openid%20profile%20offline_access
&state=9f3k2l
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256If the user isn't signed in, Gateway shows its login page first. After sign-in and consent, the browser lands on your redirect_uri with ?code=…&state=…. If the user denies, you get ?error=access_denied instead.
2. Exchange the code for tokens
POST/oauth/token
Form-encoded body. Authenticate with HTTP Basic (client_id:client_secret) or put both in the form. The response always has Cache-Control: no-store.
curl -u "$CLIENT_ID:$CLIENT_SECRET" https://aethelos-gateway.vercel.app/oauth/token \
-d grant_type=authorization_code \
-d code=CODE_FROM_REDIRECT \
-d redirect_uri=https://app.example.com/callback \
-d code_verifier=YOUR_PKCE_VERIFIER{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile offline_access",
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "ZT0xQ9wR7p..."
}| Field | Type | Description |
|---|---|---|
| access_token | string | RS256 JWT (typ at+jwt). Carries sub, scope, and your client's roles claim. |
| id_token | string | OIDC ID token with email, name, email_verified, and your nonce. |
| expires_in | number | Access token lifetime in seconds. Per client, default 3600. |
| refresh_token | string | Only returned when the original scope included offline_access. |
Supported grant_type values:
| Field | Type | Description |
|---|---|---|
| authorization_code | grant | The flow above. Requires code, redirect_uri, and code_verifier when PKCE is on. |
| refresh_token | grant | Send the current refresh_token; get a new access token and a rotated refresh token. Scopes can be narrowed, never widened. Each refresh token works once — reuse revokes the whole token family (rotation theft protection). |
| client_credentials | grant | Service-to-service tokens, no user involved. Confidential clients only. |
| urn:ietf:params:oauth:grant-type:device_code | grant | Device flow, see below. |
Userinfo
GET/oauth/userinfo
curl https://aethelos-gateway.vercel.app/oauth/userinfo \
-H "Authorization: Bearer $ACCESS_TOKEN"{
"sub": "9b2f...",
"email": "kwame@example.com",
"email_verified": true,
"name": "Kwame Asante",
"phone_number": "+233244123456",
"picture": null,
"roles": ["user"]
}Introspect
POST/oauth/introspect
Check whether a token is still valid before trusting it. Takes token and optionally token_type_hint=refresh_token. Client authentication required.
{
"active": true,
"sub": "9b2f...",
"iss": "https://aethelos-gateway.vercel.app",
"aud": "YOUR_CLIENT_ID",
"exp": 1790000000,
"scope": "openid profile",
"token_type": "Bearer"
}Inactive, revoked, or foreign tokens return { "active": false }.
Revoke
POST/oauth/revoke
Form body: token (and optionally token_type_hint=refresh_token). Revoking a refresh token kills its entire family, so a stolen rotation chain dies with one call. Per RFC 7009 the endpoint returns 200 even when the token was already invalid.
Device flow
For TVs, CLIs, and other input-constrained devices. Request a code, show the user code, poll for tokens.
POST/oauth/device/code
curl -u "$CLIENT_ID:$CLIENT_SECRET" https://aethelos-gateway.vercel.app/oauth/device/code \
-d scope=openid{
"device_code": "xR7...",
"user_code": "BXFG-KMTR",
"verification_uri": "https://aethelos-gateway.vercel.app/device",
"verification_uri_complete": "https://aethelos-gateway.vercel.app/device?user_code=BXFG-KMTR",
"expires_in": 900,
"interval": 5
}Poll /oauth/token with grant_type=urn:ietf:params:oauth:grant-type:device_code and the device_code, waiting interval seconds between calls. You'll get authorization_pending until the user approves, then the usual token response — or access_denied if they decline.
On their side, the user opens https://aethelos-gateway.vercel.app/device, signs in, enters the code, and approves. Point them at verification_uri_complete when you can so the code is filled in for them.
End session
GET/oauth/end-session
Signs the user out of Gateway (SSO included) and ends their OAuth sessions. Send the browser here with post_logout_redirect_uri — it must be a registered redirect URI of an active client, given as client_id. Without a valid redirect, the user lands on the Gateway login page.
JWKS
GET/oauth/jwks
The RS256 public key, so you can verify access and ID tokens locally instead of calling introspect. Access tokens have typ: at+jwt; ID tokens carry your nonce and email claims. Cached for one hour.
Aethelos Link — KYC & Mobile Money
Link verifies a customer's Ghana Card against national records and aggregates their Mobile Money statement. Everything runs under explicit user consent.
Authentication
Link uses the same tokens as Gatekeeper — a Bearer JWT with the right link:* scope. Get one with the client credentials grant:
curl -u "$CLIENT_ID:$CLIENT_SECRET" https://aethelos-gateway.vercel.app/oauth/token \
-d grant_type=client_credentials \
-d "scope=link:kyc link:momo link:read"Pass it as Authorization: Bearer $ACCESS_TOKEN on every Link call. The token identifies your client; consents attach to your client, not to a specific user account of yours.
| Field | Type | Description |
|---|---|---|
| link:kyc | scope | Verify a Ghana Card and read the verification result. |
| link:momo | scope | Aggregate a Mobile Money statement and read the summary. |
| link:read | scope | List your verifications and manage consents. |
Consent comes first
A user must approve access before any verification returns personal data. The flow is two-phase and works the same for KYC and MoMo:
- Call the endpoint without a
consent_tokenbut with aredirect_uri. You get back a hosted consent URL. - Send your customer to that URL. They see exactly what's being requested and can allow or deny.
- On approval, Gateway redirects to your
redirect_uriwith?consent_code=…(denial comes back as?error=access_denied). - Retry the original request with
consent_tokenset to that code. It now runs end-to-end.
consent_required (403) by running the consent flow again.Ghana Card KYC
POST/link/api/v1/kyc
Scope: link:kyc.
| Field | Type | Description |
|---|---|---|
| ghana_card_number * | string | Format GHA-XXXXXXXXX — GHA-, then 7–10 letters/digits. Case-insensitive. |
| consent_token | string | The consent_code from your redirect, once the user approved. Omit on the first call. |
| redirect_uri | string | Required on the first call so Gateway can build the consent URL. |
| state | string | Optional, echoed back through the consent redirect. |
First call (no consent yet) — 202:
curl https://aethelos-gateway.vercel.app/link/api/v1/kyc \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ghana_card_number": "GHA-123456789", "redirect_uri": "https://app.example.com/link/callback"}'{
"request_id": "e4a1b27c...",
"status": "pending_consent",
"consent_url": "https://aethelos-gateway.vercel.app/link/consent?consent_code=lc_7f2...&client_id=...",
"consent_code": "lc_7f2a..."
}After approval, the retry with consent_token returns 200 when completed:
{
"request_id": "e4a1b27c...",
"status": "completed",
"consent_id": "f0c9...",
"result": {
"full_name": "Kwame Asante",
"date_of_birth": "1991-07-14",
"gender": "male",
"nationality": "Ghanaian",
"verification_score": 0.97,
"legal_status": "active"
},
"completed_at": "2026-09-25T10:12:41Z"
}| Field | Type | Description |
|---|---|---|
| verification_score | number | Match confidence, 0 to 1. |
| legal_status | string | National-records standing, e.g. active or suspended. |
A failed verification returns 202 with status: "failed" and an error_code / error_message pair instead of a result.
Poll a KYC request
GET/link/api/v1/kyc/{request_id}
Same response body as above, plus created_at. Useful if the initial call returned 202 with a still-processing status.
Mobile Money verification
POST/link/api/v1/momo
Scope: link:momo. Same two-phase consent flow as KYC.
| Field | Type | Description |
|---|---|---|
| phone_number * | string | +233XXXXXXXXX or 0XXXXXXXXX. |
| date_from * | string | Statement start, YYYY-MM-DD. |
| date_to * | string | Statement end, YYYY-MM-DD. Max 365 days, never in the future. |
| consent_token | string | Same as KYC — omit on the first call. |
| redirect_uri | string | Required on the first call. |
When completed, the result summarizes the statement:
{
"request_id": "c88d0f...",
"status": "completed",
"consent_id": "f0c9...",
"result": {
"provider": "mtn",
"total_transactions": 214,
"total_credits": 12480.5,
"total_debits": 11311.2,
"closing_balance": 1169.3,
"credit_score_hint": 71.4
},
"completed_at": "2026-09-25T10:14:02Z"
}| Field | Type | Description |
|---|---|---|
| provider | string | mtn, vodafone, or airteltigo. |
| credit_score_hint | number | A 0–100 starting point for your own scoring, based on credit inflow. It's a hint, not a decision. |
Poll a MoMo request
GET/link/api/v1/momo/{request_id}
Same shape as the completed response above, plus created_at.
List verifications
GET/link/api/v1/verifications
Scope: link:read.
| Field | Type | Description |
|---|---|---|
| type | string | all (default), kyc, or momo. |
| status | string | Filter by status, e.g. completed. |
| limit | number | Default 50, max 100. |
| offset | number | Pagination offset. |
{
"verifications": [
{
"type": "kyc",
"request_id": "e4a1b27c...",
"status": "completed",
"ghana_card_number": "GHA-123456789",
"full_name": "Kwame Asante",
"verification_score": 0.97,
"created_at": "2026-09-25T10:11:00Z",
"completed_at": "2026-09-25T10:12:41Z"
}
],
"total": 1
}Revoke a consent
POST/link/api/v1/consent/{id}/revoke
Scope: link:read. Returns { "revoked": true, "consent_id": "..." }.
You can only revoke your own consents, and only while they're granted — anything else is a 404 or a 409. After revocation, the next verification request gets consent_required until the user approves again.
Sandbox
New projects run in mock mode: the endpoints behave exactly as documented but return deterministic sample data (Ghanaian names, generated balances, stable scores derived from the card number), so you can build the whole integration before production credentials are switched on. Your dashboard shows which mode each integration is in.