# Checkout Links & Embed (/docs/checkout-links) ## Checkout links [#checkout-links] A checkout link is a **long-lived entity** (unlike checkout sessions, which expire in 30 minutes). Create them in **Store → Checkout Links**: ``` https://jami.bio/jamidev/c/cl_Q4sp2Xr5ybOi ``` * **One product** → the link goes straight to that product's checkout. * **Several products** → buyers see a picker and choose one per checkout. * **Metadata** on the link is merged into the order's metadata (link keys win over product keys) — perfect for campaign attribution. * **Success URL** (optional) sends buyers back to your site after paying, with the `{CHECKOUT_ID}` placeholder replaced by the checkout session id: ``` https://yoursite.com/thanks?checkout={CHECKOUT_ID} ``` Deactivating a link makes its URL stop resolving immediately; it can be reactivated later. ## Redirect resolution [#redirect-resolution] After a payment finishes, buyers land on Jami's hosted result page, which then forwards them (with a short interstitial) using this precedence: 1. `successUrl` passed to the [checkout API](/docs/api/checkout) by an authenticated caller 2. the checkout link's success URL 3. your organization's default redirect URLs (**Settings → Buyer redirect URLs** — success, cancel and failure each configurable) 4. Jami's branded result page Merchant URLs are validated when saved and never read from query parameters, so the flow can't be abused as an open redirect. ## Embedded checkout [#embedded-checkout] Sell from your own site without redirecting visitors away. Add the script once and mark any element: ```html Buy now ``` Clicking the element opens checkout in a **centered overlay iframe** — styled however you like, as long as the `data-jami-checkout` attribute stays. Optional `data-jami-checkout-theme="light" | "dark"` passes a theme hint. ### Events [#events] The embed dispatches `CustomEvent`s on `window`: ```js window.addEventListener('jami:checkout:loaded', () => console.log('checkout visible')); window.addEventListener('jami:checkout:success', (e) => { console.log('paid! order:', e.detail.orderId); }); window.addEventListener('jami:checkout:close', () => console.log('dismissed')); ``` `jami:checkout:success` fires as soon as the payment confirms — including for direct (phone-confirmed) payments — so you can unlock content without waiting for your webhook. Treat it as a UX signal only; **authorize on your backend via webhooks**, never from the browser event. # Introduction (/docs) Fetch the complete documentation index at: [`https://dev.jami.bio/llms.txt`](https://dev.jami.bio/llms.txt) Use this file to discover all available pages before exploring further. For every page's raw markdown source, append `.md` to its URL (e.g. `https://dev.jami.bio/docs/quickstart.md`), or fetch the whole docs set in one call at [`https://dev.jami.bio/llms-full.txt`](https://dev.jami.bio/llms-full.txt). JamiDev is Jami's product-and-payments layer for developers. You create products, share a checkout link (or embed checkout on your own site), and JamiDev handles the payment through local Ethiopian gateways, grants benefits like license keys, notifies your backend with signed webhooks, and emails receipts — all settled in **ETB**. ## AI agent integration prompt [#ai-agent-integration-prompt] Paste the prompt below into Claude Code, Cursor, Windsurf, Codex or another coding agent to scaffold a full JamiDev integration — sandbox setup, checkout, webhook verification and going live. ````md title="AI Agent Integration Prompt" # Goal — ship your first JamiDev checkout end-to-end You are integrating JamiDev (https://dev.jami.bio/docs) into my project. Take me from zero to a working checkout: product, checkout session, payment, webhook, fulfilled order. ## My application context Stack: [framework + language, e.g. Next.js 15 + TypeScript] Surface: [web | backend-only | mobile webview] Use case: [one-time digital product | subscription | donation] Database: [Postgres | MySQL | Mongo] ## Steps 1. **Enable Developer Mode & create a sandbox org** — requires a KYC-verified Jami account. In the dashboard: Settings → Developer Mode, then switch to a sandbox organization (simulated payments, nothing real moves). 2. **Create a product** — Store → Products → New product. Pick a pricing model (`fixed`, `pwyw` or `free` — locked after creation). Publish it and copy the product id. 3. **Get an API token** — Developer → API Tokens. Save it as `JAMI_TOKEN` (`jamidev_test_…` in sandbox, `jamidev_live_…` in production). 4. **Install the SDK and create a checkout** ```bash npm install jami-sdk ``` ```ts import { Jami } from 'jami-sdk'; const jami = new Jami({ token: process.env.JAMI_TOKEN! }); const checkout = await jami.createCheckout({ productId: '', customer: { email: 'buyer@example.com', phone: '251900000001' }, // magic sandbox success number gateway: 'telebirr', // 'telebirr' | 'mpesa' | 'cbe' }); ``` Free products resolve instantly (`orderId` present). Paid products return `mode: 'redirect'` (send the buyer to `checkoutUrl`) or `mode: 'direct'` (buyer confirms on their phone — poll next). 5. **Wait for payment** ```ts if (!('orderId' in checkout)) { const status = await jami.waitForCheckout(checkout.sessionId); // status.status: 'completed' | 'expired', status.orderId } ``` 6. **Register a webhook and verify deliveries** — Automation → Webhooks → add your endpoint URL, save the returned secret as `JAMI_WEBHOOK_SECRET`. Subscribe to `order.created`, `order.completed`, `benefit.granted`, `checkout.session.expired`. ```ts import { JamiSignatureError } from 'jami-sdk'; const event = await jami.webhooks.verify({ payload: rawBody, // exactly as received, do not JSON.parse first signature: req.headers['x-jamidev-signature'], secret: process.env.JAMI_WEBHOOK_SECRET!, }); // event.type: 'order.created' | 'order.completed' | 'benefit.granted' | 'checkout.session.expired' ``` Respond `2xx` within 10 seconds. Handle idempotently — retries and manual resends can redeliver the same `event.id`. 7. **List orders (fallback / reconciliation)** ```ts const { items, total } = await jami.listOrders({ status: 'paid', page: 1, limit: 20 }); ``` 8. **Go live** — Settings → Environment → switch to production, register production webhook endpoints (subscriptions are per-organization), and issue a fresh `jamidev_live_` token. ## Failure modes - Missing/invalid token → `401`, with `.hint` set when the token was minted for the org's other environment (re-issue it). - Validation error → `400`, message names the offending field. - `POST /checkout` limited to 20 requests / 5 minutes / IP → `429`. - Webhook signature mismatch, malformed header, or stale timestamp (>300s) → throws `JamiSignatureError`; treat the delivery as untrusted. ## Sources of truth (cross-check before shipping) - https://dev.jami.bio/llms-full.txt — full docs in one fetch - https://dev.jami.bio/openapi.json — machine-readable REST spec - /docs/api — raw REST reference (checkout, orders) - /docs/sdk — typed SDK reference and error types - /docs/webhooks — event payloads and signature verification ```` ## Core concepts [#core-concepts] | Concept | What it is | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Organization** | An isolated workspace: its own products, customers, checkout links, API tokens, webhooks and balance. You can own several and switch between them from the dashboard header. | | **Environment** | Every organization is either `sandbox` (simulated payments, nothing real moves) or `production` (live ArifPay charges). Switchable both ways in Settings. | | **Product** | A sellable item with a pricing model — `fixed`, `pwyw` (pay-what-you-want) or `free`. The pricing model is locked after creation. | | **Checkout session** | A short-lived (30 min) attempt to buy one product. Snapshots the price and metadata at creation, so later product edits never affect in-flight checkouts. | | **Order** | A completed, paid checkout. Idempotent — a session can only ever produce one order, no matter how many times payment notifications replay. | | **Benefit** | Something granted automatically on purchase, e.g. a generated license key. | | **Checkout link** | A long-lived shareable URL (`/jamidev/c/…`) that can offer one or several products, carry metadata onto orders, and redirect buyers back to your site. | ## Money [#money] * All API amounts are **integer ETB minor units** (santim). `10000` = 100.00 ETB. * Buyers pay **exactly the listed price** — nothing is added at checkout. A 100 ETB product charges the buyer 100 ETB. * Taxes are deducted when the seller **withdraws**: 30% government tax + 5% Jami usage fee (a 100 ETB balance pays out 65 ETB). The dashboard's balance breakdown shows this estimate. * Amounts are always computed server-side from the trusted price configuration. For `fixed` products any client-supplied amount is ignored; for `pwyw` the amount must be an integer at or above the product's minimum. ## Payment methods [#payment-methods] Checkout supports **Telebirr**, **M-Pesa** and **CBE Birr** via ArifPay direct transfer — the buyer stays on the checkout page and confirms the payment on their phone. No card data ever touches Jami. ## Sign in with Jami [#sign-in-with-jami] Beyond payments, Jami is an **OAuth 2.1 / OpenID Connect** provider. You can put a "Sign in with Jami" button in your own product and authenticate Jami users without building an account system — v1 is identity only (`sub`, name, picture, email), with resource scopes to follow. See [Sign in with Jami](/docs/sign-in-with-jami). ## What's coming [#whats-coming] An official client SDK (`@jami/jamidev`) is planned — until then, the [REST API](/docs/api) is small and stable enough to call directly. # Products & Benefits (/docs/products) ## Pricing models [#pricing-models] | Model | Behavior | | ------- | -------------------------------------------------------------------------------------------------------------------------------- | | `fixed` | One price, set by you. The **amount** can be edited later; everything else about pricing is locked. | | `pwyw` | Pay-what-you-want with an optional `minimumAmount` and a pre-filled `defaultAmount`. The API rejects anything below the minimum. | | `free` | No payment step — checkout immediately creates a paid order and grants benefits. | The pricing model (and product type) is **immutable after creation** — enforced at the database layer, not just the UI. This protects grandfathering: every checkout session snapshots the price at the moment it's created, so orders always reflect what the buyer actually saw, even if you change the price a minute later. All prices are in **ETB minor units** in the API (santim; `10000` = 100.00 ETB). Buyers pay exactly your listed price; withdrawal-time taxes (30% government + 5% Jami) come out of your balance, never out of the buyer's pocket. ## Checkout URLs [#checkout-urls] Every published product gets an opaque checkout token (`p_…`) and a hosted checkout page: ``` https://jami.bio/jamidev/p_x7Kf3mQz9aBc ``` Raw product IDs are deliberately **not** accepted in URLs. Draft and archived products return a branded "not available" page. ## Checkout fields [#checkout-fields] Collect custom information at checkout (text, number, date, checkbox, select). Fields are defined once per organization (**Developer → Checkout Fields**) and attached to products with a `required` flag. Collected values are stored on the order and echoed in webhook payloads via the session's collected fields. ## Metadata [#metadata] Products carry a free-form `metadata` map (string → string). It's snapshotted onto every order at completion and included in `order.*` webhook events — useful for SKUs, internal IDs, or plan names. [Checkout links](/docs/checkout-links) can add their own metadata, which wins over product keys on conflict. ## Benefits [#benefits] Benefits are granted automatically when an order is paid: * **License key** — a unique key generated per order, shown on the buyer's success page, included in the receipt email, and delivered in the `benefit.granted` webhook payload. * Discord roles, GitHub access, file downloads, feature flags and custom benefits are scaffolded and dispatch through the same grant pipeline. Grants are idempotent per order — replayed payment notifications never double-grant. # Quickstart (/docs/quickstart) ## 1. Enable Developer Mode [#1-enable-developer-mode] JamiDev lives behind Developer Mode, which requires a **KYC-verified** Jami account. Go to **Settings → Developer Mode** and flip the switch — if it's disabled, complete KYC verification first. A "JamiDev" entry appears in the sidebar under **Monetization**. ## 2. Create a sandbox organization [#2-create-a-sandbox-organization] Open JamiDev and use the organization switcher in the header → **New organization…**. New organizations start in **sandbox**: checkouts are fully simulated and no real money moves, so you can test the entire flow safely. (Your default organization is production.) ## 3. Create and publish a product [#3-create-and-publish-a-product] **Store → Products → New product**, then walk the wizard: 1. **Basics** — title, description, images. 2. **Pricing** — pick `fixed`, `pwyw` or `free`. *The pricing model is locked after creation* (only the amount of a fixed product can change later). 3. **Extras** — metadata key/values, checkout fields, benefits (e.g. a license key). Hit **Publish**. Prices are entered in ETB and buyers pay exactly that price — taxes (30% government + 5% Jami) are deducted later, when you withdraw your earnings. ## 4. Share the checkout [#4-share-the-checkout] On the product page, copy the **checkout link** (an opaque `/jamidev/p_…` URL), or create a [Checkout Link](/docs/checkout-links) to sell several products from one URL or embed checkout on your own site. ## 5. Make a test purchase [#5-make-a-test-purchase] Open the checkout link. Because the org is sandbox you'll see an amber **Test mode** banner. Use the magic phone number: * `251900000001` → instant **success** * `251900000002` → instant **failure** * any other number → an interactive gateway **simulator** with Approve / Fail buttons A successful test creates a real order: benefits are granted, webhooks fire (with `livemode: false`), the dashboard Overview updates, and you—as the org owner—get a `[TEST]`-prefixed sale email. Buyer receipts are suppressed in sandbox. ## 6. Integrate from code (the SDK) [#6-integrate-from-code-the-sdk] Everything above also works from your own backend through the official [`jami-sdk`](/docs/sdk) — zero dependencies, Node 18+/edge/browser: ```bash npm install jami-sdk ``` ```ts import { Jami } from 'jami-sdk'; const jami = new Jami({ token: process.env.JAMI_TOKEN! }); // jamidev_test_… for sandbox const checkout = await jami.createCheckout({ productId: '', customer: { email: 'buyer@example.com', phone: '251900000001' }, // magic success number gateway: 'telebirr', }); if (!('orderId' in checkout)) { const status = await jami.waitForCheckout(checkout.sessionId); console.log(status.status, status.orderId); // 'completed', '665f…' } ``` See the [SDK page](/docs/sdk) for orders, webhook verification, and error handling. ## 7. Go live [#7-go-live] **Settings → Environment → Switch to production.** New checkouts now charge real money through ArifPay via Telebirr, M-Pesa or CBE Birr. Remember to: * register your production [webhook endpoints](/docs/webhooks) — subscriptions are per-organization; * issue fresh `jamidev_live_` [API tokens](/docs/api) — test tokens stop working when the environment changes. # Sandbox (/docs/sandbox) Every organization has an `environment`: **sandbox** or **production**. It's a property of the organization (not a separate server), switchable both ways at any time from **Settings → Environment**. New organizations start in sandbox. ## What sandbox changes [#what-sandbox-changes] | Surface | Behavior | | ----------------- | ------------------------------------------------------------------------------------------------------ | | Checkout | The buyer is sent to an internal **gateway simulator** instead of ArifPay | | Payments | Marked internally as simulated — never reconciled against ArifPay | | Orders & sessions | Stamped `environment: "sandbox"` at creation; switching the org later never re-labels historical data | | Balance | Sandbox earnings show on the Overview but can never be withdrawn and never mix with production numbers | | API tokens | Issued as `jamidev_test_…`; they stop authenticating if the org switches environment | | Webhooks | Delivered normally, with `"livemode": false` in the payload envelope | | Emails | Buyer receipts are suppressed; the org owner still gets a `[TEST]`-prefixed sale notification | ## Test phone numbers [#test-phone-numbers] On a sandbox checkout these settle instantly, like Stripe's `4242` card: | Phone | Result | | -------------- | ----------------------------------------------------------------- | | `251900000001` | Instant success — order created, benefits granted, webhooks fired | | `251900000002` | Instant failure — session expired, no order | | anything else | Manual simulator page with **Approve / Fail / Cancel** buttons | The simulator drives the exact same order lifecycle as a real payment notification, so replays are idempotent: approving twice still creates exactly one order. ## Switching environments [#switching-environments] Both directions are allowed, any time, per organization: * **Sandbox → production**: new checkouts charge real money. Existing test data stays labeled sandbox. Re-issue tokens as `jamidev_live_`. * **Production → sandbox**: new checkouts on your live links become simulated (buyers see the test-mode gateway and are not charged). Production history is untouched. Only **new** checkout sessions are affected by a switch — everything already created keeps the environment it was born with. # SDK (/docs/sdk) 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 [#install] ```bash npm install jami-sdk ``` ## Setup [#setup] Create a token in **Developer → API Tokens** and construct the client: ```ts 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 [#create-a-checkout] ```ts 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 [#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: ```ts 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 [#list-orders] ```ts const { items, total } = await jami.listOrders({ status: 'paid', page: 1, limit: 20 }); // items[].productId / customerId come back populated ({ title } / { email, name }) ``` ## Withdrawals [#withdrawals] Pay out your balance — the **5% usage fee is cut from** the amount you withdraw, so you receive the net. Requires a production org with Developer Mode + approved KYC; minimum 1,000.00 ETB (`100000` santim). Amounts **≤ 10,000 ETB auto-pay**; larger go to manual review. Preview the cut offline with `Jami.computeWithdrawalQuote`: ```ts const quote = Jami.computeWithdrawalQuote(100000); // → { amount: 100000, feeAmount: 5000, taxAmount: 0, netAmount: 95000, feeRate: 0.05, taxRate: 0 } const { balanceMinor } = await jami.getBalance(); const withdrawal = await jami.createWithdrawal({ amount: 100000, // gross santim (1,000 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; // 95000 — what reaches the account await jami.getWithdrawal(withdrawal._id); await jami.listWithdrawals({ status: 'completed', limit: 20 }); ``` See the [Withdrawals API](/docs/api/withdrawals) for the full contract. ## Verify webhooks [#verify-webhooks] Timing-safe HMAC verification with a replay guard — pass the **raw** request body: ```ts 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 [#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 | ```ts 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 [#notes] * The SDK adds **no automatic retries** — the status endpoint self-reconciles, and `waitForCheckout` is the only loop (always bounded by `timeoutMs` / `AbortSignal`). * Endpoint-by-endpoint details live in the [API Reference](/docs/api). # Sign in with Jami (/docs/sign-in-with-jami) Jami is an OAuth 2.1 / OpenID Connect provider. **Issuer:** `https://jami.bio/api/auth`. Prefer reading the discovery document at runtime over hardcoding endpoints: ``` GET https://jami.bio/api/auth/.well-known/openid-configuration GET https://jami.bio/api/auth/.well-known/oauth-authorization-server ``` ## Getting credentials [#getting-credentials] Registration is **self-serve** — you create and manage your own apps, no waiting on the Jami team. Credentials live behind Developer Mode: 1. **Enable Developer Mode.** In Jami, go to **Settings → Developer** and turn on Developer Mode. First-time access runs a one-off identity check (KYC); once you're approved the developer tools stay unlocked. 2. **Open the OAuth apps manager.** Either **JamiDev → Sign in with Jami**, or **Settings → OAuth** — both render the same manager, so use whichever you have open. 3. **Create an app.** Give it a name, add one or more redirect URIs, pick your scopes, and choose the client type. Hit create and Jami issues a `client_id` immediately (plus a `client_secret` for confidential clients). You fill in: | What | Notes | | --------------- | ------------------------------------------------------------------------------------------ | | App name | Shown on the consent screen | | Redirect URI(s) | HTTPS, exact-match; `localhost` allowed for development. **Add as many as you need.** | | Client type | Confidential (server, has a secret) or public (SPA/native, PKCE only) | | Scopes | See [Scopes](#scopes) below | | Website URL | Optional, linked from the consent screen | | Logo URL | Optional, shown on the consent screen | Everything is editable after the fact — see [Managing your apps](#managing-your-apps). ### Multiple redirect URIs [#multiple-redirect-uris] Yes — a single app can register **as many redirect URIs as you need**, and you can add or remove them at any time. This is the normal way to cover several environments or surfaces under one `client_id`, for example: ``` https://app.example.com/callback https://staging.example.com/callback http://localhost:3000/api/auth/callback/jami ``` Each authorization request must send a `redirect_uri` that **exactly matches** one of the registered entries — scheme, host, port, path and trailing slash all count. A value that isn't on the list is rejected with `redirect_uri_mismatch`, which is what stops an attacker from redirecting your users' codes somewhere you never approved. A confidential client's secret is displayed a single time, right after you create it or rotate it. Jami stores only a hash and can't show it again — copy it into your secret manager immediately. Lost it? Rotate to get a fresh one (the old one stops working the instant you rotate). ## Managing your apps [#managing-your-apps] The same manager (JamiDev → Sign in with Jami, or Settings → OAuth) lets you maintain an app over its whole lifetime. Every action is **owner-scoped** — you can only touch apps you created. | Action | Effect | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Edit** | Change the name, redirect URIs, scopes, website and logo. Grant types are recomputed from your scopes automatically (adding `offline_access` turns on refresh tokens). | | **Rotate secret** | Issues a new `client_secret` and invalidates the old one immediately. Confidential clients only — public clients have no secret to rotate. | | **Disable / enable** | A kill switch. Disabling blocks new authorizations and refreshes; already-issued access tokens stay valid until they expire (≤ 1 hour). | | **Delete** | Removes the app permanently. | Rotating or disabling stops new tokens at once, but an access token already in a partner's hands remains valid until it expires because it's a self-verifying JWT. For an instant cut-off of a specific token, revoke it — see [Revocation](#revocation). ## Architecture [#architecture] Jami is a standards-compliant **OAuth 2.1 / OpenID Connect provider**. Your app never sees a Jami password — it hands the user to Jami, Jami authenticates them and asks their consent, and your app receives a short-lived authorization code it exchanges for tokens on its own backend. The pieces: * **Authorization endpoint** (`/oauth2/authorize`) — where you send the user. Jami signs them in, shows the consent screen listing exactly the scopes you asked for, and redirects back with a single-use code bound to your `client_id`, `redirect_uri` and PKCE challenge. * **Token endpoint** (`/oauth2/token`) — your backend swaps the code for an `access_token`, an `id_token`, and (with `offline_access`) a rotating `refresh_token`. Confidential clients authenticate here with their secret; public clients rely on PKCE alone. * **JWKS** (`/jwks`) — the public keys you verify tokens against, offline, without calling Jami. * **UserInfo / Introspection / Revocation** — read the signed-in user, check whether a token is still live, or kill one. Credentials themselves are owner-scoped: when you create an app Jami records you as its owner and stores the client secret **hashed** (it's shown to you once and never again). Every edit, rotation, disable and delete is checked against that ownership, so one developer can never see or alter another's apps. The rest of this page is the concrete wiring for that flow. ## Wiring it up [#wiring-it-up] ### better-auth (fastest path) [#better-auth-fastest-path] Register Jami as a generic OAuth provider. Redirect URI: `https:///api/auth/oauth2/callback/jami`. ```ts // auth.ts import { betterAuth } from 'better-auth'; import { genericOAuth } from 'better-auth/plugins'; export const auth = betterAuth({ plugins: [ genericOAuth({ config: [ { providerId: 'jami', clientId: process.env.JAMI_CLIENT_ID!, clientSecret: process.env.JAMI_CLIENT_SECRET!, discoveryUrl: 'https://jami.bio/api/auth/.well-known/openid-configuration', scopes: ['openid', 'profile', 'email'], pkce: true, // required — Jami rejects non-PKCE authorization requests }, ], }), ], }); ``` ```ts // client.ts import { createAuthClient } from 'better-auth/client'; import { genericOAuthClient } from 'better-auth/client/plugins'; export const authClient = createAuthClient({ plugins: [genericOAuthClient()] }); await authClient.signIn.oauth2({ providerId: 'jami', callbackURL: '/dashboard' }); ``` Keep `clientSecret` server-side only. `pkce: true` is not optional — Jami enforces PKCE (S256) for confidential clients too. ### Direct OAuth [#direct-oauth] Three requests. **1. Send the user to Jami** ``` 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%20profile%20email &state=RANDOM_PER_ATTEMPT &code_challenge=BASE64URL(SHA256(verifier)) &code_challenge_method=S256 ``` **2. Jami redirects back** ``` GET https://your-app.com/callback ?code=AUTHORIZATION_CODE &state=RANDOM_PER_ATTEMPT &iss=https://jami.bio/api/auth ``` Verify `state` matches and `iss` is exactly `https://jami.bio/api/auth` before continuing. A declined consent returns `?error=access_denied` instead. **3. Exchange the code** ```bash curl -X POST https://jami.bio/api/auth/oauth2/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d grant_type=authorization_code \ -d code=AUTHORIZATION_CODE \ -d redirect_uri=https://your-app.com/callback \ -d client_id=YOUR_CLIENT_ID \ -d client_secret=YOUR_CLIENT_SECRET \ -d code_verifier=ORIGINAL_VERIFIER ``` ```json { "access_token": "eyJhbGciOiJSUzI1NiIs...", "token_type": "Bearer", "expires_in": 3600, "scope": "openid profile email", "id_token": "eyJhbGciOiJSUzI1NiIs...", "refresh_token": "..." } ``` `refresh_token` is present only if `offline_access` was granted. Decode `id_token` (after verifying it — see below) to identify the user, or call `/oauth2/userinfo`: ```bash curl https://jami.bio/api/auth/oauth2/userinfo -H "Authorization: Bearer ACCESS_TOKEN" ``` ```json { "sub": "69a809e5f8d34b98fe901a90", "name": "Abebe Bikila", "picture": "https://…/avatar.png", "email": "abebe@example.com", "email_verified": true, "https://jami.bio/handle": "abebe" } ``` Store the user against `sub` — it's the only identifier guaranteed stable. Email, name and handle can all change. The exchange above is a **confidential client**. Public clients — mobile apps, SPAs, anything that can't keep a secret — omit `client_secret` entirely and authenticate with PKCE alone. Confidential clients may instead send their credentials as HTTP Basic (`client_secret_basic`) rather than in the form body. ### Common setup mistakes [#common-setup-mistakes] | Symptom | Cause | | ------------------------------- | ------------------------------------------------------------------ | | `invalid_client` | Wrong `client_id`/secret, or the client is disabled | | `invalid_grant` | Code already used, expired (10 min), or `code_verifier` mismatch | | `invalid_request`, missing PKCE | `code_challenge` omitted, or method isn't `S256` | | `redirect_uri_mismatch` | Must match a registered URI exactly — scheme, path, trailing slash | | Refresh returns `invalid_grant` | Refresh tokens rotate; store the new one from every response | ## Endpoints [#endpoints] | Purpose | Endpoint | | ------------- | -------------------------------------------------- | | Authorization | `GET https://jami.bio/api/auth/oauth2/authorize` | | Token | `POST https://jami.bio/api/auth/oauth2/token` | | UserInfo | `GET https://jami.bio/api/auth/oauth2/userinfo` | | Introspection | `POST https://jami.bio/api/auth/oauth2/introspect` | | Revocation | `POST https://jami.bio/api/auth/oauth2/revoke` | | JWKS | `GET https://jami.bio/api/auth/jwks` | ## Scopes [#scopes] v1 is **identity only** — you can authenticate a Jami user, but not read or write their Jami data. Resource scopes land later without breaking these. | Scope | Grants | Shown to the user as | | ---------------- | ----------------------------------------------------------- | ------------------------------------ | | `openid` | Required. Returns `sub`. | "Confirm your Jami identity" | | `profile` | `name`, `picture`, `given_name`, `family_name`, Jami handle | "View your name and profile picture" | | `email` | `email`, `email_verified` | "View your email address" | | `offline_access` | Issues a refresh token | "Stay signed in" | Users can decline individual scopes on the consent screen — check the `scope` value returned with the token rather than assuming you got everything you asked for. ## PKCE is mandatory [#pkce-is-mandatory] PKCE is required for all clients, confidential ones included, and only `S256` is accepted (`plain` is rejected). Generate a fresh `code_verifier` (43–128 chars) and `state` per attempt, store both against the user's session, and on callback confirm `state` (CSRF) and `iss` (mix-up attack defence) before exchanging the code. Validate the `id_token` JWT before trusting it: verify the signature against `/jwks` (cache the keys — they rotate), `iss` == `https://jami.bio/api/auth`, `aud` == your `client_id`, `exp` is in the future, and `nonce` matches if you sent one. **Never trust an unverified `sub`.** ## Claims [#claims] | Claim | Scope | Example | | --------------------------- | --------- | ------------------------------------ | | `sub` | `openid` | `6712f3a...` | | `name` | `profile` | `Abebe Bikila` | | `picture` | `profile` | `https://…/avatar.png` | | `given_name`, `family_name` | `profile` | | | `https://jami.bio/handle` | `profile` | `abebe` — the user's Jami URL handle | | `email` | `email` | `abebe@example.com` | | `email_verified` | `email` | `true` | Custom Jami claims are namespaced with a URI so they never collide with a future standard OIDC claim. `/oauth2/userinfo` returns the same claims, limited to the granted scopes. ## Token lifetimes and refresh [#token-lifetimes-and-refresh] | Token | Lifetime | | ------------- | -------- | | Access token | 1 hour | | ID token | 10 hours | | Refresh token | 30 days | Refresh tokens are issued only when `offline_access` was granted, and are **rotated on every use** — store the new one and discard the old. Reusing a consumed refresh token is treated as a compromise signal. ```http POST https://jami.bio/api/auth/oauth2/token Content-Type: application/x-www-form-urlencoded grant_type=refresh_token &refresh_token= &client_id= &client_secret= ``` ## Revocation [#revocation] Users can disconnect your app anytime from **Jami → Settings → Connected apps** — refresh tokens stop working immediately. Access tokens are JWTs verified offline, so an already-issued one stays valid until it expires (max 1 hour). For a security-sensitive action, call introspection instead: ```http POST https://jami.bio/api/auth/oauth2/introspect Content-Type: application/x-www-form-urlencoded token=&client_id=&client_secret= ``` Returns `{"active": false}` for revoked, expired, or unknown tokens. To revoke a token yourself (e.g. on user logout), `POST` the same shape to `/oauth2/revoke`. ## Rate limits [#rate-limits] | Endpoint | Limit | | -------------------- | ------------ | | `/oauth2/token` | 20 / minute | | `/oauth2/authorize` | 30 / minute | | `/oauth2/introspect` | 100 / minute | | `/oauth2/revoke` | 30 / minute | | `/oauth2/userinfo` | 60 / minute | Exceeding a limit returns `429` — back off exponentially. Prefer verifying access-token JWTs locally against `/jwks` over calling introspection on every request; it's faster and doesn't consume the limit. ## Errors [#errors] | Code | Meaning | What to do | | ----------------- | ------------------------------------------------------- | ------------------------------- | | `invalid_client` | Bad `client_id`/secret, or the client is disabled | Check credentials; contact Jami | | `invalid_grant` | Code expired, already used, or `code_verifier` mismatch | Restart the flow | | `invalid_request` | Malformed request, missing PKCE | Fix the request | | `access_denied` | User declined | Don't retry automatically | | `invalid_scope` | Scope not allowed for your client | Request only granted scopes | Authorization codes are single-use and expire in 10 minutes. ## Checklist before going live [#checklist-before-going-live] * [ ] `state` generated per attempt and verified on callback * [ ] `iss` verified on callback * [ ] PKCE `S256` with a fresh verifier per attempt * [ ] ID token signature verified against JWKS, with `iss`/`aud`/`exp` all checked * [ ] Users keyed on `sub`, not email * [ ] Granted `scope` checked rather than assumed * [ ] Rotated refresh tokens persisted * [ ] Client secret stored in a secret manager, never in source or a frontend bundle # Webhooks (/docs/webhooks) Register endpoints in **Automation → Webhooks**. Each subscription gets a **secret** (shown once) and a list of events to receive. > Subscriptions belong to the **active organization**. If you create a sandbox org for > testing, register your endpoint there too — events from one org never reach another > org's subscriptions. ## Events [#events] | Event | Fired when | | -------------------------- | --------------------------------------------------------------------------------------- | | `order.created` | An order is created (payment confirmed) | | `order.completed` | The order is finalized — for one-time products this fires together with `order.created` | | `benefit.granted` | A benefit (e.g. license key) was granted for a paid order | | `checkout.session.expired` | A session failed, was cancelled, or hit its 30-minute TTL without payment | ## Payload envelope [#payload-envelope] ```json { "id": "665f1c2ab8d3a2f4e1a9c001", "type": "order.created", "created": "2026-07-11T09:30:00.000Z", "livemode": false, "data": { "orderId": "665f1c2ab8d3a2f4e1a9c111", "productId": "665f1c2ab8d3a2f4e1a9c222", "customerId": "665f1c2ab8d3a2f4e1a9c333", "customerEmail": "buyer@example.com", "customerExternalId": null, "amount": 10000, "currency": "ETB", "status": "paid", "metadata": { "campaign": "launch" }, "paidAt": "2026-07-11T09:29:58.000Z" } } ``` `livemode` is `false` for sandbox organizations — filter on it if one endpoint serves both environments. `amount` is what the buyer paid (your listed price), in ETB minor units. `benefit.granted` data: `grantId`, `benefitId`, `benefitType`, `orderId`, `customerId`, and `payload` (for `license_key` benefits, `payload.licenseKey` holds the key). ## Verifying signatures [#verifying-signatures] Every delivery is signed with HMAC-SHA256 over `"{timestamp}.{rawBody}"` using your subscription secret: ``` X-JamiDev-Signature: t=1783695518,v1=5257a869e7... X-JamiDev-Event: order.created ``` The [`jami-sdk`](/docs/sdk) does the verification (timing-safe compare + replay guard) and returns the typed event: ```ts import { Jami, JamiSignatureError } from 'jami-sdk'; const jami = new Jami({ token: process.env.JAMI_TOKEN! }); const event = await jami.webhooks.verify({ payload: rawBody, // the RAW request body string signature: req.headers['x-jamidev-signature'], secret: process.env.JAMI_WEBHOOK_SECRET!, }); // event.type is 'order.created' | 'order.completed' | 'benefit.granted' | 'checkout.session.expired' ``` Verifying manually (any language) works the same way: ```ts import crypto from 'node:crypto'; function verifyJamiDevSignature(rawBody: string, header: string, secret: string): boolean { const t = /t=(\d+)/.exec(header)?.[1]; const v1 = /v1=([a-f0-9]+)/.exec(header)?.[1]; if (!t || !v1) return false; // Reject stale timestamps to prevent replay (5 minutes is a sane window) if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'); return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1)); } ``` Always verify against the **raw request body** — parsing and re-serializing JSON can change key order and break the signature. ## Delivery & retries [#delivery--retries] * Respond with any **2xx** within 10 seconds to acknowledge. * Failed deliveries retry with backoff: **1m → 5m → 30m → 2h → 12h** (5 attempts total). * Every delivery and its response code is visible on the Webhooks page, with a manual **resend** button. * Handle events **idempotently** — retries and manual resends mean you may see the same event id more than once. # Checkout (/docs/api/checkout) ## Create a checkout session [#create-a-checkout-session] ``` POST /api/jamidev/checkout ``` Public (used by hosted checkout pages) or authenticated with a bearer token. With the [SDK](/docs/sdk): ```ts const checkout = await jami.createCheckout({ productId: '665f1c2ab8d3a2f4e1a9c222', customer: { email: 'buyer@example.com', phone: '0912345678' }, gateway: 'telebirr', }); ``` ### Request body [#request-body] ```json { "productId": "665f1c2ab8d3a2f4e1a9c222", "amount": 15000, "customer": { "email": "buyer@example.com", "phone": "0912345678", "name": "Abebe B.", "externalId": "user_42" }, "collectedFields": { "": "value" }, "gateway": "telebirr", "checkoutLink": "cl_Q4sp2Xr5ybOi", "successUrl": "https://yoursite.com/thanks?c={CHECKOUT_ID}" } ``` | Field | Notes | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `productId` | Required. Must be a **published** product. Accepts either the public `p_…` product id (shown in your dashboard) or the raw ObjectId. | | `amount` | Only for `pwyw` products — integer minor units, ≥ the product minimum. Ignored for `fixed` (server uses the configured price) and `free`. | | `customer.email` | Required. Upserts the customer per organization. | | `customer.phone` | Required for paid products — the payment push goes to this number. Ethiopian formats accepted (`09…`, `2519…`, `9…`). | | `collectedFields` | Values keyed by checkout-field id; `required` fields are enforced. | | `gateway` | `telebirr` \| `mpesa` \| `cbe` (default `telebirr`). Anything else is rejected. | | `checkoutLink` | Optional `cl_…` token — merges the link's metadata into the order and applies its success URL. Stale tokens are ignored, never an error. | | `successUrl` | Optional. Honored for token callers; anonymous calls only when the URL's host matches one of the org's registered redirect URLs. | ### Responses [#responses] **Free product** — the order is created immediately: ```json { "sessionId": "665f…", "orderId": "665f…" } ``` **Paid, production** — direct (phone-push) payment; keep the buyer on your page and poll: ```json { "sessionId": "665f…", "checkoutUrl": null, "mode": "direct" } ``` **Paid, sandbox** — redirect the buyer to the simulator: ```json { "sessionId": "665f…", "checkoutUrl": "https://jami.bio/jamidev/simulate/665f…", "mode": "redirect" } ``` Sessions expire after **30 minutes** if unpaid. ## Poll session status [#poll-session-status] ``` GET /api/jamidev/checkout/{sessionId} ``` Public. Safe to poll every \~3 seconds — or let the SDK poll for you: ```ts const status = await jami.waitForCheckout(sessionId); // resolves on completed/expired ``` ```json { "sessionId": "665f…", "status": "open", "expiresAt": "2026-07-11T10:00:00.000Z", "amount": 10000, "currency": "ETB", "orderId": null, "orderStatus": null } ``` * `status`: `open` → `completed` or `expired`. * When `orderId` is non-null, the payment succeeded and benefits are granted. * The endpoint **actively reconciles**: while a session is open it checks the payment provider directly (throttled), so polling resolves even if a provider webhook is delayed. Expired sessions are finalized automatically. ## List orders [#list-orders] See [Orders](/docs/api/orders). # Overview (/docs/api) > **Prefer the SDK.** The official [`jami-sdk` package](/docs/sdk) (`npm install jami-sdk`) > wraps every endpoint below with typed results, typed errors, a checkout polling > helper, and webhook signature verification — zero dependencies, Node 18+/edge/browser. > This reference documents the raw REST surface the SDK talks to. Base URL: ``` https://jami.bio/api/jamidev ``` There is no separate sandbox host — the environment is a property of your organization, and your token determines which one you're operating in. ## Authentication [#authentication] Create tokens in **Developer → API Tokens**. The plaintext is shown **once**; only a SHA-256 hash is stored. ``` Authorization: Bearer jamidev_live_9f2c4e... # production org Authorization: Bearer jamidev_test_1a7b3d... # sandbox org ``` Tokens are bound to the environment they were created in. If the organization switches environment, existing tokens return `401` with a message telling you to issue a new one — a leftover test token can never operate on live data. `POST /checkout` is **dual-auth**: Jami's hosted checkout pages call it anonymously for any published product, while token callers are scoped to their own organization's products and unlock extra parameters (like `successUrl`). ## Conventions [#conventions] * Amounts: integer **ETB minor units** (santim). `10000` = 100.00 ETB. * Bodies: JSON in, JSON out. * IDs: MongoDB ObjectId strings. * Errors: appropriate status + `{ "error": "human-readable message" }`. | Status | Meaning | | ------ | ------------------------------------------------------------------- | | `400` | Validation failed — the message says which field | | `401` | Missing, revoked, or environment-mismatched token | | `404` | Resource doesn't exist or isn't yours (indistinguishable by design) | | `422` | Attempt to change a locked field (pricing model, product type) | | `429` | Rate limited | | `502` | The payment provider rejected the request | ## Rate limits [#rate-limits] `POST /checkout` is limited to **20 requests per 5 minutes per IP**. Exceeding it returns `429` — back off and retry after the window. # Orders (/docs/api/orders) ## List orders [#list-orders] ``` GET /api/jamidev/orders?page=1&limit=20&status=paid Authorization: Bearer jamidev_live_… ``` With the [SDK](/docs/sdk): ```ts const { items, total } = await jami.listOrders({ status: 'paid', limit: 20 }); ``` | Query param | Notes | | ----------- | ------------------------------------------------ | | `page` | Default `1` | | `limit` | Default `20`, max `100` | | `status` | Optional filter: `pending` \| `paid` \| `failed` | ### Response [#response] ```json { "items": [ { "_id": "665f1c2ab8d3a2f4e1a9c111", "checkoutSessionId": "665f1c2ab8d3a2f4e1a9c000", "productId": { "_id": "665f…", "title": "Pro License" }, "customerId": { "_id": "665f…", "email": "buyer@example.com", "name": "Abebe B." }, "amount": 10000, "currency": "ETB", "status": "paid", "environment": "production", "metadata": { "campaign": "launch" }, "providerRef": "ARIFPAY-SESSION-REF", "paidAt": "2026-07-11T09:29:58.000Z", "createdAt": "2026-07-11T09:29:58.000Z" } ], "total": 42, "page": 1, "limit": 20 } ``` * `amount` is what the buyer paid — your listed price, in ETB minor units. * `environment` distinguishes sandbox test orders from live ones. * `metadata` is the snapshot taken at checkout — product metadata merged with any checkout-link metadata. Orders are the durable record: exactly one exists per completed checkout session, and replayed payment notifications can never create duplicates. # Withdrawals (/docs/api/withdrawals) Pay out your organization's earnings to a Telebirr, M-Pesa, or CBE account. The **5% JamiDev usage fee is cut from** the amount you withdraw (not added on top) — you withdraw `amount`, the fee (and any government tax) is deducted, and the destination receives the **net**. All money is in **ETB minor units (santim)**: `10000` = 100.00 ETB. Withdrawals require a **production** organization with **Developer Mode** enabled and **approved KYC**. The minimum withdrawal is **1,000.00 ETB** (`100000` santim). Withdrawals of **10,000 ETB or less are paid out automatically** — they come back with `status` `processing`/`completed`. Larger amounts are held for manual review (`status: 'pending'`). ## Get balance [#get-balance] ``` GET /api/jamidev/balance Authorization: Bearer jamidev_live_… ``` With the [SDK](/docs/sdk): ```ts const { balanceMinor, eligible } = await jami.getBalance(); ``` ### Response [#response] ```json { "success": true, "eligible": true, "balanceMinor": 250000, "currency": "ETB" } ``` * `balanceMinor` is the withdrawable balance in santim. * `eligible` is `false` (and `balanceMinor` is `0`) when the org isn't in production or Developer Mode is off. ## Create a withdrawal [#create-a-withdrawal] ``` POST /api/jamidev/withdrawals Authorization: Bearer jamidev_live_… Idempotency-Key: payout-2026-08-12-001 (optional) { "amount": 100000, "gateway": "telebirr", "account": "251911223344" } ``` With the [SDK](/docs/sdk): ```ts const withdrawal = await jami.createWithdrawal({ amount: 100000, // gross santim (1,000 ETB); the 5% fee is cut from this gateway: 'telebirr', // 'telebirr' | 'mpesa' | 'cbe' account: '251911223344', idempotencyKey: 'payout-2026-08-12-001', }); ``` | Field | Notes | | ----------------- | ------------------------------------------------------------------------------------------------ | | `amount` | Gross santim to withdraw. Min `100000` (1,000 ETB). The fee/tax is cut from this. | | `gateway` | `telebirr` \| `mpesa` \| `cbe` | | `account` | Destination phone for the gateway (`2519…`) | | `Idempotency-Key` | Optional header. Reusing a key returns the original withdrawal instead of creating a second one. | Preview the cut offline before calling — the server resolves your org's real rate and is authoritative: ```ts Jami.computeWithdrawalQuote(100000); // → { amount: 100000, feeAmount: 5000, taxAmount: 0, netAmount: 95000, feeRate: 0.05, taxRate: 0 } ``` ### Response [#response-1] ```json { "_id": "665f1c2ab8d3a2f4e1a9c222", "status": "completed", "amount": 100000, "feeAmount": 5000, "taxAmount": 0, "netAmount": 95000, "currency": "ETB", "gateway": "telebirr", "account": "251911223344", "createdAt": "2026-08-12T09:29:58.000Z" } ``` * `netAmount` (`amount − feeAmount − taxAmount`) is what actually reaches the account. * Amounts **≤ 10,000 ETB auto-pay** — `status` comes back `processing`/`completed`. Larger amounts land as `pending`, are reviewed (`reviewing` / `approved` / `rejected`), then paid out (`processing` → `completed`). ## Get / list withdrawals [#get--list-withdrawals] ``` GET /api/jamidev/withdrawals?page=1&limit=20&status=completed GET /api/jamidev/withdrawals/{id} Authorization: Bearer jamidev_live_… ``` ```ts const page = await jami.listWithdrawals({ status: 'completed', limit: 20 }); const one = await jami.getWithdrawal('665f1c2ab8d3a2f4e1a9c222'); ``` Track completion with the `withdrawal.paid` / `withdrawal.failed` [webhooks](/docs/webhooks), or poll `getWithdrawal`.