JamiDev

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 (scope wallet:read).
  • POST /api/oauth/wallet/send — send money from the signed-in user's wallet to another Jami user (scope wallet: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.

Authorizing a wallet callYour appJami
  1. 1
    Your apphover ⓘ

    Send the user to authorize

    Authorization-code + PKCE (S256), scope=openid wallet:read wallet:send offline_access. Jami shows the consent screen.
  2. redirect back
    2
    Jamihover ⓘ

    User consents

    Jami redirects to your redirect_uri with a single-use code, plus iss and your state.
  3. POST /oauth2/token
    3
    Your apphover ⓘ

    Exchange the code with resource

    Include resource=https://jami.bio so Jami mints a resource-bound JWT the wallet endpoints accept.
  4. two factors
    4
    Your apphover ⓘ

    Call the wallet

    Send the access token (Authorization: Bearer) AND your JamiDev API key (X-JamiDev-Key) on every wallet request.

Scopes

ScopeGrantsConsent screen
wallet:readRead the user's Jami wallet balance"See your Jami wallet balance"
wallet:sendSend money from the user's wallet to another Jami user"Send money from your Jami wallet"
offline_accessA 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=S256

On 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.bio

The second factor: your JamiDev API key

Every wallet call is authenticated by two factors, and Jami requires both:

  1. The user's OAuth token — the resource-bound Bearer token from above, in the Authorization header. It proves the user consented to this scope.
  2. Your JamiDev API key — your jamidev_live_… / jamidev_test_… key (the same one the SDK uses), in the X-JamiDev-Key header. 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

PurposeEndpointScope
Read balanceGET https://jami.bio/api/oauth/walletwallet:read
SendPOST https://jami.bio/api/oauth/wallet/sendwallet: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 }
  • availableMinor is what the user can spend right now, in santim.
  • totalMinor is 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 6750 WWW-Authenticate header.
StatuserrorMeaning
401invalid_tokenMissing/expired token, bad signature, or an opaque (non-resource) token
401jamidev_key_requiredThe X-JamiDev-Key header is missing or not a valid JamiDev API key
403insufficient_scopeToken lacks the required scope (or wallet:send on a public client)
403kyc_requiredThe sending user has no approved KYC
400invalid_amount / amount_too_small / amount_too_large / invalid_idempotency_keyFix the request
400self_transferRecipient resolves to the sender
404recipient_not_foundNo Jami user matches the handle or id
402insufficient_fundsThe sender's wallet balance does not cover the transfer
429daily_cap_exceededThe 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.

On this page