Business integration · API v1
Take BOONKA payments on your own site.
Create a payment session from your server, send the shopper to BOONKA Checkout, and get a signed webhook the moment they pay with their BOONKA credit. Three calls, no card data on your infrastructure.
هذه الوثيقة متوفرة باللغة الإنجليزية.
Life of a payment session
every session is a state machine with a 30-minute clock
- createdyour server
- pendingawaiting shopper
- processingcredit debited
- succeededwebhook fires
- paid outafter each sale
expired30 min passedcancelledyou or the shopperfailedpayment declinedShip the order on payment.succeeded, not on the redirect.Get paid out after each sale, minus the 2% fee.
Get started
How BOONKA Checkout works
Your shopper pays with their prepaid BOONKA credit, in Algerian dinars, so there are no card numbers, no PANs, and nothing sensitive ever touches your servers. You never handle credentials; you hand off to a hosted checkout page and wait to be told what happened.
An integration is built from four moving parts:
- Your server creates a payment session: an amount, your order reference, and where to send the shopper afterwards.
- BOONKA Checkout is the hosted page the shopper opens. They sign in to their BOONKA account, review your order, and confirm.
- Your webhook endpoint receives a signed
payment.succeededevent. This is the only trustworthy signal that you have been paid. - Your business dashboard is where you create API keys, set the webhook URL, and read the payment log.
Everything below the quickstart is detail. If you only read one paragraph, read this one: create the session server-side, redirect, and confirm by webhook.
Base URLs
| What | URL |
|---|---|
| API | https://api.boonka.com/api/v1 |
| Hosted checkout | https://checkout.boonka.io/{session_id} |
All requests are HTTPS and all bodies are JSON. Never build the checkout URL yourself; use the checkout_url returned when you create the session.
# 1. Your server creates the session
POST /api/v1/payment-sessions/store
→ checkout_url, id, expires_at
# 2. You redirect the shopper
302 https://checkout.boonka.io/9d4b1c2e-…
# 3. The shopper pays with their BOONKA credit
# BOONKA POSTs to your endpoint
POST https://shop.dz/webhooks/boonka
X-Gateway-Timestamp: 1785412200
X-Gateway-Signature: sha256=a3f1…
{"type": "payment.succeeded", …}
# 4. The shopper lands back on your site
302 https://shop.dz/orders/1042/paidQuickstart
Your first payment
This gets you a working test payment end to end. Pick your language in any code panel; the choice follows you down the whole page.
Create a test API key
In your BOONKA business dashboard, open Developers → API keys and create a key in Test mode. You get a pair:
pk_test_… is the public key, sent in the X-API-Key header. sk_test_… is the secret key, sent as a Bearer token. The secret is shown once and never again. Store it in your server’s environment, never in client code or a repository.
Create a payment session
From your server, when the shopper clicks "Pay with BOONKA". Send the amount in minor units: 4 500,00 DZD is 450000.
Redirect to checkout_url
Send the shopper to the checkout_url in the response. Store the session id against your order first; you will need it to reconcile the webhook.
Confirm with a webhook
Set your endpoint under Developers → Webhooks, verify the signature on every request, and fulfil the order when you receive payment.succeeded. Use Send test event to check your receiver before you take a real payment.
curl https://api.boonka.com/api/v1/payment-sessions/store \
-H "X-API-Key: $BOONKA_PUBLIC_KEY" \
-H "Authorization: Bearer $BOONKA_SECRET_KEY" \
-H "Idempotency-Key: order_1042.v1" \
-H "Content-Type: application/json" \
-d '{
"amount": 450000,
"reference": "order_1042",
"success_url": "https://shop.dz/orders/1042/paid",
"cancel_url": "https://shop.dz/orders/1042/cancelled",
"metadata": { "order_id": "1042" }
}'const res = await fetch(
"https://api.boonka.com/api/v1/payment-sessions/store",
{
method: "POST",
headers: {
"X-API-Key": process.env.BOONKA_PUBLIC_KEY,
"Authorization": `Bearer ${process.env.BOONKA_SECRET_KEY}`,
"Idempotency-Key": `order_${order.id}.v${order.version}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: 450000, // 4 500,00 DZD
reference: `order_${order.id}`,
success_url: `https://shop.dz/orders/${order.id}/paid`,
cancel_url: `https://shop.dz/orders/${order.id}/cancelled`,
metadata: { order_id: String(order.id) },
}),
}
);
const { data } = await res.json();
await orders.update(order.id, { boonka_session_id: data.id });
return reply.redirect(data.checkout_url);<?php
use GuzzleHttp\Client;
$client = new Client(['base_uri' => 'https://api.boonka.com']);
$response = $client->post('/api/v1/payment-sessions/store', [
'headers' => [
'X-API-Key' => getenv('BOONKA_PUBLIC_KEY'),
'Authorization' => 'Bearer ' . getenv('BOONKA_SECRET_KEY'),
'Idempotency-Key' => "order_{$order->id}.v{$order->version}",
],
'json' => [
'amount' => 450000, // 4 500,00 DZD
'reference' => "order_{$order->id}",
'success_url' => "https://shop.dz/orders/{$order->id}/paid",
'cancel_url' => "https://shop.dz/orders/{$order->id}/cancelled",
'metadata' => ['order_id' => (string) $order->id],
],
]);
$session = json_decode((string) $response->getBody(), true)['data'];
$order->update(['boonka_session_id' => $session['id']]);
return redirect($session['checkout_url']);import os, requests
response = requests.post(
"https://api.boonka.com/api/v1/payment-sessions/store",
headers={
"X-API-Key": os.environ["BOONKA_PUBLIC_KEY"],
"Authorization": f"Bearer {os.environ['BOONKA_SECRET_KEY']}",
"Idempotency-Key": f"order_{order.id}.version_{order.version}",
},
json={
"amount": 450000, # 4 500,00 DZD
"reference": f"order_{order.id}",
"success_url": f"https://shop.dz/orders/{order.id}/paid",
"cancel_url": f"https://shop.dz/orders/{order.id}/cancelled",
"metadata": {"order_id": str(order.id)},
},
timeout=15,
)
response.raise_for_status()
session = response.json()["data"]
order.save(boonka_session_id=session["id"])
return redirect(session["checkout_url"]){
"success": true,
"message": "Payment session created successfully",
"data": {
"id": "9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
"status": "pending",
"amount": 450000,
"currency": "DZD",
"reference": "order_1042",
"checkout_url": "https://checkout.boonka.io/9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
"expires_at": "2026-07-29T12:00:00+00:00",
"created_at": "2026-07-29T11:30:00+00:00"
}
}Authentication
API keys
Server-to-server calls are authenticated with a key pair, sent as two headers on every request:
| Header | Value |
|---|---|
X-API-Key | Your public key: pk_test_… or pk_live_… |
Authorization | Bearer followed by your secret key: sk_test_… or sk_live_… |
Both are required. Missing either one returns 401 Missing API Key.; a mismatched or revoked pair returns 401 Invalid API credentials.
One active key per environment
You hold at most one active key per environment. Creating a new key immediately revokes the previous one for that environment. This is how rotation works, and it is not reversible. Deploy the new secret before you create it, or plan for the brief window where in-flight requests fail with 401.
Keeping keys safe
- Keep the secret key on your server, in environment variables or a secret manager. It must never reach a browser, a mobile app, or a public repository.
- The public key is not a secret in the cryptographic sense, but it identifies you, so there is no reason to publish it either.
- Rotate immediately if a secret is exposed. Creating a replacement key revokes the leaked one.
- Use separate keys for staging and production by using test mode for staging.
curl https://api.boonka.com/api/v1/payment-sessions/9d4b1c2e-… \
-H "X-API-Key: pk_live_RVLyODnGa7FRXewlPasom6a7RDpBqTOP" \
-H "Authorization: Bearer sk_live_…"// One place, reused everywhere.
export const boonka = {
base: "https://api.boonka.com/api/v1",
headers: () => ({
"X-API-Key": process.env.BOONKA_PUBLIC_KEY,
"Authorization": `Bearer ${process.env.BOONKA_SECRET_KEY}`,
"Content-Type": "application/json",
}),
};<?php
$client = new GuzzleHttp\Client([
'base_uri' => 'https://api.boonka.com/api/v1/',
'headers' => [
'X-API-Key' => getenv('BOONKA_PUBLIC_KEY'),
'Authorization' => 'Bearer ' . getenv('BOONKA_SECRET_KEY'),
],
'timeout' => 15,
]);import os, requests
boonka = requests.Session()
boonka.headers.update({
"X-API-Key": os.environ["BOONKA_PUBLIC_KEY"],
"Authorization": f"Bearer {os.environ['BOONKA_SECRET_KEY']}",
})
BASE = "https://api.boonka.com/api/v1"{
"success": false,
"message": "Invalid API credentials."
}Get started
Test and live mode
BOONKA gives you two complete, separate environments. You build and prove your integration in test mode, then switch to live mode to take real money. The API is identical in both: same endpoints, same payloads, same signed webhooks.
The environment is carried by the key itself; there is no mode parameter to forget. A pk_test_ / sk_test_ pair creates test sessions. A pk_live_ / sk_live_ pair creates real ones.
| Test mode | Live mode | |
|---|---|---|
| Key prefix | pk_test_ · sk_test_ | pk_live_ · sk_live_ |
| Money | None. Test credit only. No real money moves. | Real dinars, real payouts. |
| Shopper | A second BOONKA test account. | Your real customers. |
| Webhooks | Delivered and signed identically. | Delivered and signed identically. |
environment | "test" on sessions and events. | "live" on sessions and events. |
The two are sealed off from each other
A session created with a test key is invisible to a live key, and retrieving it returns 404. That is deliberate: a stray test key in production fails loudly instead of quietly reading nothing.
Both environments deliver to the same webhook URL, so your handler must read environment on every event and refuse to act on the wrong one. This is the single most important line of code in the switch to production.
Moving from test to live
Finish the testing phase, then work through the production checklist.
// Run this at boot, before the app serves traffic.
const isProduction = process.env.NODE_ENV === "production";
const expected = isProduction ? "pk_live_" : "pk_test_";
if (!process.env.BOONKA_PUBLIC_KEY?.startsWith(expected)) {
throw new Error(
`BOONKA key is wrong for this deploy; expected ${expected}`
);
}<?php
// A service provider boot method, or a deploy check.
$expected = app()->isProduction() ? 'pk_live_' : 'pk_test_';
if (! str_starts_with((string) config('boonka.public_key'), $expected)) {
throw new RuntimeException(
"BOONKA key is wrong for this deploy; expected {$expected}"
);
}import os
expected = "pk_live_" if os.getenv("ENV") == "production" else "pk_test_"
if not os.environ["BOONKA_PUBLIC_KEY"].startswith(expected):
raise RuntimeError(
f"BOONKA key is wrong for this deploy; expected {expected}"
)Core concepts
Amounts and currency
Every amount in the API is an integer in minor units, that is, centimes. BOONKA works in Algerian dinars and currency is always "DZD"; there is no currency parameter to send.
One dinar is 100 centimes, so 450000 means 4 500,00 DZD. Sending 4500.00 is rejected: amount is an integer.
Convert once, at the boundary, and keep integers everywhere else. Never hold an amount in a floating-point value.
Itemised checkout
You can pass items, service_fee and transaction_tax so the checkout page shows a breakdown instead of a single figure. These fields are display only: the shopper is charged exactly amount, whatever the lines add up to.
If you send a breakdown, make it reconcile with amount yourself. BOONKA renders what you send without checking the arithmetic.
Metadata
metadata is key-value data you attach to a session and get back on every read and every webhook. Use it to carry your own identifiers so you never have to guess which order an event belongs to.
{
"amount": 262500, // charged: 2 625,00 DZD
"service_fee": 5000, // shown: 50,00 DZD
"transaction_tax": 7500, // shown: 75,00 DZD
"items": [
{
"name": "Herbal tea blend, 250 g",
"quantity": 2,
"amount": 125000
}
],
"reference": "order_1042",
"success_url": "https://shop.dz/orders/1042/paid",
"cancel_url": "https://shop.dz/orders/1042/cancelled"
}"metadata": {
"order_id": "1042",
"customer_id": "cust_8821",
"is_gift": true,
"table": 14
}
// Comes back unchanged on every read
// and on every webhook for this session.Core concepts
Session lifecycle
A payment session is created pending and lives for 30 minutes. Within that window the shopper can open the checkout and pay. After it, the session is dead and cannot be revived. Create a new one.
The expiry is fixed and not configurable. Create the session when the shopper is ready to pay, not when they add something to their basket.
| Status | Meaning | Terminal |
|---|---|---|
pending | Created and waiting. The shopper can pay. | no |
processing | The charge is in flight. Lasts moments. | no |
succeeded | Paid. Fulfil the order. | yes |
failed | The charge was attempted and declined. | yes |
cancelled | Cancelled by you or by the shopper. | yes |
expired | The 30-minute window closed unpaid. | yes |
refunded | Fully refunded after a dispute. | yes |
partially_refunded | Partly refunded after a dispute. | yes |
What can go wrong at checkout
These are resolved on BOONKA’s side and reach you as a status, not as an API error. Your job is to handle the resulting webhook or read the session.
- Not enough credit. The shopper’s BOONKA credit cannot cover the amount. Nothing is charged and the session stays
pending, so they can recharge and try again inside the window. - The shopper is you. A business cannot pay their own session. Use a second test account.
- Session already claimed. The first shopper to open a checkout link claims it. A second, different shopper is refused, so treat
checkout_urlas private to one buyer. - Business suspended. A business carrying a severe reputation penalty cannot receive payments, and shoppers are told so at checkout.
// A webhook arrived, or a shopper came back
// to your success page. Same handling either way:
const session = await boonka.retrieve(order.boonka_session_id);
switch (session.status) {
case "succeeded":
await orders.fulfilOnce(order.id, session.id);
break;
case "pending":
case "processing":
await orders.markAwaitingPayment(order.id);
break;
case "expired":
case "cancelled":
case "failed":
await orders.restockOrder(order.id);
break;
}Core concepts
Settlement
When a payment succeeds, the shopper’s BOONKA credit is debited and you are paid out after each sale, minus BOONKA’s 2% fee. That 2% on what you receive is BOONKA’s only fee.
So payment.succeeded means the shopper has paid and their BOONKA credit has been debited. The shopper is always charged exactly amount: the fee comes out of what you receive, never on top of the shopper’s price.
What this means for your integration
- Fulfil on
payment.succeeded. The shopper has been debited and is entitled to their order. - Do not reconcile your bank account against this API. Payouts live in your dashboard, not in the session object.
- Expect disputes to matter. If a shopper opens a dispute, the payment can be refunded to them.
Refunds
Refunds are handled through the BOONKA dashboard and the dispute process. There is no business-facing refund endpoint in v1. A refund after a dispute goes back to the shopper as BOONKA credit, and the session reflects it: status becomes refunded or partially_refunded, and refunded_amount carries the amount returned in minor units.
Poll or read the session if you need to reflect refunds in your own records; refunds do not currently emit a webhook event.
{
"id": "9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
"status": "partially_refunded",
"amount": 450000,
"refunded_amount": 120000,
"currency": "DZD",
"paid_at": "2026-07-22T11:45:00+00:00"
}Core concepts
Idempotency
Send an Idempotency-Key header when you create a payment session. BOONKA does not reject a request without one, but without it a retry creates a second session for the same order.
The key makes retries safe. If your request times out and you send it again with the same key, BOONKA replays the original response instead of creating a second session, so a flaky network cannot produce two checkout links for one order.
| Rule | Detail |
|---|---|
| Replay | Same key, same body → the original session, not a second one |
| Conflict | Same key, different body → 409, nothing is created |
Choosing a key
Derive it from the thing you are trying to do exactly once. An order identifier plus a version counter works well: order_1042.v1. If the shopper abandons checkout and comes back with a changed basket, bump to order_1042.v2 so you get a fresh session for the new amount.
Do not use a random UUID generated at the top of your retry loop; a new key on each attempt defeats the entire mechanism. Generate the key once, then retry with it.
# Same key, same body. Run it twice.
# The second call returns the FIRST session.
curl -i https://api.boonka.com/api/v1/payment-sessions/store \
-H "X-API-Key: $BOONKA_PUBLIC_KEY" \
-H "Authorization: Bearer $BOONKA_SECRET_KEY" \
-H "Idempotency-Key: order_1042.v1" \
-H "Content-Type: application/json" \
-d '{"amount":450000,"success_url":"…","cancel_url":"…"}'
# → HTTP/1.1 201 Created id 9d4b1c2e-…
# → HTTP/1.1 201 Created id 9d4b1c2e-… (same session)// Generate the key ONCE, outside the retry loop, so every
// attempt for this order carries the same one.
const idempotencyKey = `order_${order.id}.v${order.version}`;
async function createSession(body) {
const res = await fetch(`${boonka.base}/payment-sessions/store`, {
method: "POST",
headers: { ...boonka.headers(), "Idempotency-Key": idempotencyKey },
body: JSON.stringify(body),
});
// A 409 means this key is spoken for by a different body.
// Retrying will not clear it; fix the call instead.
if (!res.ok) throw new Error(`BOONKA ${res.status}`);
return (await res.json()).data;
}<?php
// Derive the key from the order, not from the attempt.
$key = "order_{$order->id}.v{$order->version}";
$response = $client->post('payment-sessions/store', [
'headers' => ['Idempotency-Key' => $key],
'json' => $payload,
'http_errors' => false,
]);
// Same key, same body: this is the original session,
// however many times the request went out.
$session = json_decode((string) $response->getBody(), true)['data'];key = f"order_{order.id}.version_{order.version}" # once, not per attempt
response = boonka.post(
f"{BASE}/payment-sessions/store",
headers={"Idempotency-Key": key},
json=payload,
timeout=15,
)
# Same key, same body: the original session comes back,
# however many times the request went out.
response.raise_for_status()
session = response.json()["data"]{
"success": false,
"message": "Idempotency key conflict. This key was already used for a different request."
}Core concepts
Redirect URLs
Every session carries two URLs you provide. Both must be absolute and reachable by a browser.
| Field | When the shopper lands there |
|---|---|
success_url | After a successful payment |
cancel_url | After cancelling, or when the payment fails |
A redirect is a courtesy, not a receipt
Landing on success_url tells you the shopper’s browser reached a URL. It does not tell you money moved. Anyone can type that URL. Browsers close, connections drop, and phones die between the payment and the redirect.
So treat the success page as a place to show the outcome, never to decide it. When the shopper arrives, look up the order and either read the session from the API or check whether the webhook has already marked it paid.
Make the URLs identify the order
BOONKA appends nothing to your URLs: no session id, no status. Put your own identifier in the path or query string so the page knows which order it is looking at:
https://shop.dz/orders/1042/paid, clean and unguessable if 1042 is paired with a session lookup.https://shop.dz/checkout/return?order=1042&t=b7f3…, add a short signed token if the page must work without a session cookie.
// GET /orders/:id/paid
app.get("/orders/:id/paid", async (req, res) => {
const order = await orders.find(req.params.id);
// The webhook usually beats the browser back.
if (order.status === "paid") {
return res.render("thanks", { order });
}
// It hasn't yet. Ask BOONKA directly.
const session = await boonka.retrieve(order.boonka_session_id);
if (session.status === "succeeded") {
await orders.fulfilOnce(order.id, session.id);
return res.render("thanks", { order });
}
// Still settling. Show a waiting state, not a failure.
return res.render("confirming", { order });
});API reference
Create a payment session
/api/v1/payment-sessions/storeCreates a session and returns the checkout_url to send the shopper to. The environment comes from your key pair.
Headers
| Header | Description | |
|---|---|---|
X-API-Key | required | Your public key. |
Authorization | required | Bearer plus your secret key. |
Idempotency-Key | recommended | Makes the call safe to retry. See Idempotency. |
Content-Type | required | application/json |
Body
| Field | Type | Description |
|---|---|---|
amount | integerrequired | Amount to charge, in minor units. |
success_url | stringrequired | Where to send the shopper after a successful payment. Absolute URL. |
cancel_url | stringrequired | Where to send the shopper after a cancelled or failed payment. |
reference | stringoptional | Your own order identifier. Max 255 characters. Searchable in the payment log and returned on every event. |
metadata | objectoptional | Key-value data returned with the session and every webhook. |
service_fee | integeroptional | Fee line shown on the checkout page, in minor units. Display only: it does not change what is charged. |
transaction_tax | integeroptional | Tax line shown on the checkout page, in minor units. Display only. |
items | arrayoptional | Line items rendered on the checkout page. Display only. Each item: name (string, max 255, required), quantity (integer ≥ 1, required), amount (integer ≥ 0, minor units, required). |
Returns
201 with the new session. Store data.id against your order before you redirect. It is the only handle you have on the payment afterwards.
curl https://api.boonka.com/api/v1/payment-sessions/store \
-H "X-API-Key: $BOONKA_PUBLIC_KEY" \
-H "Authorization: Bearer $BOONKA_SECRET_KEY" \
-H "Idempotency-Key: order_1042.v1" \
-H "Content-Type: application/json" \
-d '{
"amount": 450000,
"reference": "order_1042",
"success_url": "https://shop.dz/orders/1042/paid",
"cancel_url": "https://shop.dz/orders/1042/cancelled",
"metadata": {
"order_id": "1042",
"customer_id": "cust_8821"
},
"service_fee": 5000,
"items": [
{ "name": "Herbal tea blend, 250 g", "quantity": 2, "amount": 125000 }
]
}'const res = await fetch(`${boonka.base}/payment-sessions/store`, {
method: "POST",
headers: {
...boonka.headers(),
"Idempotency-Key": `order_${order.id}.v${order.version}`,
},
body: JSON.stringify({
amount: order.totalMinor,
reference: `order_${order.id}`,
success_url: `https://shop.dz/orders/${order.id}/paid`,
cancel_url: `https://shop.dz/orders/${order.id}/cancelled`,
metadata: { order_id: String(order.id), customer_id: order.customerId },
service_fee: order.shippingMinor,
items: order.lines.map((l) => ({
name: l.title,
quantity: l.qty,
amount: l.unitPriceMinor,
})),
}),
});
if (!res.ok) throw new Error(`BOONKA ${res.status}`);
const { data: session } = await res.json();<?php
$response = $client->post('payment-sessions/store', [
'headers' => [
'Idempotency-Key' => "order_{$order->id}.v{$order->version}",
],
'json' => [
'amount' => $order->total_minor,
'reference' => "order_{$order->id}",
'success_url' => "https://shop.dz/orders/{$order->id}/paid",
'cancel_url' => "https://shop.dz/orders/{$order->id}/cancelled",
'metadata' => [
'order_id' => (string) $order->id,
'customer_id' => $order->customer_id,
],
'service_fee' => $order->shipping_minor,
'items' => $order->lines->map(fn ($line) => [
'name' => $line->title,
'quantity' => $line->qty,
'amount' => $line->unit_price_minor,
])->all(),
],
]);
$session = json_decode((string) $response->getBody(), true)['data'];response = boonka.post(
f"{BASE}/payment-sessions/store",
headers={"Idempotency-Key": f"order_{order.id}.version_{order.version}"},
json={
"amount": order.total_minor,
"reference": f"order_{order.id}",
"success_url": f"https://shop.dz/orders/{order.id}/paid",
"cancel_url": f"https://shop.dz/orders/{order.id}/cancelled",
"metadata": {
"order_id": str(order.id),
"customer_id": order.customer_id,
},
"service_fee": order.shipping_minor,
"items": [
{"name": l.title, "quantity": l.qty, "amount": l.unit_price_minor}
for l in order.lines
],
},
timeout=15,
)
response.raise_for_status()
session = response.json()["data"]{
"success": true,
"message": "Payment session created successfully",
"data": {
"id": "9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
"status": "pending",
"amount": 450000,
"currency": "DZD",
"reference": "order_1042",
"checkout_url": "https://checkout.boonka.io/9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
"expires_at": "2026-07-29T12:00:00+00:00",
"created_at": "2026-07-29T11:30:00+00:00"
}
}{
"success": false,
"message": "The success url field must be a valid URL.",
"errors": {
"success_url": ["The success url field must be a valid URL."]
}
}API reference
Retrieve a payment session
/api/v1/payment-sessions/{id}Reads the current state of a session. This is your source of truth whenever you need to know where a payment stands: on the return page, in a reconciliation job, or when a customer asks.
| Parameter | Type | Description |
|---|---|---|
id | pathrequired | The session id returned at creation, e.g. 9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e. |
Scoping
A key can only read its own business’s sessions, in its own environment. Anything else returns 404, including a live key asking for a test session. A 404 here means "not yours or not in this environment", not necessarily "does not exist".
Reading, not polling
Webhooks are the intended way to learn that a payment happened; this endpoint is for checking a specific session you already care about. If you do run a sweeper for missed events, keep it to sessions you know are outstanding and back it off once they reach a terminal status. Do not poll every session on a timer.
Returns
200 with the full session object.
curl https://api.boonka.com/api/v1/payment-sessions/9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e \
-H "X-API-Key: $BOONKA_PUBLIC_KEY" \
-H "Authorization: Bearer $BOONKA_SECRET_KEY"boonka.retrieve = async (id) => {
const res = await fetch(`${boonka.base}/payment-sessions/${id}`, {
headers: boonka.headers(),
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`BOONKA ${res.status}`);
return (await res.json()).data;
};<?php
$response = $client->get("payment-sessions/{$sessionId}", [
'http_errors' => false,
]);
if ($response->getStatusCode() === 404) {
return null;
}
$session = json_decode((string) $response->getBody(), true)['data'];def retrieve(session_id):
response = boonka.get(f"{BASE}/payment-sessions/{session_id}", timeout=15)
if response.status_code == 404:
return None
response.raise_for_status()
return response.json()["data"]{
"success": true,
"message": "Payment session retrieved successfully",
"data": {
"id": "9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
"status": "succeeded",
"environment": "live",
"merchant_name": "Noqta",
"amount": 450000,
"total": 450000,
"service_fee": 5000,
"transaction_tax": 0,
"items": [
{ "name": "Herbal tea blend, 250 g", "quantity": 2, "amount": 125000 }
],
"currency": "DZD",
"reference": "order_1042",
"metadata": { "order_id": "1042" },
"checkout_url": "https://checkout.boonka.io/9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
"transaction_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"refunded_amount": 0,
"failure_reason": null,
"expires_at": "2026-07-29T12:00:00+00:00",
"paid_at": "2026-07-29T11:45:00+00:00",
"created_at": "2026-07-29T11:30:00+00:00",
"updated_at": "2026-07-29T11:45:00+00:00"
}
}API reference
Cancel a payment session
/api/v1/payment-sessions/{id}/cancelCancels a session that has not been paid yet. Use it when the shopper abandons checkout on your side, when stock runs out, or when your order is voided before payment.
Only a pending session can be cancelled. It moves to cancelled, its checkout link stops working, and payment.cancelled fires.
| Outcome | Status | Response |
|---|---|---|
| Was pending | 200 | Cancelled. Webhook fires. |
| Already cancelled | 200 | No change, no new webhook. Safe to call twice. |
| Paid, failed or expired | 409 | Nothing changes. The body carries the current status. |
| Not yours | 404 | Wrong business or wrong environment. |
No Idempotency-Key is needed, because cancelling twice is already harmless.
You rarely need to cancel just to clean up. An unpaid session expires by itself after 30 minutes.
curl -X POST \
https://api.boonka.com/api/v1/payment-sessions/9d4b1c2e-…/cancel \
-H "X-API-Key: $BOONKA_PUBLIC_KEY" \
-H "Authorization: Bearer $BOONKA_SECRET_KEY"const res = await fetch(
`${boonka.base}/payment-sessions/${sessionId}/cancel`,
{ method: "POST", headers: boonka.headers() }
);
const { data } = await res.json();
if (res.status === 409 && data.status === "succeeded") {
// They paid first. Fulfil, don't retry.
await orders.fulfilOnce(order.id, data.id);
}<?php
$response = $client->post("payment-sessions/{$sessionId}/cancel", [
'http_errors' => false,
]);
$body = json_decode((string) $response->getBody(), true);
if ($response->getStatusCode() === 409
&& $body['data']['status'] === 'succeeded') {
$order->fulfilOnce($body['data']['id']);
}response = boonka.post(
f"{BASE}/payment-sessions/{session_id}/cancel", timeout=15
)
body = response.json()
if response.status_code == 409 and body["data"]["status"] == "succeeded":
# They paid first. Fulfil, don't retry.
orders.fulfil_once(order.id, body["data"]["id"]){
"success": true,
"message": "Payment session cancelled.",
"data": {
"id": "9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
"status": "cancelled",
"reference": "order_1042",
"redirect_url": "https://shop.dz/orders/1042/cancelled"
}
}{
"success": false,
"message": "This payment session can no longer be cancelled.",
"data": {
"id": "9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
"status": "succeeded",
"reference": "order_1042",
"redirect_url": "https://shop.dz/orders/1042/cancelled"
}
}API reference
The payment session object
Returned in full by retrieve. Creation returns a shorter form containing id, status, amount, currency, reference, checkout_url, expires_at and created_at.
All timestamps are ISO 8601 with an offset. All amounts are integers in minor units.
| Field | Type | Description |
|---|---|---|
id | string | UUID of the session. Your handle on the payment; store it against the order. |
status | string | One of the lifecycle statuses. |
environment | string | test or live, inherited from the key that created it. |
merchant_name | string | Your display name as the shopper sees it at checkout. |
amount | integer | What the shopper is charged, in minor units. |
total | integer | The charged total. Equal to amount today; a separate field so itemised sessions can diverge later. |
service_fee | integernullable | Display-only fee line, if you sent one. |
transaction_tax | integernullable | Display-only tax line, if you sent one. |
items | arraynullable | Display-only line items, if you sent them. |
currency | string | Always "DZD". |
reference | stringnullable | Your order identifier, as supplied at creation. |
metadata | objectnullable | The metadata you attached, returned unchanged. |
checkout_url | string | The hosted checkout page for this session. |
transaction_id | stringnullable | The transaction record, once the payment has succeeded. null before that. |
refunded_amount | integer | Amount refunded so far, in minor units. 0 unless the session was refunded. |
failure_reason | stringnullable | Why the payment failed or was cancelled, e.g. cancelled_by_merchant. null otherwise. |
expires_at | string | When the session stops being payable, 30 minutes after creation. |
paid_at | stringnullable | When the payment succeeded. null until then. |
created_at | string | When the session was created. |
updated_at | string | When the session last changed. |
{
"id": "9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
"status": "succeeded",
"environment": "live",
"merchant_name": "Noqta",
"amount": 450000,
"total": 450000,
"service_fee": 5000,
"transaction_tax": 0,
"items": [
{ "name": "Herbal tea blend, 250 g", "quantity": 2, "amount": 125000 }
],
"currency": "DZD",
"reference": "order_1042",
"metadata": { "order_id": "1042" },
"checkout_url": "https://checkout.boonka.io/9d4b1c2e-…",
"transaction_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"refunded_amount": 0,
"failure_reason": null,
"expires_at": "2026-07-29T12:00:00+00:00",
"paid_at": "2026-07-29T11:45:00+00:00",
"created_at": "2026-07-29T11:30:00+00:00",
"updated_at": "2026-07-29T11:45:00+00:00"
}Webhooks
Receiving events
Webhooks are how you find out that a payment happened. BOONKA POSTs a JSON event to a URL you own, signed so you can prove it came from BOONKA.
You configure one endpoint per business account in Developers → Webhooks. It receives both test and live events; the environment field on every event tells you which is which.
Requirements for your endpoint
- HTTPS only. Plain HTTP is rejected when you save the URL.
- Publicly reachable.
localhost,.local,.internaland private IP ranges are refused. For local development, use a tunnel that gives you a public HTTPS hostname. - Fast. BOONKA waits 10 seconds for a response. Acknowledge first, do the work afterwards.
- Unauthenticated. Exclude the route from login middleware and CSRF protection. The signature is the authentication.
Answer quickly, work later
Return 200 as soon as you have verified the signature and safely recorded the event. Anything slower than 10 seconds is recorded as a failed delivery and retried, which means you will process the same payment several times for no reason.
Verify, write the event to a queue or a table, respond, and let a worker do the fulfilment.
Expect duplicates and out-of-order arrivals
Delivery is at least once. A slow response, a network blip, or a retry after a transient failure can all produce the same event twice. Ordering is not guaranteed either: under retry, payment.succeeded can arrive after a later event.
Make your handler idempotent by keying on the event id. Insert it with a unique constraint; if the insert conflicts, you have seen the event and can return 200 without doing the work again.
POST /webhooks/boonka HTTP/1.1
Host: shop.dz
Content-Type: application/json
X-Gateway-Timestamp: 1785412200
X-Gateway-Signature: sha256=a3f1c9e0b74d…
{
"id": "7c1e4a8f-0d33-4b2e-9a71-5f1c0c2a7b4e",
"type": "payment.succeeded",
"environment": "live",
"created_at": "2026-07-29T11:45:00+00:00",
"data": {
"id": "9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
"status": "succeeded",
"amount": 450000,
"currency": "DZD",
"reference": "order_1042",
"metadata": { "order_id": "1042" },
"paid_at": "2026-07-29T11:45:00+00:00"
}
}-- One row per event. The constraint does the work.
CREATE TABLE boonka_events (
event_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (event_id)
);
-- In the handler: if nothing was inserted,
-- it's a duplicate. Return 200 and stop.
INSERT INTO boonka_events (event_id, event_type, payload)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
RETURNING event_id;Webhooks
Verifying signatures
Your webhook URL is public, so anyone can POST to it. The signature is what separates a real BOONKA event from someone claiming an order was paid. Verify every request before you read the body.
The two headers
| Header | Value |
|---|---|
X-Gateway-Timestamp | Unix time in seconds when the delivery was signed. |
X-Gateway-Signature | sha256= followed by the hex HMAC. |
How to verify
- Read the raw request body, exactly as received. Do not parse and re-serialise it first, because any change in whitespace or key order breaks the signature.
- Build the signed string: the timestamp, a full stop, then the raw body:
{timestamp}.{body}. - Compute
HMAC-SHA256over it using your endpoint’s signing secret (whsec_…), hex-encoded. - Compare
"sha256=" + your_hmacwith the header using a constant-time comparison. A plain==leaks timing information. - Reject anything whose timestamp is more than about five minutes from your clock, so a captured request cannot be replayed later.
Getting the secret
The signing secret is shown in full when you first save your webhook and whenever you rotate it. After that the dashboard shows only a hint like whsec_…a91f. Store it as an environment variable next to your API secret.
Rotating invalidates the old secret immediately. Deploy the new one first, then rotate, or accept a short window of rejected deliveries, which will be retried.
Test it before you trust it
Developers → Webhooks → Send test event delivers a webhook.test event signed exactly like a real one. If your verification passes for that, it will pass for a payment.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.BOONKA_WEBHOOK_SECRET;
const TOLERANCE = 300; // seconds
app.post(
"/webhooks/boonka",
express.raw({ type: "application/json" }), // raw bytes
async (req, res) => {
const timestamp = req.get("X-Gateway-Timestamp");
const signature = req.get("X-Gateway-Signature");
if (!timestamp || !signature) return res.sendStatus(400);
// Replay guard
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE) {
return res.sendStatus(400);
}
const body = req.body.toString("utf8");
const expected =
"sha256=" +
crypto
.createHmac("sha256", SECRET)
.update(`${timestamp}.${body}`)
.digest("hex");
// Constant-time compare
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(400);
}
const event = JSON.parse(body);
// Record it, then answer. Fulfil in a worker.
const isNew = await events.record(event.id, event.type, event);
res.sendStatus(200);
if (isNew) await queue.push("boonka.event", event);
}
);<?php
$secret = getenv('BOONKA_WEBHOOK_SECRET');
$raw = file_get_contents('php://input'); // raw bytes
$timestamp = $_SERVER['HTTP_X_GATEWAY_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_GATEWAY_SIGNATURE'] ?? '';
if ($timestamp === '' || $signature === '') {
http_response_code(400);
exit;
}
// Replay guard: 5 minutes
if (abs(time() - (int) $timestamp) > 300) {
http_response_code(400);
exit;
}
$expected = 'sha256=' . hash_hmac(
'sha256',
$timestamp . '.' . $raw,
$secret
);
if (! hash_equals($expected, $signature)) { // constant time
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
// Record, answer, then fulfil out of band.
$isNew = $events->record($event['id'], $event['type'], $event);
http_response_code(200);
if ($isNew) {
dispatch(new HandleBoonkaEvent($event));
}import hashlib, hmac, json, os, time
from flask import Flask, request, abort
SECRET = os.environ["BOONKA_WEBHOOK_SECRET"].encode()
TOLERANCE = 300 # seconds
@app.post("/webhooks/boonka")
def boonka_webhook():
raw = request.get_data() # raw bytes
timestamp = request.headers.get("X-Gateway-Timestamp", "")
signature = request.headers.get("X-Gateway-Signature", "")
if not timestamp or not signature:
abort(400)
# Replay guard
try:
if abs(time.time() - int(timestamp)) > TOLERANCE:
abort(400)
except ValueError:
abort(400)
signed = f"{timestamp}.".encode() + raw
expected = "sha256=" + hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature): # constant time
abort(400)
event = json.loads(raw)
# Record, answer, fulfil in a worker.
if events.record(event["id"], event["type"], event):
queue.push("boonka.event", event)
return "", 200Webhooks
Event reference
Every event has the same envelope. data is a compact view of the payment session. For the full object, retrieve the session by data.id.
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier for this event. Deduplicate on it. |
type | string | The event name, listed below. |
environment | string | test or live. Always branch on this before touching real orders. |
created_at | string | When the event was generated, ISO 8601. |
data.id | string | The payment session UUID. |
data.status | string | The session status at the time of the event. |
data.amount | integer | Amount in minor units. |
data.currency | string | Always "DZD". |
data.reference | stringnullable | Your order identifier. |
data.metadata | objectnullable | The metadata you attached at creation. |
data.paid_at | stringnullable | Set on payment.succeeded; null otherwise. |
Events
| Type | Fires when | What to do |
|---|---|---|
payment.succeeded | The shopper paid. | Fulfil the order. This is the one that matters. |
payment.failed | The charge was attempted and declined. | Put the stock back, tell the shopper, offer another attempt. |
payment.cancelled | You or the shopper cancelled a pending session. | Put the stock back. Mark the order abandoned. |
webhook.test | You pressed Send test event. | Nothing. Use it to prove your verification works. |
Handling the environment
One endpoint receives both test and live events. If your staging and production systems share a webhook URL, branch on environment and never let a test event fulfil a real order. If they do not share a URL, still assert it. A mistaken key in production is much easier to spot when the handler rejects the event loudly.
async function handle(event) {
// Refuse to act on the wrong environment.
const expected = process.env.NODE_ENV === "production" ? "live" : "test";
if (event.environment !== expected) {
logger.warn({ event }, "boonka: environment mismatch, ignoring");
return;
}
const order = await orders.findBySessionId(event.data.id);
if (!order) return; // not ours; nothing to do
switch (event.type) {
case "payment.succeeded":
// Trust but check: the amount must match.
if (event.data.amount !== order.totalMinor) {
await orders.flagForReview(order.id, "amount mismatch");
return;
}
return orders.fulfilOnce(order.id, event.data.id);
case "payment.failed":
case "payment.cancelled":
return orders.restockOrder(order.id, event.type);
case "webhook.test":
return;
}
}{
"id": "5f1c0c2a-7b4e-4e1a-9b2d-7c1e4a8f0d33",
"type": "webhook.test",
"environment": "test",
"created_at": "2026-07-29T11:30:00+00:00",
"data": {
"message": "This is a test event from Boonka. If you received it, your endpoint is configured correctly."
}
}Webhooks
Retries and delivery
A delivery counts as successful when your endpoint answers with any 2xx inside 10 seconds. Anything else, whether a 4xx, a 5xx, a timeout or a TLS failure, is a failure and will be retried.
| Setting | Value |
|---|---|
| Timeout | 10 seconds per attempt |
| Attempts | 5 in total |
| Backoff | 1 min → 5 min → 15 min → 30 min → 60 min |
| Success | Any 2xx |
| Guarantee | At least once. Duplicates are normal; order is not guaranteed. |
After the fifth failure the delivery is abandoned. The payment still happened; only the notification was lost. This is why a reconciliation sweep matters: for orders still awaiting payment after their session expired, read the session and reconcile the difference.
Reading the delivery log
Developers → Webhooks → Deliveries shows every attempt with its HTTP status, response time, and error text. When an integration goes quiet, look here first. It usually says exactly what your endpoint returned.
Filter it by status, event type and environment to narrow down a problem.
If deliveries are failing
- 401 or 403 in the log. Your route is behind authentication. Exclude it from login middleware and CSRF checks.
- 419 or a CSRF error. Laravel’s CSRF middleware. Add the path to the exclusion list.
- 400 that you returned yourself. Signature verification failed. Check you are hashing the raw body, and that the secret matches the current one.
- Timeouts. You are doing the work before responding. Acknowledge first.
- Nothing in the log at all. No webhook is configured, or yours is paused. Check it is saved and active in the dashboard.
// Runs every 15 minutes. Catches anything the
// webhook missed: a dropped delivery, an outage.
async function reconcile() {
const stale = await orders.find({
status: "awaiting_payment",
createdBefore: minutesAgo(35), // past the 30-min window
limit: 200,
});
for (const order of stale) {
const session = await boonka.retrieve(order.boonka_session_id);
if (!session) continue;
if (session.status === "succeeded") {
// The webhook never landed. Fulfil now.
await orders.fulfilOnce(order.id, session.id);
metrics.increment("boonka.recovered_by_sweep");
} else if (["expired", "cancelled", "failed"].includes(session.status)) {
await orders.restockOrder(order.id, session.status);
}
}
}Operate
Errors
Every response carries a success boolean and a human-readable message. Failures may add errors (a field-by-field map on validation failures) and code (a stable machine-readable string on some endpoints).
Branch on the HTTP status and, where present, code. Never branch on message, because the wording can change.
| Status | Meaning | What to do |
|---|---|---|
401 | Missing, malformed or revoked API credentials. | Check both headers and that the key is still active. |
403 | The action is not allowed for this actor. | Do not retry. |
404 | Not found, not yours, or in the other environment. | Check the id and that the key matches the environment. |
409 | State conflict: an idempotency clash, or a session that cannot be cancelled. | Read the body. Usually a real outcome, not a transient error. |
422 | Validation failed. | Read errors and fix the payload. |
5xx | Something failed on BOONKA’s side. | Retry with exponential backoff and the same idempotency key. |
Retrying safely
Retry 5xx responses, connection failures and timeouts. Do not retry 4xx: those describe your request, and sending it again produces the same answer.
A timeout is the dangerous case: you do not know whether the session was created. Retry it with the same idempotency key and you will either create it or receive the one that already exists.
// 422: validation, with a field map
{
"success": false,
"message": "The success url field must be a valid URL.",
"errors": {
"success_url": ["The success url field must be a valid URL."]
}
}
// 401: credentials
{
"success": false,
"message": "Missing API Key."
}
// 409: idempotency conflict
{
"success": false,
"message": "Idempotency key conflict. This key was already used for a different request."
}async function callBoonka(path, init, idempotencyKey) {
let delay = 500;
for (let attempt = 1; attempt <= 4; attempt++) {
try {
const res = await fetch(`${boonka.base}${path}`, {
...init,
headers: { ...boonka.headers(), "Idempotency-Key": idempotencyKey },
signal: AbortSignal.timeout(15_000),
});
// 4xx describes the request. Don't repeat it.
if (res.status < 500) return res;
} catch (err) {
// Timeout or network failure: safe to retry,
// because the key makes creation idempotent.
if (attempt === 4) throw err;
}
await sleep(delay + Math.random() * 250); // jitter
delay *= 2;
}
throw new Error("boonka: exhausted retries");
}Operate
Testing
Test mode is a full parallel environment. Sessions behave identically, webhooks are signed identically, and no real money moves. Build everything against it before you request live keys.
What you need
- A
pk_test_/sk_test_key pair. - A second BOONKA account to act as the shopper. A business cannot pay their own session, so you cannot test end to end with one account.
- A publicly reachable HTTPS URL for webhooks. A tunnelling tool works;
localhostand private IPs are rejected.
A test plan worth running
| Scenario | How | Expect |
|---|---|---|
| Happy path | Pay with a funded test shopper. | payment.succeeded, order fulfilled once. |
| Not enough credit | Shopper with less BOONKA credit than the amount. | Nothing charged, session stays pending. |
| Expiry | Create a session, wait 30 minutes, then read it. | expired. No webhook arrives. |
| Business cancel | Cancel a pending session. | 200, then payment.cancelled. |
| Cancel a paid session | Pay, then cancel. | 409 with "status": "succeeded". |
| Duplicate webhook | Replay a delivery from your own logs. | Handled once. Second is a no-op. |
| Bad signature | POST to your endpoint with a wrong signature. | Your endpoint returns 400, nothing is fulfilled. |
| Idempotent create | Send the same create twice with one key. | One session, not two. |
| Wrong environment | Read a test session with a live key. | 404. |
Get a green delivery here before you take your first test payment. If this fails, every payment webhook will fail the same way.
# Your endpoint must answer 400, not 200.
curl -i -X POST https://shop.dz/webhooks/boonka \
-H "Content-Type: application/json" \
-H "X-Gateway-Timestamp: $(date +%s)" \
-H "X-Gateway-Signature: sha256=deadbeef" \
-d '{"id":"forged","type":"payment.succeeded",
"environment":"live","created_at":"2026-07-29T11:45:00+00:00",
"data":{"id":"forged","status":"succeeded",
"amount":450000,"currency":"DZD"}}'
# → HTTP/1.1 400 Bad Request ✓ correct
# → HTTP/1.1 200 OK ✗ you are giving away goodsOperate
Go-live checklist
Work through this before you switch to pk_live_. Most incidents on a new integration come from one of these lines being skipped.
Security
- The secret key lives in the server environment, never in client code, a repository, or a log line.
- Webhook signatures are verified on every request, against the raw body, with a constant-time comparison.
- The replay window is enforced: deliveries more than five minutes old are rejected.
- You have deliberately tested that a forged request is refused.
- The webhook route is excluded from authentication and CSRF, and from nothing else.
Correctness
- Amounts are integers in minor units everywhere, converted once at the boundary.
- Fulfilment is idempotent and keyed on the session id.
- Orders are fulfilled on
payment.succeeded, never on the redirect alone. - The webhook handler checks
environmentbefore touching real orders. - The handler compares
data.amountagainst your own order total before fulfilling. - Every create sends a stable
Idempotency-Keyderived from the order, generated outside the retry loop. - You store
data.idagainst the order at creation time, before redirecting.
Resilience
- The webhook endpoint acknowledges within 10 seconds and does the work asynchronously.
- Duplicate events are absorbed by a unique constraint on the event
id. - A reconciliation sweep catches payments whose webhook never arrived.
- Retries are exponential with jitter, and only on
5xx, timeouts and connection failures. - You alert when the sweep recovers payments, and when webhook deliveries start failing.
Operations
- Your live webhook URL is configured and a test event has been delivered to it successfully.
- Your team knows that payouts are listed in the dashboard, not in the session object, and that the 2% fee comes out of what you receive.
- Someone knows how to issue a refund from the dashboard, and has done it once.
- You have a runbook for "the shopper says they paid but the order says unpaid": retrieve the session, check the delivery log.