Subscriptions
Charge a Jami user a fixed amount every week, month or year from their Jami wallet, after they approve it once with their wallet PIN.
A subscription charges the same amount on a schedule, such as 250 ETB a month for a membership, until either side cancels. The money comes from the payer's Jami wallet, not from a phone prompt each time. The payer approves the plan once, on Jami, with their wallet PIN. After that, Jami charges each renewal on its own and tells your server with a webhook.
Your server never charges a wallet. It creates a subscribe link for a plan, sends the
payer to it, and listens for subscription.* events. Only the payer, signed in to Jami, can
start a subscription.
Payers need a Jami account with wallet balance
Subscriptions are paid from a Jami wallet, so the payer needs a Jami account and enough balance for each charge. They can add money to their wallet with Telebirr, M-Pesa or CBE Birr, but each charge itself always comes from the wallet. For a one-off phone payment, use Charge any amount or a regular product.
How it works
You create a plan. A plan is a product with productType: 'recurring', a fixed
price and a billing interval, such as 25000 santim (250 ETB) every month.
Your server creates a subscribe link. jami.subscriptions.createCheckout({ productId })
returns a url like https://jami.bio/jamidev/subscribe/sc_…. It's valid for 24 hours,
works once, and locks in the plan's current price.
The payer approves on Jami. They sign in, see your organization's name, the plan, the amount, how often it renews and the next charge date. They tick a consent box and enter their 6-digit wallet PIN. If they haven't set a PIN yet, they set one right there with a code Jami emails them.
The first period is paid immediately. The subscription and its first charge are created in the same transaction. If the first charge fails, for example because the wallet is short, no subscription is created and the payer can fix it and try again on the same link.
The payer comes back to you. Jami redirects to your successUrl with
?subscription_id=sub_… added. You get subscription.created.
Jami renews it. At the end of each period, Jami charges the wallet and sends
subscription.renewed. If a renewal fails, Jami retries on a fixed schedule
(dunning) and sends subscription.payment_failed each time.
Each paid period is credited to the organization owner's Jami wallet as earnings, exactly like a sale. There's no fee when the charge happens; the usual JamiDev fee is taken when you withdraw.
Quickstart
You need an API token and jami-sdk 0.6.0 or later. Amounts
are integer santim: 25000 is 250.00 ETB.
npm install jami-sdk@^0.6.0Create a recurring product
import { Jami } from 'jami-sdk';
const jami = new Jami({ token: process.env.JAMI_TOKEN! });
const plan = await jami.products.create({
title: 'Gebeta Club membership',
pricingModel: 'fixed', // recurring plans are always fixed-price
price: { amount: 25000 }, // 250.00 ETB per period
productType: 'recurring',
billingInterval: 'month', // 'week' | 'month' | 'year'
intervalCount: 1, // every 1 month
});
plan.token; // 'p_x7Kf3mQz9aBc'Do this once and store the token. You can also create the plan in the dashboard.
Create a subscribe link
When a signed-in user of your app clicks Subscribe, create a link from your server.
Pass your own user id as customer.externalId so you can find the subscription later.
const checkout = await jami.subscriptions.createCheckout({
productId: 'p_x7Kf3mQz9aBc',
customer: { email: user.email, externalId: user.id },
successUrl: 'https://gebeta.club/welcome?sub={SUBSCRIPTION_ID}',
cancelUrl: 'https://gebeta.club/pricing',
metadata: { plan: 'monthly' },
});
// → { id: 'sc_…', url: 'https://jami.bio/jamidev/subscribe/sc_…', expiresAt, livemode }Send the payer to it
return Response.redirect(checkout.url, 303);You can also send the url in a message. The payer approves it on Jami and lands on your
successUrl. Jami fills in {SUBSCRIPTION_ID} and {CHECKOUT_ID}, and always adds
subscription_id to the query string.
Grant access from webhooks
Subscribe your webhook endpoint to the subscription.* events. Existing
endpoints don't get them until you tick them in Automation → Webhooks.
const event = await jami.webhooks.verify({
payload: await req.text(),
signature: req.headers.get('x-jamidev-signature') ?? '',
secret: process.env.JAMI_WEBHOOK_SECRET!,
});
switch (event.type) {
case 'subscription.created':
case 'subscription.renewed':
// Paid through event.data.currentPeriodEnd.
await grantAccess(event.data.customerExternalId, event.data.currentPeriodEnd);
break;
case 'subscription.payment_failed':
// Still retrying unless nextRetryAt is null. Ask the user to top up their wallet.
await warnUser(event.data.customerExternalId, event.data.nextRetryAt);
break;
case 'subscription.canceled':
await revokeAccess(event.data.customerExternalId);
break;
}Check status by your user id
When you need the current state, for example on login, look it up by customerExternalId:
const { items } = await jami.subscriptions.list({
customerExternalId: user.id,
productId: 'p_x7Kf3mQz9aBc',
});
const live = items.find((s) => s.status !== 'canceled');
const hasAccess = Boolean(live); // past_due still counts while Jami retries, your calljami.subscriptions.get('sub_…') returns one subscription with its recent charges.
Cancel
await jami.subscriptions.cancel('sub_Qm3xT8vLp2NaR7kd'); // at period end
await jami.subscriptions.cancel('sub_Qm3xT8vLp2NaR7kd', { atPeriodEnd: false }); // nowThe payer can also cancel from their Jami account at any time.
Every call is in API: Subscriptions. With the
MCP server, ask your agent to "create a subscribe link for p_x7Kf3mQz9aBc for
user_42"; it calls jami_create_subscription_link.
Statuses and lifecycle
| Status | Meaning |
|---|---|
active | Paid up. currentPeriodEnd is when the next charge is due. |
past_due | The latest renewal failed and Jami is retrying. nextChargeAt is the next try. |
canceled | Ended. No more charges, ever. cancellationReason says why. |
A subscription can only reach canceled once; to start again the payer approves a new
subscribe link. cancellationReason is one of:
| Reason | What happened |
|---|---|
customer | The payer canceled from Jami. |
merchant | Your organization canceled, from the API or the dashboard. |
payment_failed | The last retry failed. |
merchant_closed | Your organization was closed, so Jami stopped charging. |
Cancel at period end, or now
cancel(id) (default) | cancel(id, { atPeriodEnd: false }) | |
|---|---|---|
| Status right away | Stays active, with cancelAtPeriodEnd: true | canceled |
| Further charges | None | None |
| Ends | At currentPeriodEnd, the end of the period they paid for | Now |
| Webhooks | subscription.updated now (change: 'cancel_scheduled'), then subscription.canceled at the end | subscription.canceled |
A past_due subscription always ends now, because there's no paid period left to finish.
Cancelling is idempotent: cancelling an ended subscription returns it unchanged. There are no
refunds for the rest of a period.
Resume
jami.subscriptions.resume(id) undoes a scheduled cancel, so the subscription renews as
normal. You can only undo a cancel your organization scheduled:
- If the payer scheduled the cancel,
resumereturns409 not_allowed. Only they can resume it, from Jami. - If the subscription has already ended,
resumereturns409 not_allowed. The payer has to subscribe again. - If no cancel is scheduled,
resumereturns the subscription unchanged.
This is deliberate: a seller can never restart charges a payer stopped.
Billing schedule
Periods are counted from the moment the payer approved, called the anchor. Each renewal is due at the anchor plus a whole number of intervals, at the same time of day (UTC). Jami never adds an interval to the previous end date, so the schedule doesn't drift.
For monthly and yearly plans, the day of the month is kept and clamped to shorter months. A monthly plan approved on January 31 renews on:
| Charge | Due |
|---|---|
| 1 | Jan 31 (at approval) |
| 2 | Feb 28 (Feb 29 in a leap year) |
| 3 | Mar 31 |
| 4 | Apr 30 |
| 5 | May 31 |
Weekly plans renew every 7 × intervalCount days. A late payment after a failed renewal
doesn't move the schedule either: the period it pays for still starts on the original due
date.
| Interval | intervalCount | Example |
|---|---|---|
week | 1 to 52 | 2 = every 2 weeks |
month | 1 to 12 | 3 = every 3 months |
year | 1 | yearly |
Failed renewals (dunning)
When a renewal can't be paid, the subscription becomes past_due and Jami tries again:
| Try | When | If it fails |
|---|---|---|
| 1 | On the due date | past_due, retry in 1 day |
| 2 | Due date + 1 day | Retry on due date + 3 days |
| 3 | Due date + 3 days | Retry on due date + 7 days |
| 4 | Due date + 7 days | canceled, cancellationReason: 'payment_failed' |
Every failed try sends subscription.payment_failed with the charge.failure and
nextRetryAt; on the last try, nextRetryAt is null and subscription.canceled follows.
A successful retry sends subscription.renewed, sets the status back to active and resets
failedAttempts to 0.
failure.code | Meaning |
|---|---|
insufficient_funds | The payer's wallet didn't have enough balance. |
kyc_required | The payer's identity verification (KYC) isn't approved. |
payer_unavailable | The payer's Jami account can't be charged, for example it was closed. |
simulated_failure | A sandbox test renewal you asked to fail. |
internal_error | Something broke on Jami's side. |
If something breaks on Jami's side during a renewal, it doesn't count as one of the payer's tries; Jami backs off and tries again later without charging twice.
Emails
Jami emails the payer and your organization's owner, so you don't have to build receipts or reminders.
| When | The payer gets | The owner gets |
|---|---|---|
| Subscription created | A receipt and the next charge date | "New subscriber" |
| Before each renewal | A reminder: 1 day ahead for weekly plans, 3 days ahead otherwise. It warns them if their balance is too low. | |
| Renewal paid | A receipt for the period | "Renewal paid" |
| Renewal failed | Why it failed and the next retry date | "Renewal failed", with the next try |
| Cancel scheduled or resumed | A confirmation, with the end date or next renewal | Only when the payer made the change |
| Subscription ended | Why it ended | "Subscription ended", with the reason |
The payer also gets a phone notification for charges, failures and reminders if they've
turned them on in Jami. In sandbox, payers get no emails, and owner emails are prefixed
[TEST].
Security model
- Your API token can't charge a wallet. It can create subscribe links, read subscriptions, cancel them, and resume a cancel you scheduled. It can't create a subscription, run a charge, change an amount, or undo a payer's cancel.
- The payer approves with their PIN. A subscription only starts when the payer, signed in to Jami, enters their 6-digit wallet PIN on Jami's own page. Five wrong PINs lock it, for 15 minutes, then 1 hour, then 24 hours. Renewals and cancelling never need the PIN.
- The amount and interval are locked. A subscribe link records the plan's amount, interval and title when you create it. The approval page shows exactly that, and approval charges exactly that, even if you edit the product's price afterwards. The subscription then keeps that amount and schedule for its whole life.
- The approval page can't be embedded. Jami serves it with
frame-ancestors 'none', so no other site can put it in an iframe and trick a payer into approving. Redirect to it or open it in a new tab. - Sandbox and production never mix. Every subscription call only sees subscriptions in
your organization's current environment: a
jamidev_test_token can't read or cancel live subscriptions, and a live token can't touch sandbox ones. - One live subscription per payer and plan. Approving a second link for a plan the payer
already has returns
already_subscribed. - Identity checks. In production, the payer needs approved KYC when they approve and at every renewal, and can't subscribe to an organization they own.
- Each period is paid at most once, even if a renewal is retried or runs twice.
Test in sandbox
In a sandbox organization, subscriptions work end to end but no money moves: there's no wallet debit, no KYC check, and you can subscribe to your own organization to try the flow. You still approve with a wallet PIN.
You don't have to wait a month to see a renewal. simulateRenewal runs the next renewal
now and makes it succeed or fail:
await jami.subscriptions.simulateRenewal('sub_Qm3xT8vLp2NaR7kd', { outcome: 'success' });
// → { result: 'renewed', subscription: { …, currentPeriodEnd: one more period ahead } }
await jami.subscriptions.simulateRenewal('sub_Qm3xT8vLp2NaR7kd', { outcome: 'failure' });
// → { result: 'retry_scheduled', subscription: { status: 'past_due', failedAttempts: 1, … } }Each call sends the same webhooks a real renewal would. A successful one moves the
subscription forward one period. Four failures in a row cancel it with payment_failed.
On a subscription with a scheduled cancel, it ends the subscription instead. It returns
409 for production subscriptions.
FAQ
Next
- Every endpoint and field: API: Subscriptions.
- Event payloads: Webhooks.
- Plan products and prices: Products & Benefits.
