Wallet
Read a user's Jami wallet balance and send money on their behalf — authorized over OAuth 2.1 / OpenID Connect.
The Wallet API lets a connected app work with a Jami user's own wallet, with the user's consent:
GET /api/oauth/wallet— read the balance (scopewallet:read).POST /api/oauth/wallet/send— send money from the signed-in user's wallet to another Jami user (scopewallet:send).
wallet:send is a peer transfer: both legs are Jami user wallets — the amount leaves the
signed-in user's balance and lands in the recipient's. There is no partner-funded credit, no
withdrawal, and no debit of your own balance in v1. The sending user must have approved
KYC (the same bar as a withdrawal).
All amounts are integer ETB minor units (santim): 50000 means ETB 500.00.
Wallet vs. Withdrawals
These are different surfaces. Withdrawals pay out your organization's earnings using your JamiDev API token. The Wallet API acts on an end user's wallet after they sign in and consent — it's authorized with an OAuth token, not just your API key.
How the connection works (OAuth 2.1 / OIDC)
The wallet endpoints are resource endpoints of Jami's OAuth 2.1 / OpenID Connect
provider (issuer: https://jami.bio/api/auth). To call them your app runs the standard
authorization-code + PKCE flow, asks for the wallet scopes, and gets back a resource-bound
JWT access token it presents to the wallet endpoints.
Registration, the consent screen, PKCE, JWKS and refresh-token rotation are shared with Sign in with Jami — read that page for the full provider reference and to create your app. The wallet-specific wiring is below.
- 1Your apphover ⓘ
Send the user to authorize
Authorization-code + PKCE (S256), scope=openid wallet:read wallet:send offline_access. Jami shows the consent screen. - redirect back2Jamihover ⓘ
User consents
Jami redirects to your redirect_uri with a single-use code, plus iss and your state. - POST /oauth2/token3Your apphover ⓘ
Exchange the code with resource
Include resource=https://jami.bio so Jami mints a resource-bound JWT the wallet endpoints accept. - two factors4Your apphover ⓘ
Call the wallet
Send the access token (Authorization: Bearer) AND your JamiDev API key (X-JamiDev-Key) on every wallet request.
Scopes
| Scope | Grants | Consent screen |
|---|---|---|
wallet:read | Read the user's Jami wallet balance | "See your Jami wallet balance" |
wallet:send | Send money from the user's wallet to another Jami user | "Send money from your Jami wallet" |
offline_access | A rotating refresh token, to keep access past 1 hour | "Stay signed in" |
wallet:read is self-serve — request it when you register your app. wallet:send moves
real money out of the user's wallet, so Jami grants it on approval only and limits it to
confidential (server) clients — a leaked send token could drain the user, so a
public/SPA/native client can never hold it. Users can decline individual scopes on the
consent screen, so check the scope value returned with the token rather than assuming you
got everything you asked for.
1. Send the user to authorize
A normal OAuth 2.1 authorization request — PKCE (S256) is mandatory, confidential
clients included. Generate a fresh code_verifier (43–128 chars) and state per attempt and
store both against the user's session.
GET https://jami.bio/api/auth/oauth2/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https%3A%2F%2Fyour-app.com%2Fcallback
&scope=openid%20wallet%3Aread%20wallet%3Asend%20offline_access
&state=RANDOM_PER_ATTEMPT
&code_challenge=BASE64URL(SHA256(verifier))
&code_challenge_method=S256On the callback, verify state matches and iss is exactly https://jami.bio/api/auth
before continuing. A declined consent returns ?error=access_denied.
2. Exchange the code for a resource-bound token
The wallet endpoints require a JWT access token bound to the Jami API audience. Include
a resource parameter when you exchange the code (and on every refresh):
POST https://jami.bio/api/auth/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=<code>
&redirect_uri=<redirect_uri>
&client_id=<client_id>
&client_secret=<client_secret>
&code_verifier=<verifier>
&resource=https://jami.bio{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid wallet:read wallet:send offline_access",
"id_token": "eyJhbGciOiJSUzI1NiIs...",
"refresh_token": "..."
}resource is what makes the token work
Without resource=https://jami.bio, Jami returns an opaque token that the wallet
endpoints reject with 401 invalid_token ("not bound to this resource"). Always send it —
the SDK does this for you.
Public clients omit client_secret and authenticate with PKCE alone — but note wallet:send
is confidential-only, so a public client can only ever hold wallet:read.
3. Refreshing
Access tokens live 1 hour. When offline_access was granted you get a refresh token;
it's rotated on every use, so store the new one and discard the old (reusing a consumed
refresh token is treated as a compromise signal). Keep sending resource so the refreshed
token stays bound:
POST https://jami.bio/api/auth/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=<refresh_token>
&client_id=<client_id>
&client_secret=<client_secret>
&resource=https://jami.bioThe second factor: your JamiDev API key
Every wallet call is authenticated by two factors, and Jami requires both:
- The user's OAuth token — the resource-bound Bearer token from above, in the
Authorizationheader. It proves the user consented to this scope. - Your JamiDev API key — your
jamidev_live_…/jamidev_test_…key (the same one the SDK uses), in theX-JamiDev-Keyheader. It proves which registered JamiDev product is acting, so every wallet access is attributable to an organization.
Omit the key and the call is rejected with 401 { "error": "jamidev_key_required" } — even
if the OAuth token is perfectly valid.
Endpoints
| Purpose | Endpoint | Scope |
|---|---|---|
| Read balance | GET https://jami.bio/api/oauth/wallet | wallet:read |
| Send | POST https://jami.bio/api/oauth/wallet/send | wallet:send |
The OAuth endpoints they build on — /oauth2/authorize, /oauth2/token, /oauth2/userinfo,
/oauth2/introspect, /oauth2/revoke, /jwks — are documented under
Sign in with Jami.
Read the balance
GET https://jami.bio/api/oauth/wallet
Authorization: Bearer <access_token>
X-JamiDev-Key: jamidev_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx{ "currency": "ETB", "availableMinor": 125000, "totalMinor": 125000 }availableMinoris what the user can spend right now, in santim.totalMinoris their lifetime total earned.
Send to another Jami user
Address the recipient by Jami handle (recipientHandle) or directly by their Jami user
id (recipientUserId) — send exactly one.
POST https://jami.bio/api/oauth/wallet/send
Authorization: Bearer <access_token>
X-JamiDev-Key: jamidev_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
{ "recipientHandle": "abebe", "amountMinor": 5000, "idempotencyKey": "tip-8b21f0a4" }The idempotency key (≥ 8 chars) is required. Reuse the same key when you retry — a repeated key returns the original transfer instead of sending twice.
{
"transfer": {
"id": "665f...",
"amountMinor": 5000,
"currency": "ETB",
"recipientUserId": "69a809e5f8d34b98fe901a90",
"status": "completed",
"createdAt": "2026-08-21T12:00:00.000Z"
},
"deduped": false
}201 on a fresh transfer, 200 on a deduped retry ("deduped": true, no money moved).
Limits and errors
- Per transfer: min ETB 1.00, max ETB 10,000.00. Per (user, app): ETB 50,000.00 / rolling 24h.
- The sending user must have approved KYC; self-transfers are rejected.
- Bearer errors (
invalid_token,insufficient_scope) carry an RFC 6750WWW-Authenticateheader.
| Status | error | Meaning |
|---|---|---|
| 401 | invalid_token | Missing/expired token, bad signature, or an opaque (non-resource) token |
| 401 | jamidev_key_required | The X-JamiDev-Key header is missing or not a valid JamiDev API key |
| 403 | insufficient_scope | Token lacks the required scope (or wallet:send on a public client) |
| 403 | kyc_required | The sending user has no approved KYC |
| 400 | invalid_amount / amount_too_small / amount_too_large / invalid_idempotency_key | Fix the request |
| 400 | self_transfer | Recipient resolves to the sender |
| 404 | recipient_not_found | No Jami user matches the handle or id |
| 402 | insufficient_funds | The sender's wallet balance does not cover the transfer |
| 429 | daily_cap_exceeded | The per-(user, app) daily transfer cap would be exceeded |
With the SDK
The jami-sdk ships a JamiWallet client that runs
the whole flow — it builds the PKCE authorize URL, exchanges and refreshes resource-bound
tokens, and sends both factors on every call:
import { JamiWallet } from 'jami-sdk';
const wallet = new JamiWallet({
apiKey: process.env.JAMI_API_KEY!, // → X-JamiDev-Key
clientId: process.env.JAMI_CLIENT_ID!,
clientSecret: process.env.JAMI_CLIENT_SECRET!,
redirectUri: 'https://yourapp.com/callback',
});
// 1. redirect the user
const auth = await wallet.createAuthorization(); // persist auth.state + auth.codeVerifier
// 2. on the callback, after checking state:
const tokens = await wallet.exchangeCode({ code, codeVerifier });
// 3. use the wallet
const balance = await wallet.getBalance(tokens.accessToken);
const { transfer } = await wallet.send(tokens.accessToken, {
recipientHandle: 'abebe',
amountMinor: 5000,
idempotencyKey: crypto.randomUUID(),
});See the SDK guide for refresh handling and the typed
JamiWalletError codes.
