Quick start
- 1
Sign up for a BananaCrystal account at agents.bananacrystal.com.
- 2
Create a key. Go to Account, Integrations, Partner API. Pick the scopes you need and copy the
sk_live_key. It's shown once. - 3
Register a webhook with
POST /webhook-endpointsand store thewhsec_secret it returns. - 4
Start small. Keys move real money, so try your integration with small amounts first.
Example use case: a creator monetization platform
A platform like OnlyFans, Fanvault or Yapp.ink, where creators sell memberships, exclusive content and products, and fans can tip them. Fans pay in their own currency, and creators cash out in theirs. This example follows a fan's money from top-up to the creator's bank account. Each step shows the API call your server makes and the webhook you get back.
Fan, Kenya
KES 2,000
by mobile money or bank transfer
depositFan's wallet
about $15
held in USD
transferCreator's wallet
$40
from memberships, posts and tips
withdrawCreator, Nigeria
in NGN
by bank transfer
Your platform creates and manages every wallet through the API. Your users don't need a BananaCrystal account.
- 1
Fans and creators sign up on your platform
When an account is created, your server makes a wallet for it and saves the wallet id next to the user. Your app shows a short line such as "Wallet powered by BananaCrystal", so users know who holds their money.
POST/walletswithreference, the user's name andholder_email, returnswal_… - 2
A fan tops up
They click Add funds. Your server opens a deposit session and your page shows the BananaCrystal widget. They pick Kenya, choose mobile money or bank transfer, enter the amount and pay. In other countries the widget also offers cash or USDC.
POST/sessionswithtype: "deposit", returnsidandclient_secretPAGEBananaCrystal.open({ session, clientSecret })WEBHOOKdeposit.settledandsession.completed: about $15 is in the fan's wallet - 3
The fan pays a creator
The fan unlocks a paid post for $3 and, during a live stream, taps Tip $5. Each one is a transfer from the fan's wallet to the creator's. A monthly membership works the same way: your server makes one transfer each month. Transfers between your own wallets have no platform fee.
POST/transfersfrom the fan's wallet to the creator'sWEBHOOKtransfer.completed - 4
The creator cashes out to a bank account in Nigeria
Memberships, paid posts and tips from many fans add up to $40. The creator clicks Withdraw. The first time, the widget asks for an ID check. Then they enter their bank details and get the money in naira by bank transfer.
POST/sessionswithtype: "withdraw"PAGEThe widget runs the ID check (first time only) and the payoutWEBHOOKwallet.kyc_updated, thensession.completed - 5
Optional: the creator also has a BananaCrystal account
If they sign in to BananaCrystal with the same email, they can connect this wallet to their account. Its balance then shows up next to their own in BananaCrystal, and they can spend it there too.
WEBHOOKwallet.linked. Read balances from the API from then on.
What you build
- Your sign-up, plus one call to make a wallet
- A "Powered by BananaCrystal" line where the wallet shows
- Add funds and Withdraw buttons
- Subscribe, Unlock and Tip buttons, each one transfer
- Two small server routes that open a session
- A webhook handler that updates your database
What BananaCrystal handles
- Bank transfer, mobile money, cash and USDC in 140+ currencies
- Currency exchange in and out of USD
- ID checks before money leaves
- Payout status, sent to you as webhooks
Other things you can build
/payments/subscriptions/refundsHow it fits together
One user's wallet from sign-up to withdrawal: who calls what, and which webhooks you get.
1. Sign up
- Your user1.Signs up on your platform
- Your server2.Calls
POST /walletswithreference, the user's name andholder_email - BananaCrystal3.Returns the wallet id (
wal_…). Store it with your user.
2. Add funds
- Your server1.Calls
POST /sessionswithtype: deposit - Your user2.Your page opens the widget (bcpay.js)
- Your user3.Pays by bank transfer, mobile money, cash or USDC
- BananaCrystal4.Sends
session.completedanddeposit.settled
3. Withdraw
- Your server1.Calls
POST /sessionswithtype: withdraw - Your user2.Opens the widget
- BananaCrystal3.First time only: the ID check,
kyc_statusnone to approved - BananaCrystal4.Pays out by bank transfer, mobile money or cash pickup
- BananaCrystal5.Sends
session.completed
4. Optional: connect to BananaCrystal
- Your user1.Signs in to BananaCrystal with the same email
- Your user2.Sees "Connect your <Partner> wallet?" and taps Connect
- BananaCrystal3.Sends
wallet.linked. No money moves.
Authentication
Send your secret key as a bearer token on every request. Keep it on your server. Never put it in a browser or mobile app.
Authorization: Bearer sk_live_...Live · sk_live_ keys
https://agentic.bananacrystal.com/api/v1/partnerEach key carries scopes; a call outside them returns 403. You can also limit a key to a list of IP addresses.
Idempotency
Send an Idempotency-Key header when you create a wallet, payment, refund, transfer, payout or subscription. If a request times out and you retry it, you won't create a second one. Use any unique string, such as a UUID or your own order id.
- Same key, same body: you get the first result back.
- Same key, different body: you get
409and nothing is created.
Errors
Errors use standard HTTP status codes, and every error has the same body. Log message: it tells you what to fix.
{
"success": false,
"statusCode": 400,
"message": "amount must be a positive decimal string with at most 6 decimal places, e.g. \"5.00\""
}400the request is invalid401missing or unknown key403the key lacks the scope, the IP isn't allowed, a code is wrong, or the wallet isn't verified for payouts404not found, or not yours409idempotency conflict, or the payment isn't open410the payment code expired502a provider we depend on failed (identity checks, a first subscription charge)
Deposits & withdrawals
Let a wallet holder add money or cash out by bank transfer, mobile money, cash or USDC, without building the forms yourself. One API call opens BananaCrystal's widget in a popup on your site (bcpay.js), or as a full page. Your users don't need a BananaCrystal account.
Example: an Add funds button
Your server creates a session, your page opens the widget in a popup, and the webhook tells you when the money lands. A withdrawal works the same way with type: "withdraw".
// server.js: a holder clicks "Add funds" on their wallet page.
app.post("/wallet/add-funds", async (req, res) => {
const r = await fetch("https://agentic.bananacrystal.com/api/v1/partner/sessions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BC_SECRET_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `topup-${req.body.requestId}`,
},
body: JSON.stringify({
type: "deposit", // or "withdraw" for a user you've signed in
wallet: req.user.walletId,
amount: "25.00", // optional: leave it out and the holder picks
reference: `topup_${req.user.id}`,
description: "Add funds",
}),
});
const session = await r.json();
// Only these two go to the browser. Never your secret key.
res.json({ session: session.id, clientSecret: session.client_secret });
});
// The webhook is the source of truth, even if the holder closes the popup.
// verifyWebhook is in "Receiving webhooks" below.
app.post(
"/webhooks/bananacrystal",
express.raw({ type: "application/json" }),
(req, res) => {
const ok = verifyWebhook(
req.body.toString("utf8"),
req.get("BananaCrystal-Signature") || "",
process.env.BC_WEBHOOK_SECRET,
);
if (!ok) return res.status(400).end();
const event = JSON.parse(req.body);
if (event.type === "session.completed") {
markTopUpDone(event.data.reference, event.data.amount_settled);
}
res.sendStatus(200);
},
);<!-- wallet.html -->
<script src="https://agents.bananacrystal.com/bcpay.js"></script>
<button id="add-funds">Add funds</button>
<script>
document.getElementById("add-funds").onclick = async () => {
const r = await fetch("/wallet/add-funds", { method: "POST" });
const { session, clientSecret } = await r.json();
BananaCrystal.open({
session,
clientSecret,
onEvent: (e) => {
// bc:completed closes the popup. Refresh from your server,
// which checks the session or waits for the webhook.
if (e.type === "bc:completed") refreshWallet();
},
});
};
</script>- 1.Browser → Your serverThe user clicks Add funds
- 2.Your server → BananaCrystal APIsecret key
POST /sessionswith your secret key - 3.BananaCrystal API → Your server
idandclient_secret - 4.Your server → BrowserOnly
idandclient_secret - 5.Browser → Widget
BananaCrystal.open({ session, clientSecret }) - 6.WidgetThe user picks a method and finishes
- 7.Widget → Browser
bc:completedto youronEvent - 8.Your server → BananaCrystal APIsecret key
GET /sessions/:id, or wait for thesession.completedwebhook. This is the source of truth.
Your secret key stays on your server. The browser only ever gets one session's id and client_secret.
/sessionsOpen the widget for one deposit into a wallet, or one withdrawal out of it.
Body (JSON)
typestringrequired"deposit": the holder adds money by bank transfer, mobile money, cash or USDC. Needs payments:write. "withdraw": the holder takes money out by bank transfer, mobile money or cash pickup. Needs payouts:write.
walletstringrequiredThe wallet that gets the money, or pays it out.
amountstringDecimal string, up to 6 decimal places, e.g. "25.00". A deposit starts at this amount. A withdrawal can't go over it. Leave it out and the holder enters the amount in the widget, up to the wallet balance for a withdrawal.
currencystring"USD", the default.
verificationstringWithdraw only. "partner", the default: you already verified your signed-in user, so the holder confirms with a button and gets no code from us. "email": we email the holder a code to confirm.
customer_emailstringThe holder's email. Required when verification is "email": the code goes there.
return_urlstringFull-page flow only: where the holder goes when they finish.
cancel_urlstringFull-page flow only: where the holder goes if they cancel or the link expires. Defaults to return_url.
referencestringYour own id, up to 120 characters.
descriptionstringShown to the holder in the widget.
metadataobjectUp to 20 string keys and values, returned as you sent them.
expires_inintegerSeconds until the session expires, from 900 (15 minutes) to 604800 (7 days). Defaults to 86400 (24 hours). A withdrawal with verification "partner" defaults to 1800 (30 minutes) and can't go past 86400 (24 hours).
- Hand id and client_secret to your page and call BananaCrystal.open (see the example above). Or send the holder to url for the full-page flow.
- client_secret opens this one session. Pass it to the browser that needs it and keep it out of your logs.
- Deposit: completes when the first deposit after the session opened settles in the wallet. The amount that landed is in amount_settled.
- Withdraw: the holder passes an ID check first if the wallet's kyc_status isn't approved. One withdrawal per session. If it fails, the session reopens with last_error and the holder can try again.
- Withdraw with verification "partner": anyone holding the link can send the money, with no code to stop them. So create the session only for a user you've signed in, right when they ask, and keep the expiry short.
{
"id": "ses_5b7d9f1a3c5e7b9d2f4a6c8e",
"object": "session",
"type": "deposit",
"client_secret": "cs_8f2a…",
"url": "https://agents.bananacrystal.com/connect/ses_5b7d9f1a3c5e7b9d2f4a6c8e#cs=cs_8f2a…",
"status": "open",
"wallet": "wal_3f9c1a7e2b4d6f8a0c1e3b5d",
"amount": "25.000000",
"currency": "USD",
"verification": "partner",
"customer_email": "member@example.com",
"reference": "topup_4821",
"description": "Add funds",
"metadata": { "member_id": "4821" },
"return_url": "https://example.com/wallet",
"cancel_url": "https://example.com/wallet",
"order": null,
"amount_settled": null,
"last_error": null,
"completed_at": null,
"expires_at": "2026-10-26T09:12:44.000Z",
"created_at": "2026-10-25T09:12:44.000Z"
}/sessions/:idGet a session.
{
"id": "ses_5b7d9f1a3c5e7b9d2f4a6c8e",
"object": "session",
"type": "deposit",
"client_secret": "cs_8f2a…",
"url": "https://agents.bananacrystal.com/connect/ses_5b7d9f1a3c5e7b9d2f4a6c8e#cs=cs_8f2a…",
"status": "completed",
"wallet": "wal_3f9c1a7e2b4d6f8a0c1e3b5d",
"amount": "25.000000",
"currency": "USD",
"verification": "partner",
"customer_email": "member@example.com",
"reference": "topup_4821",
"description": "Add funds",
"metadata": { "member_id": "4821" },
"return_url": "https://example.com/wallet",
"cancel_url": "https://example.com/wallet",
"order": "3c1e5a7b-…",
"amount_settled": "24.650000",
"last_error": null,
"completed_at": "2026-10-25T09:31:02.000Z",
"expires_at": "2026-10-26T09:12:44.000Z",
"created_at": "2026-10-25T09:12:44.000Z"
}/sessionsList sessions, newest first.
Query
walletstringOnly this wallet.
typestringdeposit or withdraw.
statusstringopen, processing, completed, canceled or expired.
limitinteger1 to 100. Default 20.
{ "data": [ ... ] }/sessions/:id/cancelClose an open session so nobody can use it.
- 409 if the session isn't open.
- Status goes open, then completed, canceled or expired. A withdrawal is processing while the money moves.
- In the popup, bcpay.js calls your onEvent with bc:ready, bc:status, bc:completed, bc:canceled and bc:close, and closes itself on the last three.
- In the full-page flow, we send the holder to return_url with session_id and status added to the query. A cancel or an expiry goes to cancel_url the same way.
- Don't trust a browser event or the query string alone. Check the session with GET /sessions/:id, or wait for the session.completed webhook, before you show the money as there.
USDC deposits
Give a wallet its own USDC address, for holders who'd rather send USDC than use the widget. USDC sent there lands in the wallet as USDb, 1:1 less the deposit fee.
/wallets/:id/deposit-addressThe wallet's USDC deposit address on one network.
Query
networkstringrequired"polygon" or "stellar".
- Polygon returns chain_id and token.contract_address and decimals instead of memo.
- Stellar: the holder must include the memo, or the deposit can't be matched to the wallet.
- The address stays the same for the wallet, so you can show it on your site.
- 503 if the address couldn't be created. Try again shortly.
{
"wallet": "wal_3f9c1a7e2b4d6f8a0c1e3b5d",
"network": "stellar",
"address": "GBXQ…",
"memo": "4821907",
"memo_type": "id",
"token": { "code": "USDC", "issuer": "GA5Z…" },
"min_amount": "1",
"fee": { ... }
}/depositsDeposits that settled in your wallets, by any method, newest first.
Query
walletstringOnly this wallet.
limitinteger1 to 100. Default 20.
- Every deposit into a partner wallet shows up here once, whether it came through the widget, a deposit address or anything else. Each one also sends deposit.settled.
- method is bank, mobile_money, cash or usdc.
{
"data": [
{
"id": "3c1e5a7b-…",
"object": "deposit",
"wallet": "wal_3f9c1a7e2b4d6f8a0c1e3b5d",
"amount": "24.650000",
"currency": "USD",
"method": "usdc",
"fiat_currency": null,
"fiat_amount": null,
"settled_at": "2026-10-25T09:31:02.000Z"
}
]
}Payments
A payment moves money from a BananaCrystal user (the payer) into one of your wallets. The payer approves it with a code we email them.
POST /payments is for payers who already have a BananaCrystal account and pay from its balance. We email them the code. For everyone else, use a deposit session. See Deposits & withdrawals.
Example: a donation
A donor gives $5 to a campaign on your platform. The campaign's wallet gets the full $5.00. The donor pays $5.00 plus the transfer fee (currently 0.3%).
# 1. Start the donation. The donor gets a code by email.
curl https://agentic.bananacrystal.com/api/v1/partner/payments \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: donation-771-donor-42" \
-d '{
"wallet": "wal_3f9c1a7e2b4d6f8a0c1e3b5d",
"amount": "5.00",
"currency": "USD",
"payer": "donor@example.com",
"description": "Donation to Clean Water Fund"
}'
# -> { "id": "pay_9a7c...", "status": "otp_sent", ... }
# 2. The donor enters the code in your app. Confirm it.
curl https://agentic.bananacrystal.com/api/v1/partner/payments/pay_9a7c5e3b1d2f4a6c8e0b2d4f/confirm \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "code": "482913" }'
# -> { "id": "pay_9a7c...", "status": "settled", ... }Payment status goes otp_sent then settled, failed or expired. A wrong code leaves it otp_sent, so the donor can try again.
/paymentsStart a payment. We email the payer a 6-digit code.
Body (JSON)
walletstringrequiredThe wallet that receives the money.
amountstringrequiredDecimal string, up to 6 decimal places, e.g. "5.00". The wallet receives this full amount.
currencystringrequired"USD".
payerstringrequiredThe payer's BananaCrystal email or username.
descriptionstringShown to the payer in the email.
metadataobjectUp to 20 string keys and values, returned as you sent them.
- The payer pays the transfer fee (currently 0.3%) on top of the amount. The fee field has the exact figure. To see it before you charge, call POST /quotes.
- Returns 400 if the payer's balance can't cover the amount plus the fee.
{
"id": "pay_9a7c5e3b1d2f4a6c8e0b2d4f",
"object": "payment",
"wallet": "wal_3f9c1a7e2b4d6f8a0c1e3b5d",
"amount": "5.000000",
"currency": "USD",
"fee": "0.015000",
"payer": "donor@example.com",
"description": "Donation to Clean Water Fund",
"metadata": { "campaign_id": "771" },
"status": "otp_sent",
"transaction_id": null,
"failure_reason": null,
"code_expires_at": "2026-10-23T09:22:44.000Z",
"created_at": "2026-10-23T09:12:44.000Z",
"settled_at": null
}/payments/:id/confirmConfirm a payment with the payer's code.
Body (JSON)
codestringrequiredThe 6-digit code the payer received.
- If the code is wrong you get an error, and the payment stays otp_sent. The payer can try again until the code expires.
- An expired code returns 410. Create a new payment.
- Confirming a payment that already settled returns it again with 200. Any other status that isn't otp_sent returns 409.
{
"id": "pay_9a7c5e3b1d2f4a6c8e0b2d4f",
"object": "payment",
"wallet": "wal_3f9c1a7e2b4d6f8a0c1e3b5d",
"amount": "5.000000",
"currency": "USD",
"fee": "0.015000",
"payer": "donor@example.com",
"description": "Donation to Clean Water Fund",
"metadata": { "campaign_id": "771" },
"status": "settled",
"transaction_id": "0.0.4512@1761210790.123456789",
"failure_reason": null,
"code_expires_at": null,
"created_at": "2026-10-23T09:12:44.000Z",
"settled_at": "2026-10-23T09:13:10.000Z"
}/payments/:idGet a payment.
{
"id": "pay_9a7c5e3b1d2f4a6c8e0b2d4f",
"object": "payment",
"wallet": "wal_3f9c1a7e2b4d6f8a0c1e3b5d",
"amount": "5.000000",
"currency": "USD",
"fee": "0.015000",
"payer": "donor@example.com",
"description": "Donation to Clean Water Fund",
"metadata": { "campaign_id": "771" },
"status": "settled",
"transaction_id": "0.0.4512@1761210790.123456789",
"failure_reason": null,
"code_expires_at": null,
"created_at": "2026-10-23T09:12:44.000Z",
"settled_at": "2026-10-23T09:13:10.000Z"
}/paymentsList payments, newest first.
Query
walletstringOnly this wallet.
statusstringotp_sent, settled, failed or expired.
limitinteger1 to 100.
{ "data": [ ... ] }Subscriptions
Charge a payer the same amount every week or month. They approve once with an emailed code; later charges run on their own.
/subscriptionsStart a subscription. We email the payer a code that names the recurring charge.
Body (JSON)
walletstringrequiredThe wallet that receives each charge.
payerstringrequiredThe payer's BananaCrystal email or username.
amountstringrequiredEach charge, e.g. "9.99".
currencystringrequired"USD".
intervalstringrequired"week" or "month".
descriptionstringShown to the payer in the email.
metadataobjectUp to 20 string keys and values, returned as you sent them.
{
"id": "sub_4c6e8a0b2d4f6a8c0e2b4d6f",
"object": "subscription",
"wallet": "wal_3f9c1a7e2b4d6f8a0c1e3b5d",
"payer": "donor@example.com",
"amount": "9.990000",
"currency": "USD",
"interval": "month",
"description": "Monthly donation to Clean Water Fund",
"metadata": {},
"status": "pending",
"code_expires_at": "2026-10-23T09:22:44.000Z",
"next_charge_at": null,
"last_charged_at": null,
"failed_attempts": 0,
"canceled_at": null,
"created_at": "2026-10-23T09:12:44.000Z"
}/subscriptions/:id/confirmConfirm with the payer's code. This takes the first charge.
Body (JSON)
codestringrequiredThe 6-digit code the payer received.
- A wrong code returns 403 and the subscription stays pending.
- If the first charge fails you get 502 and the subscription is canceled.
{
"id": "sub_4c6e8a0b2d4f6a8c0e2b4d6f",
"object": "subscription",
"wallet": "wal_3f9c1a7e2b4d6f8a0c1e3b5d",
"payer": "donor@example.com",
"amount": "9.990000",
"currency": "USD",
"interval": "month",
"description": "Monthly donation to Clean Water Fund",
"metadata": {},
"status": "active",
"code_expires_at": null,
"next_charge_at": "2026-11-23T09:13:10.000Z",
"last_charged_at": "2026-10-23T09:13:10.000Z",
"failed_attempts": 0,
"canceled_at": null,
"created_at": "2026-10-23T09:12:44.000Z"
}/subscriptions/:id/cancelCancel a subscription. No more charges run.
- Returns the subscription with status "canceled" and next_charge_at null.
/subscriptions/:idGet a subscription.
/subscriptionsList subscriptions, newest first.
Query
walletstringOnly this wallet.
statusstringpending, active, past_due or canceled.
{ "data": [ ... ] }- After the first charge, later charges run on their own. The payer doesn't get a new code.
- Each charge is a normal payment with a subscription field, and sends payment.settled.
- If a charge fails, the subscription goes past_due and we try again 24 hours later. After 3 failed tries, we cancel it.
Transfers
Move money between two of your own wallets. No fee.
/transfersMove money from one wallet to another.
Body (JSON)
from_walletstringrequiredWhere the money comes from.
to_walletstringrequiredWhere it goes. Must be a different wallet.
amountstringrequiredDecimal string, up to 6 decimal places, e.g. "20.00".
descriptionstringFor your records.
metadataobjectUp to 20 string keys and values, returned as you sent them.
{
"id": "tr_2b4d6f8a0c2e4a6c8e0b2d4f",
"object": "transfer",
"from_wallet": "wal_3f9c1a7e2b4d6f8a0c1e3b5d",
"to_wallet": "wal_8d0f2b4a6c8e0a2c4e6b8d0f",
"amount": "20.000000",
"currency": "USD",
"status": "completed",
"transaction_id": "0.0.4512@…",
"failure_reason": null,
"description": null,
"metadata": {},
"created_at": "2026-10-23T11:00:00.000Z",
"completed_at": "2026-10-23T11:00:03.000Z"
}/transfers/:idGet a transfer.
/transfersList transfers, newest first.
Query
walletstringOnly transfers from or to this wallet.
{ "data": [ ... ] }Payouts
Pay a wallet's balance out to its holder's own BananaCrystal account. From there they can withdraw by bank transfer or cash pickup. No fee.
A payout goes to a BananaCrystal account, never straight to a bank account. To pay someone without a BananaCrystal account by bank transfer, mobile money or cash pickup, use a withdraw session. See Deposits & withdrawals.
/payoutsPay out to the wallet holder.
Body (JSON)
walletstringrequiredThe wallet to pay out from.
currencystringrequired"USD".
destinationobjectrequired{ "type": "bananacrystal_account", "account": "<email or username>" }
amountstringrequiredDecimal string, up to 6 decimal places, e.g. "20.00".
descriptionstringFor your records.
metadataobjectUp to 20 string keys and values, returned as you sent them.
- The wallet's kyc_status must be approved, or you get 403. See POST /wallets/:id/kyc-link.
- 400 for any destination type other than bananacrystal_account. 404 if the account doesn't exist.
{
"id": "po_6a8c0e2b4d6f8a0c2e4b6d8f",
"object": "payout",
"from_wallet": "wal_3f9c1a7e2b4d6f8a0c1e3b5d",
"destination": {
"type": "bananacrystal_account",
"account": "organizer@example.com"
},
"amount": "20.000000",
"currency": "USD",
"status": "completed",
"transaction_id": "0.0.4512@…",
"failure_reason": null,
"description": "October donations",
"metadata": {},
"created_at": "2026-10-23T12:00:00.000Z",
"completed_at": "2026-10-23T12:00:04.000Z"
}/payouts/:idGet a payout.
/payoutsList payouts, newest first.
Query
walletstringOnly this wallet.
{ "data": [ ... ] }Refunds
Send money from a settled payment back to the payer's wallet, in full or in part.
/payments/:id/refundsRefund a settled payment.
Body (JSON)
amountstringDecimal string, e.g. "2.50". Leave it out to refund everything not yet refunded.
descriptionstringFor your records.
metadataobjectUp to 20 string keys and values, returned as you sent them.
- You can only refund a settled payment. Refunds can't add up to more than the payment (400).
- The money leaves the wallet the payment went into and goes back to the payer. status is pending, completed or failed.
{
"id": "re_7e9b1d3f5a7c9e1b3d5f7a9c",
"object": "refund",
"from_wallet": "wal_3f9c1a7e2b4d6f8a0c1e3b5d",
"payment": "pay_9a7c5e3b1d2f4a6c8e0b2d4f",
"payer": "donor@example.com",
"amount": "2.500000",
"currency": "USD",
"status": "completed",
"transaction_id": "0.0.4512@…",
"failure_reason": null,
"description": "Partial refund",
"metadata": {},
"created_at": "2026-10-23T11:00:00.000Z",
"completed_at": "2026-10-23T11:00:03.000Z"
}/refunds/:idGet a refund.
/refundsList refunds, newest first.
Query
paymentstringOnly this payment.
walletstringOnly this wallet.
limitinteger1 to 100.
{ "data": [ ... ] }Wallets
A wallet holds a balance for one of your users, such as a fundraising campaign. Payments settle into it.
/walletsCreate a wallet.
Body (JSON)
referencestringYour own id for this user, so you can find the wallet later.
holder_first_namestringrequiredYour user's first name, up to 100 characters.
holder_last_namestringrequiredYour user's last name, up to 100 characters.
holder_emailstringrequiredYour user's email. A BananaCrystal account with the same email can connect this wallet. Send only an email you've verified: whoever controls it in BananaCrystal can connect the wallet.
linkablebooleanfalse keeps the wallet out of BananaCrystal accounts. Defaults to true.
metadataobjectUp to 20 string keys and values, returned as you sent them.
- holder_first_name, holder_last_name and holder_email are required. If one is missing or empty, a name is over 100 characters, or the email isn't valid, you get 400. We store the email in lowercase.
- When you create the wallet, tell your user that BananaCrystal holds it, so the name isn't a surprise later in the widget or in an email from us. For example: "Your wallet is powered by BananaCrystal. BananaCrystal holds your balance and handles deposits, withdrawals and ID checks."
{
"id": "wal_3f9c1a7e2b4d6f8a0c1e3b5d",
"object": "wallet",
"reference": "campaign_8812",
"address": "0.0.5123456",
"metadata": { "category": "environment" },
"kyc_status": "none",
"holder_first_name": "Rina",
"holder_last_name": "Wijaya",
"holder_email": "rina@example.com",
"linkable": true,
"linked": false,
"linked_at": null,
"created_at": "2026-10-23T09:12:44.000Z"
}/wallets/:idChange a wallet's holder name, holder_email, linkable or reference.
Body (JSON)
holder_first_namestringYour user's first name, up to 100 characters. Can't be empty.
holder_last_namestringYour user's last name, up to 100 characters. Can't be empty.
holder_emailstringA new email disconnects the wallet if it's connected. Send only an email you've verified: whoever controls it in BananaCrystal can connect the wallet.
linkablebooleanfalse disconnects the wallet if it's connected.
referencestringYour own id for this user.
- Disconnecting this way sends wallet.unlinked with by: partner.
{
"id": "wal_3f9c1a7e2b4d6f8a0c1e3b5d",
"object": "wallet",
"reference": "campaign_8812",
"address": "0.0.5123456",
"metadata": { "category": "environment" },
"kyc_status": "none",
"holder_first_name": "Rina",
"holder_last_name": "Wijaya",
"holder_email": "rina@example.com",
"linkable": true,
"linked": false,
"linked_at": null,
"created_at": "2026-10-23T09:12:44.000Z"
}/walletsList wallets, newest first.
Query
limitinteger1 to 100. Default 20.
referencestringOnly wallets with this reference.
{ "data": [ {
"id": "wal_3f9c1a7e2b4d6f8a0c1e3b5d",
"object": "wallet",
"reference": "campaign_8812",
"address": "0.0.5123456",
"metadata": { "category": "environment" },
"kyc_status": "none",
"holder_first_name": "Rina",
"holder_last_name": "Wijaya",
"holder_email": "rina@example.com",
"linkable": true,
"linked": false,
"linked_at": null,
"created_at": "2026-10-23T09:12:44.000Z"
} ] }/wallets/:idGet a wallet with its balance.
- balances.USDb is null if the balance couldn't be read right now.
- kyc_status is none, pending, approved or rejected. Payouts need approved.
{
"id": "wal_3f9c1a7e2b4d6f8a0c1e3b5d",
"object": "wallet",
"reference": "campaign_8812",
"address": "0.0.5123456",
"metadata": { "category": "environment" },
"kyc_status": "none",
"holder_first_name": "Rina",
"holder_last_name": "Wijaya",
"holder_email": "rina@example.com",
"linkable": true,
"linked": false,
"linked_at": null,
"balances": { "USDb": "125.500000" },
"created_at": "2026-10-23T09:12:44.000Z"
}/wallets/:id/kyc-linkGet a link the wallet's holder opens to verify their identity.
- Send no body. The link works for 30 minutes. After that, ask for a new one.
- Open url in a new tab or redirect to it. It takes the holder to the ID check.
- If the holder is already approved, url is null and kyc_status is "approved".
- When the result comes in, we send wallet.kyc_updated.
- 502 if the verification provider is unavailable. Try again later.
{
"wallet": "wal_3f9c1a7e2b4d6f8a0c1e3b5d",
"kyc_status": "pending",
"url": "https://agentic.bananacrystal.com/api/v1/partner/verify/…",
"expires_at": "2026-10-23T09:42:44.000Z"
}/transactionsMoney in and out of a wallet, newest first.
Query
walletstringrequiredThe wallet.
limitinteger1 to 100.
- type is payment, transfer, refund or payout. direction is in or out.
{
"wallet": "wal_3f9c1a7e2b4d6f8a0c1e3b5d",
"data": [
{
"type": "payment",
"id": "pay_9a7c5e3b1d2f4a6c8e0b2d4f",
"direction": "in",
"amount": "5.000000",
"currency": "USD",
"counterparty": "donor@example.com",
"transaction_id": "0.0.4512@1761210790.123456789",
"metadata": {},
"at": "2026-10-23T09:13:10.000Z"
},
{
"type": "payout",
"id": "po_…",
"direction": "out",
"amount": "20.000000",
"currency": "USD",
"counterparty": "organizer@example.com",
"transaction_id": "0.0.4512@…",
"metadata": {},
"at": "2026-10-23T10:01:00.000Z"
}
]
}- Connected wallets: when a BananaCrystal user signs in with the wallet's holder_email, they can connect the wallet to their account. Its balance then shows up next to their own in BananaCrystal, and they can spend it there. The wallet keeps its id.
- Once a wallet is connected, its holder can spend from it in BananaCrystal too: send, withdraw or pay. You get payout.completed with metadata.initiated_by set to holder. Always read the balance from GET /wallets/:id instead of keeping your own copy.
- You get wallet.linked when the holder connects it and wallet.unlinked when it's disconnected, with by set to holder or partner. Set linkable to false if you don't want your wallets connected at all.
holder_email on the walletwallet.linkedThey spend in BananaCrystal
payout.completed with metadata.initiated_by: holderThey disconnect
wallet.unlinked. No money moves.Countries & methods
Which countries, currencies and payment methods work for deposits and withdrawals, with their limits. Use it to build a country picker or to check coverage. Wallets always hold USD (as USDb, 1:1), and the widget converts.
curl "https://agentic.bananacrystal.com/api/v1/partner/payment-methods?type=withdraw&country=KE" \
-H "Authorization: Bearer sk_live_..."/payment-methodsEvery country, currency and method for deposits and withdrawals, with limits.
Query
typestringdeposit or withdraw.
countrystringISO-2 code, e.g. "KE".
currencystringe.g. "KES".
- method is bank (bank transfer), mobile_money, p2p, cash or usdc.
- min and max are in limits_currency, or null when there's no limit. networks lists banks or mobile money networks when we have them.
- country is null when a method isn't tied to one country, like USDC. USDC rows carry network (polygon or stellar).
- in_widget says whether the widget offers it today. The rest you reach through the API.
{
"data": [
{
"type": "withdraw",
"country": "KE",
"currency": "KES",
"method": "mobile_money",
"min": null,
"max": "1935.21",
"limits_currency": "USD",
"networks": ["M-Pesa"],
"in_widget": true
}
],
"currencies": ["KES"],
"countries": ["KE"]
}What's available
A snapshot from 26 October 2026. Coverage and limits change, so read GET /payment-methods for the current list.
Deposit
Bank transfer
- AR ARS
- BR BRL
- BW BWP
- CG XAF
- CI XOF
- CM XAF
- CO COP
- GA XAF
- KE KES
- MW MWK
- MX MXN
- RW RWF
- SN XOF
- TZ TZS
- UG UGX
- US USD
- ZA ZAR
- ZM ZMW
Mobile money
- BF XOF
- BJ XOF
- BW BWP
- CI XOF
- CM XAF
- GA XAF
- KE KES
- MW MWK
- RW RWF
- SN XOF
- TG XOF
- TZ TZS
- UG UGX
- ZM ZMW
P2P
- NG NGN
Cash
- USD
Paid in cash at an agent location.
USDC
- Polygon
- Stellar
Withdraw in the widget
Bank transfer
- AR ARS
- BW BWP
- CO COP
- KE KES
- MX MXN
- NG NGN
- ZA ZAR
Mobile money
- BW BWP
- CI XOF
- CM XAF
- KE KES
- MW MWK
- RW RWF
- TZ TZS
- UG UGX
- ZM ZMW
Cash pickup
- USD
Live covers more countries, including cash pickup in IDR in Indonesia.
Withdraw by bank transfer to more countries (API only for now, coming to the widget)
Bank transfer
- AED
- ARS
- AUD
- BRL
- BWP
- CAD
- CNY
- COP
- EUR
- GBP
- HKD
- INR
- JPY
- KES
- MWK
- MXN
- NGN
- RWF
- SGD
- TZS
- UGX
- USD
- VND
- XAF
- XOF
- ZMW
84 countries.
Exchange rates
Live exchange rates, and a price quote for an amount in another currency, so you can show prices in your user's currency. Nothing moves; money still settles in USD (USDb). Any key can call these, with no scope needed.
/ratesExchange rates against USD.
- rate is units of the currency per 1 USD.
{
"base": "USD",
"data": [
{ "currency": "IDR", "rate": 16250.5, "updated_at": "2026-10-23T09:00:00.000Z" }
]
}/quotesPrice an amount and its fee in another currency, for display.
Body (JSON)
amountstringrequiredUSD amount, e.g. "5.00".
currencystringrequired"USD".
display_currencystringrequirede.g. "IDR". Unknown currencies return 400.
- For display only. A quote is good for 60 seconds.
{
"object": "quote",
"amount": "5.000000",
"fee": "0.015000",
"total": "5.015000",
"currency": "USD",
"display_currency": "IDR",
"rate": 16250.5,
"display_amount": "81252.50",
"display_total": "81496.26",
"expires_at": "2026-10-23T09:13:44.000Z"
}Receiving webhooks
We POST an event to your endpoint when something happens to a payment, refund, transfer, payout, wallet, subscription, deposit or widget session.
curl https://agentic.bananacrystal.com/api/v1/partner/webhook-endpoints \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/bananacrystal",
"events": ["payment.settled", "payment.failed", "payment.expired"]
}'
# -> { "id": "we_...", "secret": "whsec_..." } (secret shown once)payment.settledThe payer confirmed and the money arrived. Subscription charges send this too.
payment.failedThe code was accepted, but the transfer failed.
payment.expiredThe code expired before the payer confirmed.
refund.completedA refund reached the payer.
refund.failedA refund couldn't be sent.
transfer.completedA transfer between your wallets went through.
transfer.failedA transfer couldn't be sent.
payout.completedA payout reached the holder's account. metadata.initiated_by is holder when the holder spent from a connected wallet in BananaCrystal.
payout.failedA payout couldn't be sent.
wallet.kyc_updatedA wallet's kyc_status changed. data is the wallet.
wallet.linkedThe holder connected the wallet to their BananaCrystal account.
wallet.unlinkedThe wallet was disconnected. by is holder or partner.
subscription.activatedThe payer confirmed and the first charge went through.
subscription.payment_failedA charge failed. data has failure_reason.
subscription.canceledYou canceled it, or its charge failed 3 times. reason is canceled_by_partner or payment_failed.
deposit.settledMoney landed in one of your wallets, by any method. data is the deposit.
session.completedA widget deposit settled in the wallet, or its withdrawal settled. data is the session.
session.canceledThe holder canceled in the widget, or you canceled the session.
session.expiredA session expired before anyone finished it.
The body is the event. data holds the object it's about, such as the payment. Each request also carries these headers: BananaCrystal-Signature, BananaCrystal-Event-Id and BananaCrystal-Event-Type.
{
"id": "evt_1a3c5e7b9d2f4a6c8e0b1d3f",
"type": "payment.settled",
"created_at": "2026-10-23T09:13:10.000Z",
"livemode": true,
"data": { "id": "pay_9a7c...", "object": "payment", "status": "settled", ... }
}Check the signature
BananaCrystal-Signature looks like t=1761210790,v1=5257a8…. Compute HMAC_SHA256(secret, t + "." + rawBody) as hex and compare it to v1. Reject the event if they differ or if t is more than 5 minutes old. Use the raw body exactly as received, before any JSON parsing.
const crypto = require("crypto");
// Use the raw request body, before any JSON parsing.
function verifyWebhook(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=")),
);
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express
app.post(
"/webhooks/bananacrystal",
express.raw({ type: "application/json" }),
(req, res) => {
const ok = verifyWebhook(
req.body.toString("utf8"),
req.get("BananaCrystal-Signature") || "",
process.env.BC_WEBHOOK_SECRET,
);
if (!ok) return res.status(400).end();
const event = JSON.parse(req.body);
// Handle event.type, then answer 2xx quickly
res.sendStatus(200);
},
);<?php
function verify_webhook(string $rawBody, string $header, string $secret): bool {
$parts = [];
foreach (explode(',', $header) as $pair) {
[$k, $v] = array_pad(explode('=', $pair, 2), 2, '');
$parts[$k] = $v;
}
$t = (int) ($parts['t'] ?? 0);
if ($t === 0 || abs(time() - $t) > 300) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1'] ?? '');
}
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_BANANACRYSTAL_SIGNATURE'] ?? '';
if (!verify_webhook($rawBody, $header, getenv('BC_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($rawBody, true);
// Handle $event['type'], then answer 2xx quickly
http_response_code(200);import hashlib
import hmac
import time
def verify_webhook(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts.get("t", "0"))
except ValueError:
return False
if not t or abs(time.time() - t) > 300:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
# Flask
@app.post("/webhooks/bananacrystal")
def bananacrystal_webhook():
ok = verify_webhook(
request.get_data(),
request.headers.get("BananaCrystal-Signature", ""),
os.environ["BC_WEBHOOK_SECRET"],
)
if not ok:
return "", 400
event = request.get_json()
# Handle event["type"], then answer 2xx quickly
return "", 200Retries
Answer with any 2xx status to accept the event. Anything else, or no answer, and we retry with backoff for 24 hours. Use BananaCrystal-Event-Id to skip events you already handled, and POST /events/:id/resend to get one again.
Webhook endpoints
Tell us where to send events. Each endpoint gets its own signing secret.
/webhook-endpointsRegister an endpoint.
Body (JSON)
urlstringrequiredAn https URL on your server.
eventsstring[]Which events to send. Leave it out to get all of them.
- secret is returned once, here. Store it to verify signatures.
{
"id": "we_5b7d9f1a3c5e7a9b1d3f5a7c",
"url": "https://example.com/webhooks/bananacrystal",
"events": ["payment.settled", "payment.failed"],
"created_at": "2026-10-23T09:00:00.000Z",
"secret": "whsec_…"
}/webhook-endpointsList endpoints.
{ "data": [ { "id": "we_…", "url": "…", "events": null, "created_at": "…" } ] }/webhook-endpoints/:idRemove an endpoint. It stops getting events.
Events
Every event we've sent you, with the delivery attempts to each endpoint.
/eventsList events, newest first.
Query
limitinteger1 to 100. Default 50.
{
"data": [
{
"id": "evt_1a3c5e7b9d2f4a6c8e0b1d3f",
"type": "payment.settled",
"created_at": "2026-10-23T09:13:10.000Z",
"livemode": false,
"data": { "id": "pay_…", "object": "payment", "status": "settled" },
"deliveries": [
{
"endpoint": "we_…",
"status": "delivered",
"attempts": 1,
"last_status_code": 200,
"last_error": null,
"delivered_at": "2026-10-23T09:13:11.000Z"
}
]
}
]
}/events/:id/resendSend an event again to your endpoints.
{ "id": "evt_…", "queued": 1 }