SDK
The official jami package for Node, edge runtimes and browsers.
The official TypeScript SDK wraps the whole REST API in a typed, zero-dependency
client. It runs anywhere fetch exists: Node 18+, Cloudflare Workers, Vercel Edge,
Deno, Bun, and modern browsers.
Install
npm install jami-sdkSetup
Create a token in Developer → API Tokens and construct the client:
import { Jami } from 'jami-sdk';
const jami = new Jami({ token: process.env.JAMI_TOKEN! });
jami.environment; // 'test' (jamidev_test_…) or 'live' (jamidev_live_…)The token shape is validated in the constructor. Amounts everywhere are integer
ETB minor units: 10000 = 100.00 ETB.
Create a checkout
const checkout = await jami.createCheckout({
productId: 'p_GltCYuOxxov', // the product id from your dashboard (the raw ObjectId also works)
customer: { email: 'buyer@example.com', phone: '251912345678' },
gateway: 'telebirr', // 'telebirr' | 'mpesa' | 'cbe'
// amount: 15000, // pay-what-you-want products only
});
if ('orderId' in checkout) {
// Free product — completed instantly.
} else if (checkout.mode === 'redirect') {
// Send the buyer to the hosted payment page.
location.href = checkout.checkoutUrl!;
} else {
// mode === 'direct' — the buyer confirms on their phone; poll below.
}Wait for the payment
waitForCheckout polls the status endpoint (which actively reconciles with the
payment provider server-side) until the session completes, expires, or the timeout
elapses — it never spins forever:
const status = await jami.waitForCheckout(checkout.sessionId, {
intervalMs: 2000, // default
timeoutMs: 300_000, // default 5 minutes; rejects with code 'poll_timeout'
});
if (status.status === 'completed') {
console.log('paid!', status.orderId);
}One-shot polling is jami.getCheckoutStatus(sessionId).
List orders
const { items, total } = await jami.listOrders({ status: 'paid', page: 1, limit: 20 });
// items[].productId / customerId come back populated ({ title } / { email, name })Withdrawals
Pay out your org's earnings — the 7% usage fee is cut from the amount you withdraw,
so you receive the net. getBalance is your org's own earnings (the withdrawal
ceiling); funds leave the owner's unified wallet. Requires a production org with
Developer Mode + approved KYC; minimum 100.00 ETB (10000 santim), and a rolling-24h
cap of 100,000 ETB per org. Amounts ≤ 10,000 ETB auto-pay; larger go to manual
review. Preview the cut offline with Jami.computeWithdrawalQuote:
const quote = Jami.computeWithdrawalQuote(10000);
// → { amount: 10000, feeAmount: 700, taxAmount: 0, netAmount: 9300, feeRate: 0.07, taxRate: 0 }
const { balanceMinor } = await jami.getBalance(); // your org's earnings
const withdrawal = await jami.createWithdrawal({
amount: 10000, // gross santim (100 ETB); the fee is cut from this
gateway: 'telebirr', // 'telebirr' | 'mpesa' | 'cbe'
account: '251911223344',
idempotencyKey: 'payout-2026-08-12-001', // optional; makes retries safe
});
withdrawal.status; // auto-paid → 'processing' | 'completed'; held → 'pending'
withdrawal.netAmount; // 9300 — what reaches the account
await jami.getWithdrawal(withdrawal._id);
await jami.listWithdrawals({ status: 'completed', limit: 20 });See the Withdrawals API for the full contract.
Sign in with Jami + Wallet
JamiWallet is a separate surface from the Jami org client above. Here an end user
signs in through Jami's OAuth consent screen and grants your app scoped access to their
own Jami wallet — read the balance (wallet:read) and send money to other Jami users
(wallet:send). It's a confidential OAuth client, so the secret and the user's tokens
stay on your server. See the Wallet page for the API contract and the
OAuth/OIDC connection, and Sign in with Jami for the provider,
registration, and consent details.
Accessing a user's wallet needs two things, and the SDK sends both on every wallet call:
- The user's OAuth consent — the resource-bound access token from the flow below.
- Your JamiDev API key (
apiKey) — the samejamidev_live_…/jamidev_test_…key as the org client. It travels in theX-JamiDev-Keyheader so Jami knows which product is acting; without it the call is rejected.
import { JamiWallet } from 'jami-sdk';
const wallet = new JamiWallet({
apiKey: process.env.JAMI_API_KEY!, // identifies your JamiDev product
clientId: process.env.JAMI_CLIENT_ID!, // your confidential OAuth client
clientSecret: process.env.JAMI_CLIENT_SECRET!,
redirectUri: 'https://yourapp.com/callback',
});1. Send the user to sign in
createAuthorization builds the authorize URL and generates a fresh PKCE verifier, state,
and nonce. Persist state and codeVerifier in the user's session — you need them on
the callback.
const auth = await wallet.createAuthorization();
// store auth.state + auth.codeVerifier in the session, then:
redirect(auth.url);2. Handle the callback
Reject the request unless the returned state matches what you stored, then exchange the
code for a resource-bound token set:
const tokens = await wallet.exchangeCode({ code, codeVerifier }); // { accessToken, refreshToken, expiresAt, idToken, … }
const me = wallet.parseIdentity(tokens.idToken); // { sub, name, handle, … } for displayRefresh when the access token nears expiry (tokens.expiresAt is epoch ms, computed ~30s
early); the resource binding is preserved:
if (tokens.expiresAt && Date.now() >= tokens.expiresAt) {
tokens = await wallet.refresh(tokens.refreshToken!); // rotates — persist the new set
}3. Read and send
Amounts are integer santim. send is idempotent on idempotencyKey — a reused key
returns the original transfer with deduped: true and moves no money.
const balance = await wallet.getBalance(tokens.accessToken);
// → { currency: 'ETB', availableMinor: 125000, totalMinor: 125000 }
const { transfer, deduped } = await wallet.send(tokens.accessToken, {
recipientHandle: 'abebe', // or recipientUserId — provide exactly one
amountMinor: 5000, // 50.00 ETB
idempotencyKey: crypto.randomUUID(),
});
// transfer.status === 'completed'; deduped === false on a fresh sendThe SDK always requests resource=<issuer> so Jami mints a verifiable, resource-bound token
the wallet endpoints accept (an unbound token is rejected) — you never construct that string.
The sender must have approved KYC to send. Business failures raise JamiWalletError
with the server's code (kyc_required, insufficient_funds, daily_cap_exceeded,
recipient_not_found, self_transfer, …); a bad/expired token or a missing JamiDev key
raises JamiAuthError (401).
Verify webhooks
Timing-safe HMAC verification with a replay guard — pass the raw request body:
import { JamiSignatureError } from 'jami-sdk';
const event = await jami.webhooks.verify({
payload: rawBody, // string, exactly as received
signature: req.headers['x-jamidev-signature'],
secret: process.env.JAMI_WEBHOOK_SECRET!, // per-subscription secret
});
switch (event.type) {
case 'order.completed': /* fulfill */ break;
case 'benefit.granted': /* deliver the perk */ break;
}Throws JamiSignatureError on any mismatch, malformed header, stale timestamp
(default tolerance 300 s), or unknown event type — treat those deliveries as untrusted.
Errors
Every failure is a typed error extending JamiError (status, code, requestId?):
| Error | When |
|---|---|
JamiValidationError | 400 — the message names the offending field |
JamiAuthError | 401 — invalid/revoked token; .hint is set when the token was minted for the org's other environment (re-issue it) |
JamiRateLimitError | 429 — checkout is limited to 20 req / 5 min / IP |
JamiError | everything else (404, 502, timeouts) |
JamiSignatureError | webhook verification failed |
import { JamiAuthError } from 'jami-sdk';
try {
await jami.listOrders();
} catch (err) {
if (err instanceof JamiAuthError && err.hint) {
// The org switched environment — create a fresh token in the dashboard.
}
}Notes
- The SDK adds no automatic retries — the status endpoint self-reconciles, and
waitForCheckoutis the only loop (always bounded bytimeoutMs/AbortSignal). - Endpoint-by-endpoint details live in the API Reference.
