Payments
An app can hold money on the Banga ledger: what users pay it and what it pays users. Three rules hold whatever the app does: an app never sees a user's wallets or balances; an app never moves a user's money without that user confirming on their own device; an app can only pay out what its own wallet holds.
The app wallet
On the app's Payments tab, Turn payments on creates one wallet per supported currency (USDBG), owned by the app rather than by you. It has no deposit address and cannot withdraw or convert; its balance only moves on the ledger:
- Move to my wallet sends funds from the app wallet to your own wallet of the same currency, from where you withdraw or convert as usual.
- Fund from my wallet sends funds the other way, so the app can pay users before its first sale.
Both are ordinary internal transfers and appear in both wallets' histories. A site administrator can freeze the app's wallet at any time; while frozen nothing moves in or out, including to you. Turning payments off keeps the wallet and its balance.
The platform fee
Banga keeps 3.0 % of every payment a user makes to your app, taken from your side: the user pays exactly the amount shown on their screen, and your wallet receives that amount less the fee, rounded up to the currency's smallest unit. A refund returns the full amount the user paid; the fee is not returned. The rate in force is always the one on this page and on the Limits page.
Paying users out
Paying users (refunds and rewards) is a separate right that a site administrator grants after reviewing what your app pays for. Ask on the Payments tab with a short note; the tab shows the answer. Payouts come only from the app wallet, never go negative, and are capped per payout and per day (see Limits).
Asking a user to pay
Three ways, all ending in a native confirmation the app cannot draw or skip, and a signed event to your webhook.
Each needs the payments scope in the manifest, granted by the user at install (you, as the owner, and
your testers can pay a test app without installing it):
// a webhook reply, for example after a form
{ "type": "payment",
"payment": { "amount": "5.00", "currency": "USDBG", "reference": "evt-42/seat-17",
"description": "Ticket · Rooftop meetup", "expiresIn": 600 } }
// a card action, one tap for every member who sees the card
{ "type": "pay", "label": "Buy ticket", "amount": "5.00", "currency": "USDBG",
"reference": "evt-42/seat-17", "description": "Ticket · Rooftop meetup" }
// from a web app
const result = await banga.requestPayment({ amount: "5.00", currency: "USDBG",
reference: "evt-42/seat-17", description: "Ticket · Rooftop meetup" });
Your webhook then receives payment.completed, payment.declined or
payment.expired, signed exactly like an invocation and retried until you answer with a 2xx:
{ "type": "payment.completed", "id": "...", "ts": 1758542671, "app": { "id": "..." },
"payment": { "id": "...", "kind": "charge", "status": "completed", "amount": "5.00", "currency": "USDBG",
"reference": "evt-42/seat-17", "description": "Ticket · Rooftop meetup" },
"user": { "id": "u_p9Kx..." } }
Amounts are strings. A reference is unique per user: asking again with a reference that is still pending returns
the same request, one that was declined or expired is re-priced, and one that was paid is refused, so a retry can
never charge twice. A request waits 10 minutes
unless expiresIn (seconds, at most 3600)
says otherwise. The user is never charged more than one request's amount per confirmation, and never more than
the app's per-charge and per-day limits (Limits).
What you learn: the payment's id, your reference, the amount, the outcome and when. What you never learn: the user's wallet, balance, real id, or why a request was declined (an empty wallet and a "no" look the same).
Delivery: the first attempt of payment.completed happens while the user is still on the sheet, and
your reply (a private card, an ack or an error) is shown to them. If you do
not answer with a 2xx, the event is retried on a schedule and shown as undelivered on the Payments tab
after the last attempt, where you can redeliver it by hand.
Example: selling a ticket with a scannable receipt
The whole flow of an event-tickets app, from the organiser's card to scanning the ticket at the door. Each JSON example on this page is checked against the platform's rules, so it works as written.
1. The manifest
The app asks for payments (to ask users to pay), post.cards (so the organiser can post the
event card) and profile.name. /event collects the event's details;
/ticket is the form behind the card's Buy with a referral button.
{
"contract": 1,
"name": "Meetup Tickets",
"description": "Sell tickets to a meetup from any room.",
"scopes": ["profile.name", "post.cards", "payments"],
"allowedLinkHosts": [],
"allowedImageHosts": [],
"webhook": { "url": "https://tickets.example.com/banga/hook" },
"commands": [
{ "id": "event.create", "command": "/event", "title": "Create an event",
"description": "Post an event with a Buy ticket button", "flow": "event" },
{ "id": "ticket.buy", "command": "/ticket", "title": "Buy a ticket",
"description": "Buy a ticket by event code", "flow": "buy" }
],
"flows": {
"event": { "mode": "submit", "views": [ { "id": "event", "title": "New event", "submitLabel": "Post the event", "blocks": [
{ "type": "input", "id": "title", "label": "Title", "input": "text", "required": true, "maxLength": 60 },
{ "type": "input", "id": "when", "label": "When", "input": "text", "required": true, "maxLength": 40 },
{ "type": "input", "id": "price", "label": "Ticket price, USDBG", "input": "number", "required": true, "min": 0.01, "max": 100 },
{ "type": "input", "id": "seats", "label": "Seats", "input": "number", "required": true, "min": 1, "max": 500 }
] } ] },
"buy": { "mode": "submit", "views": [ { "id": "buy", "title": "Buy a ticket", "submitLabel": "Continue to payment", "blocks": [
{ "type": "input", "id": "event", "label": "Event code", "input": "text", "required": true, "maxLength": 8 },
{ "type": "input", "id": "referral", "label": "Referral code", "input": "text", "maxLength": 8 }
] } ] }
},
"cards": {}
}
2. The event card
Your webhook answers /event with a card for the room. Its pay button asks whoever taps it
for the price; the reference names the event, and the platform makes it unique per buyer, so everyone in the room
can buy from the same card. The button is drawn with the amount next to your label ("Buy ticket · 5 USDBG").
{
"blocks": [
{ "type": "title", "text": "Rooftop meetup" },
{ "type": "facts", "items": [
{ "k": "When", "v": "Fri 3 Oct, 19:00" },
{ "k": "Price", "v": "5.00 USDBG" },
{ "k": "Seats", "v": "40" },
{ "k": "Event code", "v": "ABCD" }
] },
{ "type": "text", "text": "One ticket per person. Returns are refunded in full." }
],
"actions": [
{ "type": "pay", "label": "Buy ticket", "style": "primary", "amount": "5.00", "currency": "USDBG",
"reference": "evt-ABCD", "description": "Ticket - Rooftop meetup" },
{ "type": "open_flow", "label": "Buy with a referral", "flowId": "buy", "payload": { "event": "ABCD" } },
{ "type": "submit", "label": "Cancel event", "style": "danger", "actionId": "cancel", "payload": { "event": "ABCD" } }
]
}
A member taps Buy ticket and sees the native payment sheet: your app's name, the amount, your description, their balance before and after. Nothing moves until they confirm on their device.
3. The payment event
When they confirm, the platform moves the money into your app wallet and calls your webhook, signed like any call (see Verifying a call):
{ "type": "payment.completed", "id": "3K2EnDIRrXyKui6XrmHZvcA1krc", "ts": 1759186227,
"app": { "id": "3K29MJ4xNixxHdMlg7URCBGhLIA" },
"payment": { "id": "3K2EmuFPmwyUdZQH9sDr2NfbWyg", "kind": "charge", "status": "completed",
"amount": "5", "currency": "USDBG", "reference": "evt-ABCD",
"description": "Ticket - Rooftop meetup", "createdAt": 1759186224, "completedAt": 1759186227 },
"user": { "id": "u_p9KxQ2mZ..." } }
Record the ticket against payment.id (you need it for a refund) and user.id. The same
event can arrive again with the same X-Banga-Delivery id if your first answer was lost: answer it
again the same way without issuing a second ticket.
4. The receipt, with the QR code
Answer the event with a private card. The buyer sees it at once if you answer within the time limit; if you answer only a retried delivery, they are told it arrived. Either way it stays with the payment, and they can open it again from the transaction in their wallet's history.
{
"type": "card",
"visibility": "private",
"card": {
"blocks": [
{ "type": "title", "text": "Your ticket - Rooftop meetup" },
{ "type": "facts", "items": [
{ "k": "When", "v": "Fri 3 Oct, 19:00" },
{ "k": "Seat", "v": "17" },
{ "k": "Price", "v": "5.00 USDBG" }
] },
{ "type": "qr", "value": "TICKET:ABCD:3K2EmuFPmwyUdZQH9sDr2NfbWyg:6f3c9a1e", "caption": "Show this at the door" },
{ "type": "text", "text": "Keep this card: it is your ticket." }
],
"actions": [
{ "type": "submit", "label": "Return ticket", "style": "danger", "actionId": "return", "payload": { "event": "ABCD" } }
]
}
}
The QR code's value here is the event code, the platform's payment id and the first characters of an HMAC of both
under a secret only your backend knows: nobody can make a valid one without your secret, and the payment id tells
you which ticket it is. The receipt's Return ticket button reaches your webhook as a
submit action, from the room the ticket was bought in; refund with the payment id there
(below).
5. The backend
The part of the webhook that handles the event, in Java (the signature check comes first, as for every call):
Map<String, Object> onPaymentEvent(JsonNode event) {
if (!"payment.completed".equals(event.path("type").asText())) {
return null; // declined or expired: answer 204, nothing to show
}
String paymentId = event.path("payment").path("id").asText();
String userId = event.path("user").path("id").asText();
String eventCode = event.path("payment").path("reference").asText().substring("evt-".length());
Ticket ticket = tickets.computeIfAbsent(paymentId, id -> new Ticket(eventCode, id, userId)); // a retry finds it
String code = "TICKET:" + eventCode + ":" + paymentId + ":" + hmac(ticketSecret, eventCode + ":" + paymentId).substring(0, 8);
return Map.of("type", "card", "visibility", "private", "card", Map.of(
"blocks", List.of(
Map.of("type", "title", "text", "Your ticket - " + ticket.eventTitle()),
Map.of("type", "qr", "value", code, "caption", "Show this at the door")),
"actions", List.of(Map.of("type", "submit", "label", "Return ticket", "style", "danger",
"actionId", "return", "payload", Map.of("event", eventCode)))));
}
6. At the door
Scanning is yours: a door app or a web page of your own reads the QR code, and your backend checks it.
- Split the value; recompute the HMAC of the event code and payment id and compare it with the last part.
- Look the ticket up by payment id; refuse it if it was returned (refunded) or already used, then mark it used.
- If you want to be sure the money is still there,
GET /app-api/v1/payments/{paymentId}answers the charge with itsstatusandrefundedAmount.
Returns and a cancelled event are refunds with the payment id (Paying out from your backend); a ticket that is refunded should stop scanning.
Paying out from your backend
A payments key (bpk_..., issued on the Payments tab, shown once, separate from the
room-bot token so a leaked bot token cannot move money) authenticates your backend's calls to
/app-api/v1. Rotating it stops the old key at once. Amounts are strings; every call needs an
idempotencyKey (up to 120 characters) that makes a retry safe: the same key with the same request
returns the same result with 200, a different request with a used key is refused with 409.
POST https://dev.banga.chat/app-api/v1/payouts
Authorization: Bearer bpk_...
Content-Type: application/json
{ "userId": "u_p9Kx...", "amount": "0.50", "currency": "USDBG", "reference": "ref-42/maya",
"note": "Referral reward", "idempotencyKey": "ref-42/maya" }
-> 201 { "id": "...", "kind": "payout", "status": "completed", "amount": "0.5", "currency": "USDBG",
"reference": "ref-42/maya", "description": "Referral reward", "userId": "u_p9Kx...", ... }
A payout needs the site administrator's grant (ask on the Payments tab), a recipient who still has the app
installed (userId is the per-app id you received in a webhook or event), an amount within the
per-payout cap, and enough in the app wallet. The recipient sees your app's name and the note on their phone and
in their wallet history.
POST /app-api/v1/payments/{paymentId}/refunds
{ "amount": "3.00", "reason": "Seat downgraded", "idempotencyKey": "refund/evt-42/seat-17/1" }
-> 201 { "id": "...", "kind": "refund", "parentPaymentId": "{paymentId}", "amount": "3", ... }
A refund returns part or all (leave amount out) of a completed charge, needs no grant, reaches the
user even after they removed the app, and never exceeds what is left of the charge. Refunds and payouts together
count toward the app's daily cap. The platform fee of the original charge is not returned.
GET /app-api/v1/payments/{id} -> the charge, refund or payout; a charge carries refundedAmount
GET /app-api/v1/payments?reference=evt-42/seat-17 -> the app's payments with that reference, newest first (up to 50)
GET /app-api/v1/wallet -> { "balances": [{ "currency": "USDBG", "available": "1240.5" }],
"limits": { ... }, "payoutsGranted": true, "frozen": false }
Errors
| Status | Message starts with | Meaning |
|---|---|---|
401 | Unknown or revoked key | |
403 | payouts_not_granted, frozen, app_not_active | The app may not send money right now |
409 | idempotency_conflict | The key was used with a different request |
422 | insufficient_balance, limit_exceeded, user_unknown, user_removed,
exceeds_charge, nothing_to_refund, not_refundable, currency_not_supported | A rule of the payout or refund |
429 | rate_limited | More than 60 calls a minute |
400 | A malformed request or an unknown payment |