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 laterimport { 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.
Collect the payment: push or link
Every checkout, for a custom amount or for a product, can be collected in
one of two ways. You choose with gateway.
| Direct push | Payment link | |
|---|---|---|
| You send | gateway, customer.email and customer.phone | No gateway. customer is optional |
| You get | mode: "direct", checkoutUrl: null | mode: "redirect", checkoutUrl: "https://jami.bio/jamidev/pay/cs_…" |
| The buyer | Gets a Telebirr, M-Pesa or CBE Birr prompt on their phone and confirms it | Opens the link, enters their phone (and email, unless you sent it), picks a method and pays |
| If a payment fails | The session ends; start a new checkout | The buyer tries again on the same link until it expires |
| Good for | Your own checkout screen, where you already have the phone | Invoices, 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.
Payment links
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
successUrlif 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 bycreateCheckout. Store it next to your order. Every order and session webhook has it asdata.sessionId.metadata, your own key/values. They're saved on the order and sent back inorder.createdandorder.completedasdata.metadata.
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
| Field | Rule |
|---|---|
productId | Leave it out. Sending one makes it a product checkout. |
amount | Required. Integer santim, at least 100 (1.00 ETB). |
description | Required. 1 to 120 characters after trimming. |
metadata | Optional. 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.phone | Required 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, collectedFields | Don't apply. They belong to products. |
| Authentication | An 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:
| Message | Fix |
|---|---|
productId is required | Send 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 characters | Shorten 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 values | Send a flat object. |
metadata can have at most 20 keys | Move the rest to your own database, keyed by sessionId. |
metadata.<key> must be a string, number or boolean | Flatten 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 want | Use |
|---|---|
| Your server decides the price per order | Custom amount |
| A shareable payment page anyone can open, without code | Product |
| Checkout links or the storefront | Product |
| Benefits such as license keys, granted on payment | Product |
| Checkout fields the buyer fills in | Product |
| A fixed catalog you edit in the dashboard | Product |
Both produce the same orders, webhooks and payouts, and both work in sandbox.
