JamiDev

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.0

Create a recurring product

setup.ts
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.

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.

app/api/subscribe/route.ts
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.

app/api/webhooks/jami/route.ts
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 call

jami.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 }); // now

The 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

StatusMeaning
activePaid up. currentPeriodEnd is when the next charge is due.
past_dueThe latest renewal failed and Jami is retrying. nextChargeAt is the next try.
canceledEnded. 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:

ReasonWhat happened
customerThe payer canceled from Jami.
merchantYour organization canceled, from the API or the dashboard.
payment_failedThe last retry failed.
merchant_closedYour organization was closed, so Jami stopped charging.

Cancel at period end, or now

cancel(id) (default)cancel(id, { atPeriodEnd: false })
Status right awayStays active, with cancelAtPeriodEnd: truecanceled
Further chargesNoneNone
EndsAt currentPeriodEnd, the end of the period they paid forNow
Webhookssubscription.updated now (change: 'cancel_scheduled'), then subscription.canceled at the endsubscription.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, resume returns 409 not_allowed. Only they can resume it, from Jami.
  • If the subscription has already ended, resume returns 409 not_allowed. The payer has to subscribe again.
  • If no cancel is scheduled, resume returns 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:

ChargeDue
1Jan 31 (at approval)
2Feb 28 (Feb 29 in a leap year)
3Mar 31
4Apr 30
5May 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.

IntervalintervalCountExample
week1 to 522 = every 2 weeks
month1 to 123 = every 3 months
year1yearly

Failed renewals (dunning)

When a renewal can't be paid, the subscription becomes past_due and Jami tries again:

TryWhenIf it fails
1On the due datepast_due, retry in 1 day
2Due date + 1 dayRetry on due date + 3 days
3Due date + 3 daysRetry on due date + 7 days
4Due date + 7 dayscanceled, 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.codeMeaning
insufficient_fundsThe payer's wallet didn't have enough balance.
kyc_requiredThe payer's identity verification (KYC) isn't approved.
payer_unavailableThe payer's Jami account can't be charged, for example it was closed.
simulated_failureA sandbox test renewal you asked to fail.
internal_errorSomething 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.

WhenThe payer getsThe owner gets
Subscription createdA receipt and the next charge date"New subscriber"
Before each renewalA reminder: 1 day ahead for weekly plans, 3 days ahead otherwise. It warns them if their balance is too low.
Renewal paidA receipt for the period"Renewal paid"
Renewal failedWhy it failed and the next retry date"Renewal failed", with the next try
Cancel scheduled or resumedA confirmation, with the end date or next renewalOnly when the payer made the change
Subscription endedWhy 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

On this page