jami-sdk
v0.4.0
Published
Official TypeScript SDK for JamiDev — Jami's payments & product API for Ethiopian developers. Server: create checkouts, poll status, list orders, withdraw, verify webhooks, and drive a user's Jami wallet over OAuth. Browser: a zero-secret storefront that
Maintainers
Readme
jami-sdk
Official TypeScript SDK for JamiDev — Jami's payments & product API for developers in Ethiopia. Sell digital products and get paid in ETB through Telebirr, M-Pesa, and CBE Birr, with a hosted checkout, webhooks, and a free sandbox.
- Zero dependencies — a thin
fetchwrapper that works in Node 18+, edge runtimes (Cloudflare Workers, Vercel Edge), and modern browsers. - Fully typed: discriminated results, typed webhook events, typed errors.
- Money is always integer ETB minor units (santim):
10000= 100.00 ETB. Buyers pay exactly the listed price — taxes (government tax + 7% JamiDev usage fee) are deducted at withdrawal, never added at checkout.
Install
npm install jami-sdkQuickstart
Create an API token in the JamiDev dashboard (Developer → API Tokens). Sandbox
organizations issue jamidev_test_… tokens, production organizations jamidev_live_….
import { Jami } from 'jami-sdk';
const jami = new Jami({ token: process.env.JAMI_TOKEN! });
// 1. Create a checkout for one of your products
const checkout = await jami.createCheckout({
productId: '665f1c2e8b1a2f0012ab34cd', // the product's id (not the public p_… token)
customer: { email: '[email protected]', phone: '251911223344' },
gateway: 'telebirr', // 'telebirr' | 'mpesa' | 'cbe'
});
if ('orderId' in checkout) {
// Free product — completed instantly.
console.log('order', checkout.orderId);
} else if (checkout.mode === 'redirect') {
// Send the buyer to the hosted payment page.
console.log('redirect to', checkout.checkoutUrl);
} else {
// mode === 'direct': the buyer confirms on their phone — poll until done.
const status = await jami.waitForCheckout(checkout.sessionId);
console.log(status.status, status.orderId); // 'completed', 'ord_…'
}
// 2. List orders
const { items, total } = await jami.listOrders({ status: 'paid', limit: 20 });
console.log(`${total} paid orders`, items[0]?.amount); // amount in santimSandbox testing
Use a jamidev_test_ token from a sandbox organization. Magic phone numbers:
251900000001 → instant success, 251900000002 → instant failure. No real money moves.
Withdrawals
Pay out your organization's earnings to a Telebirr, M-Pesa, or CBE account. The 7%
JamiDev usage fee is cut from the amount you withdraw (not added on top) — you
withdraw amount, the fee (and any tax) is deducted, and the destination receives
the net. Preview the cut offline with Jami.computeWithdrawalQuote; the server
resolves your org's real rate and is authoritative.
getBalance reports your org's own earnings — that's the ceiling on what you can
withdraw. The funds themselves leave the owner's unified Jami wallet, so a withdrawal
can still fail with 402 if that wallet was drawn down elsewhere even when the
balance looked sufficient.
Requires a production org with Developer Mode on and approved KYC;
minimum withdrawal is 100.00 ETB (10000 santim). Withdrawals of 10,000 ETB
or less are paid out automatically; larger amounts are held for manual review. A
rolling-24h cap of 100,000 ETB per org returns 429 past the limit.
import { Jami } from 'jami-sdk';
const jami = new Jami({ token: process.env.JAMI_TOKEN! });
// 1. Preview what reaches the destination (pure — no request).
const quote = Jami.computeWithdrawalQuote(10_000); // withdraw 100.00 ETB (the minimum)
// → { amount: 10000, feeAmount: 700, taxAmount: 0, netAmount: 9300, feeRate: 0.07, taxRate: 0 }
// 2. Make sure your org earnings cover the gross amount.
const { balanceMinor } = await jami.getBalance(); // santim — your org's earnings
if (balanceMinor < 10_000) throw new Error('insufficient balance');
// 3. Withdraw. Pass an idempotencyKey so a retry never double-withdraws.
const withdrawal = await jami.createWithdrawal({
amount: 10_000, // gross santim; 7% is cut from this
gateway: 'telebirr', // 'telebirr' | 'mpesa' | 'cbe'
account: '251911223344', // the payout phone
idempotencyKey: 'payout-2026-08-12-001',
});
console.log(withdrawal.status, withdrawal.netAmount); // 'completed', 9300Auto-paid withdrawals (≤ 10,000 ETB) come back processing/completed immediately.
Larger ones land as pending, are reviewed (reviewing/approved/rejected), then
paid out (processing → completed). Track completion via the withdrawal.paid /
withdrawal.failed webhooks (below), or poll jami.getWithdrawal(id).
The 7% fee is money JamiDev keeps; santim rounding is round-half-up (
round(amount * 0.07)). Checkout prices are unaffected — buyers still pay exactly the listed price; fees only apply at withdrawal.
Sign in with Jami + Wallet
JamiWallet is a second, separate surface from the 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 client: the
secret and the user's tokens stay on your server.
Accessing a user's wallet needs two things, and the SDK enforces both:
- 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 on every wallet call so Jami knows which product is acting; without it the call is rejected. This is how wallet access is attributed to a registered JamiDev organization.
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. Redirect the user to sign in. Persist state + codeVerifier in their session.
const auth = await wallet.createAuthorization();
redirect(auth.url);
// 2. On the callback: reject 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 });
// 3. Use the wallet.
const balance = await wallet.getBalance(tokens.accessToken); // { availableMinor, totalMinor, currency }
const { transfer, deduped } = await wallet.send(tokens.accessToken, {
recipientHandle: 'abebe', // or recipientUserId
amountMinor: 5000, // 50.00 ETB, in santim
idempotencyKey: crypto.randomUUID(),
});The SDK always requests resource=<issuer> so Jami mints a verifiable, resource-bound
access token the wallet endpoints accept (an unbound token is rejected) — you never
construct that string. The sender must have approved KYC to send. Errors raise
JamiWalletError (with the server's code: kyc_required, insufficient_funds,
daily_cap_exceeded, recipient_not_found, …) or JamiAuthError (401).
Storefront (browser)
JamiStorefront lets a brand sell their published JamiDev products from their own
website, settling money into their Jami wallet — with no secret in the browser and
no server in the middle for card checkout. It ships two ways: import it in your
frontend bundle, or drop in the standalone <script> build that exposes a JamiStore
global.
- Hosted checkout (card): redirects the buyer to Jami's hosted checkout page for a
public product token (
p_…) or checkout-link token (cl_…). Fully browser-direct. - Pay with Jami wallet: redirects to a confidential server you host (
walletPay) that runs the buyer's Sign in with Jami +wallet:send. Needed becausewallet:sendis a confidential-client scope — a browser can't hold that secret.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jami-storefront.global.js"></script>
<div id="shop"></div>
<script>
const { JamiStorefront, mountStorefront } = window.JamiStore;
const store = new JamiStorefront({
// Omit walletPay to offer hosted card checkout only.
walletPay: { endpoint: 'https://brands.jami.bio/pay/wallet', recipient: 'mybrand' }
});
// Built-in dependency-free grid…
mountStorefront('#shop', {
storefront: store,
products: [{ token: 'p_AbC123', title: 'Leather Bag', priceEtb: 2500, image: '…' }]
});
// …or wire your own UI:
store.checkout('p_AbC123'); // → hosted checkout
store.payWithWallet({ amountMinor: 250000, reference: 'p_AbC123' }); // → wallet server
</script>Or in a bundler: import { JamiStorefront, mountStorefront } from 'jami-sdk/storefront'.
Products are yours to supply (token + display fields) — the storefront never needs your
API token or a product-list call. Get a product's public p_… token from the JamiDev
dashboard; publish the product first (only published products resolve at checkout).
Wallet-pay endpoint contract. When walletPay is set, the wallet button redirects
to GET {endpoint}?recipient=<handle>&amountMinor=<santim>&reference=<token>&return_url=<url>.
That server (a confidential OAuth client, e.g. your Brand Hub) signs the buyer in with
Jami, performs wallet:send to recipient, records the order, and redirects to
return_url. Card checkout needs no such server.
Webhooks
JamiDev signs every delivery with X-JamiDev-Signature: t=<unix>,v1=<hmac> —
HMAC-SHA256 of "{t}.{rawBody}" with your subscription secret. Verify with the raw,
unparsed request body:
import { Jami, JamiSignatureError } from 'jami-sdk';
const jami = new Jami({ token: process.env.JAMI_TOKEN! });
app.post('/webhooks/jami', express.raw({ type: 'application/json' }), async (req, res) => {
try {
const event = await jami.webhooks.verify({
payload: req.body.toString('utf8'), // RAW body — never re-serialize
signature: req.header('X-JamiDev-Signature')!,
secret: process.env.JAMI_WEBHOOK_SECRET!, // per-subscription secret
});
switch (event.type) {
case 'order.completed':
// fulfill: event.data holds the order payload; event.livemode is false for sandbox.
// event.data.sessionId is the same id createCheckout returned — use it to
// correlate the event back to the checkout you started.
break;
case 'withdrawal.paid':
// a payout settled: event.data holds the withdrawal payload
break;
case 'order.created':
case 'benefit.granted':
case 'checkout.session.expired':
case 'withdrawal.failed':
break;
}
res.sendStatus(200);
} catch (err) {
if (err instanceof JamiSignatureError) return res.sendStatus(400);
throw err;
}
});Events: order.created · order.completed · benefit.granted · checkout.session.expired ·
withdrawal.paid · withdrawal.failed. Every order/benefit/session event carries
data.sessionId — the same id createCheckout returned — so you can correlate a delivery
back to its checkout without storing an orderId mapping yourself.
Failed deliveries retry automatically (1m → 5m → 30m → 2h → 12h, 5 attempts).
API
| Method | Description |
| --- | --- |
| new Jami({ token, baseUrl?, fetch? }) | Token is shape-validated; environment is derived from its prefix. |
| jami.createCheckout(params) | POST /checkout. Paid → { sessionId, checkoutUrl, mode }; free → { sessionId, orderId }. |
| jami.getCheckoutStatus(sessionId) | GET /checkout/{id}. Server-side reconciliation means polling always resolves. |
| jami.waitForCheckout(sessionId, { intervalMs?, timeoutMs?, signal? }) | Polls until completed/expired/order; rejects with code: 'poll_timeout' after timeoutMs (default 5 min). |
| jami.listOrders({ page?, limit?, status? }) | GET /orders — newest first, limit ≤ 100. |
| Jami.computeWithdrawalQuote(amount, rates?) | Pure/offline. Returns { amount, feeAmount, taxAmount, netAmount, feeRate, taxRate } — the 7% fee cut from amount. |
| jami.getBalance() | GET /balance. { balanceMinor, eligible, currency } in santim — your org's own earnings. |
| jami.createWithdrawal({ amount, gateway, account, idempotencyKey?, metadata? }) | POST /withdrawals. Debits amount (capped at org earnings), cuts the 7% fee; you receive netAmount. 402 if wallet funds fall short, 429 past the 100k ETB/24h org cap. |
| jami.getWithdrawal(id) | GET /withdrawals/{id}. |
| jami.listWithdrawals({ page?, limit?, status? }) | GET /withdrawals — newest first, limit ≤ 100. |
| jami.webhooks.verify({ payload, signature, secret, toleranceSec? }) | Timing-safe HMAC verify + replay guard (default tolerance 300 s). Returns the typed event. |
Errors
All requests throw typed errors extending JamiError (status, code, requestId?):
JamiValidationError(400) — bad request body.JamiAuthError(401) — invalid/revoked token. When the token was minted for the org's other environment,.hinttells you to re-issue it.JamiRateLimitError(429) — checkout is limited to 20 requests / 5 min per IP.JamiSignatureError— webhook verification failed; treat the delivery as untrusted.
Notes
- Checkout sessions expire after 30 minutes.
productIdis the product's database id, visible in the dashboard — not the publicp_…checkout token.- The SDK never retries automatically;
waitForCheckoutis the only loop and it always terminates.
Docs & support
Full docs: the JamiDev developer documentation. Questions: [email protected].
MIT © Jami
