JamiDev

Charge any amount

Charge a buyer an amount your server decides, such as 1,237 ETB for order #1042, with no product to set up.

Send an amount and a description from your server and the buyer pays exactly that. There's no product to create first. Use it when your own system decides the price:

  • a cart total or an invoice;
  • a wallet top-up, a deposit or a booking fee;
  • anything priced per request, such as delivery or a quote.

If you sell a fixed catalog and want hosted pages, checkout links or license keys, use a product instead. Product or custom amount? compares the two.

Charge 1,237 ETB

You need an API token. Custom amounts only work from your server, never from a browser.

npm install jami-sdk  # 0.5.0 or later
charge.ts
import { Jami } from 'jami-sdk';

const jami = new Jami({ token: process.env.JAMI_TOKEN! });

const checkout = await jami.createCheckout({
  amount: 123700,                        // 1,237.00 ETB in santim
  description: 'Order #1042 — 3 prints', // what the buyer sees
  metadata: { orderId: '1042' },         // optional, comes back on the webhook
  customer: { email: 'buyer@example.com', phone: '251911223344' },
  gateway: 'telebirr',                   // 'telebirr' | 'mpesa' | 'cbe'
});

const status = await jami.waitForCheckout(checkout.sessionId);
console.log(status.status, status.orderId); // 'completed' '6710c31f…'
curl https://jami.bio/api/jamidev/checkout \
  -H "Authorization: Bearer $JAMI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 123700,
    "description": "Order #1042 — 3 prints",
    "metadata": { "orderId": "1042" },
    "customer": { "email": "buyer@example.com", "phone": "251911223344" },
    "gateway": "telebirr"
  }'

Then poll GET /api/jamidev/checkout/{sessionId} until status is completed or expired, or wait for the webhook.

With the MCP server connected, ask:

Charge buyer@example.com 1,237 birr for order #1042 on Telebirr, phone 251911223344, and tell me when it's paid.

The agent calls jami_create_checkout with amount_minor: 123700 and a description, then jami_wait_for_checkout.

This one pushes the prompt to the buyer's phone. If you don't have their phone, or you'd rather send them a link, leave out gateway; see the next section. In a sandbox organization, use phone 251900000001 to pay instantly or 251900000002 to see a failure.

Every checkout, for a custom amount or for a product, can be collected in one of two ways. You choose with gateway.

Direct pushPayment link
You sendgateway, customer.email and customer.phoneNo gateway. customer is optional
You getmode: "direct", checkoutUrl: nullmode: "redirect", checkoutUrl: "https://jami.bio/jamidev/pay/cs_…"
The buyerGets a Telebirr, M-Pesa or CBE Birr prompt on their phone and confirms itOpens the link, enters their phone (and email, unless you sent it), picks a method and pays
If a payment failsThe session ends; start a new checkoutThe buyer tries again on the same link until it expires
Good forYour own checkout screen, where you already have the phoneInvoices, chat, Telegram, SMS or email, when the buyer isn't in your app

Direct push is the example above: your server talks to the buyer's phone, and no Jami page is involved.

Payment link:

const { checkoutUrl, sessionId } = await jami.createCheckout({
  amount: 123700,
  description: 'Order #1042 — 3 prints',
  metadata: { orderId: '1042' },
});

await sendSms(buyerPhone, `Pay for your order: ${checkoutUrl}`);
// Store sessionId; the order.completed webhook carries it.

The same works for a product: jami.createCheckout({ productId: 'p_…' }), plus amount for a flexible-price product. Nothing is charged until the buyer pays on the link.

A link is made for one checkout: one amount, one buyer, one payment. On the page the buyer:

  • sees your organization's name, the amount and the description (or the product's title, description and first image);
  • enters their phone, and their email for the receipt unless you passed customer;
  • picks Telebirr, CBE Birr, or More, which opens ArifPay's page with every method on the account, M-Pesa included;
  • confirms the prompt and lands on the success page, which links back to your successUrl if you set one.

The amount is locked: the buyer can't change it.

Retries. If an attempt fails (wrong PIN, timed out, rejected), the link stays open, says why, and offers Try again. The buyer can retry until the link expires. While a prompt is on its way, a new attempt is refused for 150 seconds, so a double tap can't send two prompts.

Expiry. A link lasts as long as its session: 30 minutes from createCheckout. After that the page says it has expired and nothing more can be paid. The checkout.session.expired webhook fires then, not after each failed attempt, and its failure is the last attempt's reason (or session_expired if nobody tried). Jami closes expired sessions itself within about 30 seconds, so the webhook arrives even if nobody opens the link or polls it.

Paid. Once paid, the link shows "Already paid" with the receipt. You get order.completed as for any checkout.

Status while it's open. Polling a link's session returns an extra attempt object, so you can tell "the buyer is trying" from "the last try failed":

{ "status": "open", "attempt": { "inFlight": false, "lastFailure": { "code": "payment_timed_out", "message": "…" } } }

Sandbox. Links work in a sandbox organization. Enter 0900000001 on the page to pay instantly or 0900000002 to fail; any other number opens the simulator.

Links need an API token. A checkout started without a token, such as from your product's own page, works as before.

What the buyer sees

The description takes the place of a product title:

  • on the payment prompt on their phone, and on the payment link if you sent one;
  • in their receipt email and your sale email;
  • on the hosted success, cancel and error pages;
  • in the latest orders on your dashboard overview, in GET /orders (description) and in order webhooks.

In the dashboard's Orders list these payments appear under Custom payment.

Write it the way you'd write a line on a receipt: Order #1042 — 3 prints, Wallet top-up, Booking deposit, 12 Oct. Keep it under 120 characters.

On the hosted cancel and error pages, Try again goes back to the payment link. For a direct push there's no page to go back to, so the button is hidden; start a new checkout from your own site.

Match the payment to your order

You have two handles, and the webhook carries both:

  • sessionId, returned by createCheckout. Store it next to your order. Every order and session webhook has it as data.sessionId.
  • metadata, your own key/values. They're saved on the order and sent back in order.created and order.completed as data.metadata.
webhook handler
const event = await jami.webhooks.verify({ payload: rawBody, signature, secret });

if (event.type === 'order.completed') {
  const { metadata, description, amount, sessionId } = event.data;
  await markPaid(metadata.orderId, { amount, sessionId }); // amount is 123700
}

data.description is the description you sent; for product checkouts it's null. See Webhooks for the full payload.

Rules

FieldRule
productIdLeave it out. Sending one makes it a product checkout.
amountRequired. Integer santim, at least 100 (1.00 ETB).
descriptionRequired. 1 to 120 characters after trimming.
metadataOptional. Up to 20 keys, each 1 to 40 characters. Values are strings, numbers or booleans, stored as strings, up to 500 characters each.
customer.email, customer.phoneRequired with gateway (a direct push). Optional for a payment link: the buyer enters them on the page.
gateway'telebirr', 'mpesa' or 'cbe' to push the prompt to the buyer's phone. Leave it out for a payment link.
checkoutLink, collectedFieldsDon't apply. They belong to products.
AuthenticationAn API token. Without one, the request fails with productId is required.

Buyers pay exactly amount. Fees come out when you withdraw, never at checkout.

Errors

All 400, with the message in error:

MessageFix
productId is requiredSend your API token. Custom amounts don't work without one.
description is required when productId is omitted (what the buyer is paying for)Add a description.
description must be at most 120 charactersShorten it.
amount is required when productId is omitted: an integer in ETB minor units, at least 100 (1.00 ETB)Send an integer of santim, not birr: 123700 for 1,237 ETB.
metadata must be an object of string valuesSend a flat object.
metadata can have at most 20 keysMove the rest to your own database, keyed by sessionId.
metadata.<key> must be a string, number or booleanFlatten nested values.

The SDK checks amount and description before sending, and throws JamiValidationError. Other errors, such as an invalid email, a missing phone or a bad token, are the same as for any checkout.

Product or custom amount?

You wantUse
Your server decides the price per orderCustom amount
A shareable payment page anyone can open, without codeProduct
Checkout links or the storefrontProduct
Benefits such as license keys, granted on paymentProduct
Checkout fields the buyer fills inProduct
A fixed catalog you edit in the dashboardProduct

Both produce the same orders, webhooks and payouts, and both work in sandbox.

On this page