Developers

    Partner API

    Give every user on your platform a wallet. They add money in their own currency, you move it between wallets, and they cash out by bank transfer, mobile money or cash pickup. BananaCrystal runs the payment rails, the exchange and the ID checks.

    See a live demo

    Quick start

    1. 1

      Sign up for a BananaCrystal account at agents.bananacrystal.com.

    2. 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. 3

      Register a webhook with POST /webhook-endpoints and store the whsec_ secret it returns.

    4. 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.

    1. 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/wallets with reference, the user's name and holder_email, returns wal_…
    2. 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/sessions with type: "deposit", returns id and client_secret
      PAGEBananaCrystal.open({ session, clientSecret })
      WEBHOOKdeposit.settled and session.completed: about $15 is in the fan's wallet
    3. 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/transfers from the fan's wallet to the creator's
      WEBHOOKtransfer.completed
    4. 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/sessions with type: "withdraw"
      PAGEThe widget runs the ID check (first time only) and the payout
      WEBHOOKwallet.kyc_updated, then session.completed
    5. 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

    How it fits together

    One user's wallet from sign-up to withdrawal: who calls what, and which webhooks you get.

    How it fits together
    1. 1. Sign up

      1. Your user1.Signs up on your platform
      2. Your server2.Calls POST /wallets with reference, the user's name and holder_email
      3. BananaCrystal3.Returns the wallet id (wal_…). Store it with your user.
    2. 2. Add funds

      1. Your server1.Calls POST /sessions with type: deposit
      2. Your user2.Your page opens the widget (bcpay.js)
      3. Your user3.Pays by bank transfer, mobile money, cash or USDC
      4. BananaCrystal4.Sends session.completed and deposit.settled
    3. 3. Withdraw

      1. Your server1.Calls POST /sessions with type: withdraw
      2. Your user2.Opens the widget
      3. BananaCrystal3.First time only: the ID check, kyc_status none to approved
      4. BananaCrystal4.Pays out by bank transfer, mobile money or cash pickup
      5. BananaCrystal5.Sends session.completed
    4. 4. Optional: connect to BananaCrystal

      1. Your user1.Signs in to BananaCrystal with the same email
      2. Your user2.Sees "Connect your <Partner> wallet?" and taps Connect
      3. 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.

    Header
    Authorization: Bearer sk_live_...

    Live · sk_live_ keys

    https://agentic.bananacrystal.com/api/v1/partner

    Each 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 409 and 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.

    Error
    {
      "success": false,
      "statusCode": 400,
      "message": "amount must be a positive decimal string with at most 6 decimal places, e.g. \"5.00\""
    }
    • 400 the request is invalid
    • 401 missing or unknown key
    • 403 the key lacks the scope, the IP isn't allowed, a code is wrong, or the wallet isn't verified for payouts
    • 404 not found, or not yours
    • 409 idempotency conflict, or the payment isn't open
    • 410 the payment code expired
    • 502 a 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
    // 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
    <!-- 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>
    The widget, step by step
    1. 1.Browser → Your serverThe user clicks Add funds
    2. 2.Your server → BananaCrystal APIsecret keyPOST /sessions with your secret key
    3. 3.BananaCrystal API → Your serverid and client_secret
    4. 4.Your server → BrowserOnly id and client_secret
    5. 5.Browser → WidgetBananaCrystal.open({ session, clientSecret })
    6. 6.WidgetThe user picks a method and finishes
    7. 7.Widget → Browserbc:completed to your onEvent
    8. 8.Your server → BananaCrystal APIsecret keyGET /sessions/:id, or wait for the session.completed webhook. 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.

    POST/sessions

    Open the widget for one deposit into a wallet, or one withdrawal out of it.

    any keyaccepts Idempotency-Keyreturns 201

    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.

    walletstringrequired

    The wallet that gets the money, or pays it out.

    amountstring

    Decimal 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.

    verificationstring

    Withdraw 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_emailstring

    The holder's email. Required when verification is "email": the code goes there.

    return_urlstring

    Full-page flow only: where the holder goes when they finish.

    cancel_urlstring

    Full-page flow only: where the holder goes if they cancel or the link expires. Defaults to return_url.

    referencestring

    Your own id, up to 120 characters.

    descriptionstring

    Shown to the holder in the widget.

    metadataobject

    Up to 20 string keys and values, returned as you sent them.

    expires_ininteger

    Seconds 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.
    Response
    {
      "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"
    }
    GET/sessions/:id

    Get a session.

    scope payments:read
    Response
    {
      "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"
    }
    GET/sessions

    List sessions, newest first.

    scope payments:read

    Query

    walletstring

    Only this wallet.

    typestring

    deposit or withdraw.

    statusstring

    open, processing, completed, canceled or expired.

    limitinteger

    1 to 100. Default 20.

    Response
    { "data": [ ... ] }
    POST/sessions/:id/cancel

    Close an open session so nobody can use it.

    any key
    • 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.

    GET/wallets/:id/deposit-address

    The wallet's USDC deposit address on one network.

    scope wallets:read

    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.
    Response
    {
      "wallet": "wal_3f9c1a7e2b4d6f8a0c1e3b5d",
      "network": "stellar",
      "address": "GBXQ…",
      "memo": "4821907",
      "memo_type": "id",
      "token": { "code": "USDC", "issuer": "GA5Z…" },
      "min_amount": "1",
      "fee": { ... }
    }
    GET/deposits

    Deposits that settled in your wallets, by any method, newest first.

    scope wallets:read

    Query

    walletstring

    Only this wallet.

    limitinteger

    1 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.
    Response
    {
      "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%).

    donation.sh
    # 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.

    POST/payments

    Start a payment. We email the payer a 6-digit code.

    scope payments:writeaccepts Idempotency-Keyreturns 201

    Body (JSON)

    walletstringrequired

    The wallet that receives the money.

    amountstringrequired

    Decimal string, up to 6 decimal places, e.g. "5.00". The wallet receives this full amount.

    currencystringrequired

    "USD".

    payerstringrequired

    The payer's BananaCrystal email or username.

    descriptionstring

    Shown to the payer in the email.

    metadataobject

    Up 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.
    Response
    {
      "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
    }
    POST/payments/:id/confirm

    Confirm a payment with the payer's code.

    scope payments:write

    Body (JSON)

    codestringrequired

    The 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.
    Response
    {
      "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"
    }
    GET/payments/:id

    Get a payment.

    scope payments:read
    Response
    {
      "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"
    }
    GET/payments

    List payments, newest first.

    scope payments:read

    Query

    walletstring

    Only this wallet.

    statusstring

    otp_sent, settled, failed or expired.

    limitinteger

    1 to 100.

    Response
    { "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.

    POST/subscriptions

    Start a subscription. We email the payer a code that names the recurring charge.

    scope subscriptions:writeaccepts Idempotency-Keyreturns 201

    Body (JSON)

    walletstringrequired

    The wallet that receives each charge.

    payerstringrequired

    The payer's BananaCrystal email or username.

    amountstringrequired

    Each charge, e.g. "9.99".

    currencystringrequired

    "USD".

    intervalstringrequired

    "week" or "month".

    descriptionstring

    Shown to the payer in the email.

    metadataobject

    Up to 20 string keys and values, returned as you sent them.

    Response
    {
      "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"
    }
    POST/subscriptions/:id/confirm

    Confirm with the payer's code. This takes the first charge.

    scope subscriptions:write

    Body (JSON)

    codestringrequired

    The 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.
    Response
    {
      "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"
    }
    POST/subscriptions/:id/cancel

    Cancel a subscription. No more charges run.

    scope subscriptions:write
    • Returns the subscription with status "canceled" and next_charge_at null.
    GET/subscriptions/:id

    Get a subscription.

    scope payments:read
    GET/subscriptions

    List subscriptions, newest first.

    scope payments:read

    Query

    walletstring

    Only this wallet.

    statusstring

    pending, active, past_due or canceled.

    Response
    { "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.

    POST/transfers

    Move money from one wallet to another.

    scope transfers:writeaccepts Idempotency-Keyreturns 201

    Body (JSON)

    from_walletstringrequired

    Where the money comes from.

    to_walletstringrequired

    Where it goes. Must be a different wallet.

    amountstringrequired

    Decimal string, up to 6 decimal places, e.g. "20.00".

    descriptionstring

    For your records.

    metadataobject

    Up to 20 string keys and values, returned as you sent them.

    Response
    {
      "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"
    }
    GET/transfers/:id

    Get a transfer.

    scope wallets:read
    GET/transfers

    List transfers, newest first.

    scope wallets:read

    Query

    walletstring

    Only transfers from or to this wallet.

    Response
    { "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.

    POST/payouts

    Pay out to the wallet holder.

    scope payouts:writeaccepts Idempotency-Keyreturns 201

    Body (JSON)

    walletstringrequired

    The wallet to pay out from.

    currencystringrequired

    "USD".

    destinationobjectrequired

    { "type": "bananacrystal_account", "account": "<email or username>" }

    amountstringrequired

    Decimal string, up to 6 decimal places, e.g. "20.00".

    descriptionstring

    For your records.

    metadataobject

    Up 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.
    Response
    {
      "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"
    }
    GET/payouts/:id

    Get a payout.

    scope wallets:read
    GET/payouts

    List payouts, newest first.

    scope wallets:read

    Query

    walletstring

    Only this wallet.

    Response
    { "data": [ ... ] }

    Refunds

    Send money from a settled payment back to the payer's wallet, in full or in part.

    POST/payments/:id/refunds

    Refund a settled payment.

    scope refunds:writeaccepts Idempotency-Keyreturns 201

    Body (JSON)

    amountstring

    Decimal string, e.g. "2.50". Leave it out to refund everything not yet refunded.

    descriptionstring

    For your records.

    metadataobject

    Up 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.
    Response
    {
      "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"
    }
    GET/refunds/:id

    Get a refund.

    scope payments:read
    GET/refunds

    List refunds, newest first.

    scope payments:read

    Query

    paymentstring

    Only this payment.

    walletstring

    Only this wallet.

    limitinteger

    1 to 100.

    Response
    { "data": [ ... ] }

    Wallets

    A wallet holds a balance for one of your users, such as a fundraising campaign. Payments settle into it.

    POST/wallets

    Create a wallet.

    scope wallets:writeaccepts Idempotency-Key

    Body (JSON)

    referencestring

    Your own id for this user, so you can find the wallet later.

    holder_first_namestringrequired

    Your user's first name, up to 100 characters.

    holder_last_namestringrequired

    Your user's last name, up to 100 characters.

    holder_emailstringrequired

    Your 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.

    linkableboolean

    false keeps the wallet out of BananaCrystal accounts. Defaults to true.

    metadataobject

    Up 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."
    Response
    {
      "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"
    }
    PATCH/wallets/:id

    Change a wallet's holder name, holder_email, linkable or reference.

    scope wallets:write

    Body (JSON)

    holder_first_namestring

    Your user's first name, up to 100 characters. Can't be empty.

    holder_last_namestring

    Your user's last name, up to 100 characters. Can't be empty.

    holder_emailstring

    A 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.

    linkableboolean

    false disconnects the wallet if it's connected.

    referencestring

    Your own id for this user.

    • Disconnecting this way sends wallet.unlinked with by: partner.
    Response
    {
      "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"
    }
    GET/wallets

    List wallets, newest first.

    scope wallets:read

    Query

    limitinteger

    1 to 100. Default 20.

    referencestring

    Only wallets with this reference.

    Response
    { "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"
        } ] }
    GET/wallets/:id

    Get a wallet with its balance.

    scope wallets:read
    • balances.USDb is null if the balance couldn't be read right now.
    • kyc_status is none, pending, approved or rejected. Payouts need approved.
    Response
    {
      "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"
    }
    POST/wallets/:id/kyc-link

    Get a link the wallet's holder opens to verify their identity.

    scope kyc:write
    • 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.
    Response
    {
      "wallet": "wal_3f9c1a7e2b4d6f8a0c1e3b5d",
      "kyc_status": "pending",
      "url": "https://agentic.bananacrystal.com/api/v1/partner/verify/…",
      "expires_at": "2026-10-23T09:42:44.000Z"
    }
    GET/transactions

    Money in and out of a wallet, newest first.

    scope wallets:read

    Query

    walletstringrequired

    The wallet.

    limitinteger

    1 to 100.

    • type is payment, transfer, refund or payout. direction is in or out.
    Response
    {
      "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.
    Connected wallets
    Your serverSets holder_email on the wallet
    Your userSigns in to BananaCrystal with that email (confirmed when they signed up)
    Your userSees the Connect card and taps Connect
    BananaCrystalSends wallet.linked

    They spend in BananaCrystal

    Your userSends or withdraws more than their own wallet holds
    BananaCrystalMoves only the shortfall from the connected wallet
    BananaCrystalSends payout.completed with metadata.initiated_by: holder

    They disconnect

    Your userTaps Disconnect in BananaCrystal
    BananaCrystalSends 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.

    Example
    curl "https://agentic.bananacrystal.com/api/v1/partner/payment-methods?type=withdraw&country=KE" \
      -H "Authorization: Bearer sk_live_..."
    GET/payment-methods

    Every country, currency and method for deposits and withdrawals, with limits.

    any key

    Query

    typestring

    deposit or withdraw.

    countrystring

    ISO-2 code, e.g. "KE".

    currencystring

    e.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.
    Response
    {
      "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.

    GET/rates

    Exchange rates against USD.

    any key
    • rate is units of the currency per 1 USD.
    Response
    {
      "base": "USD",
      "data": [
        { "currency": "IDR", "rate": 16250.5, "updated_at": "2026-10-23T09:00:00.000Z" }
      ]
    }
    POST/quotes

    Price an amount and its fee in another currency, for display.

    any key

    Body (JSON)

    amountstringrequired

    USD amount, e.g. "5.00".

    currencystringrequired

    "USD".

    display_currencystringrequired

    e.g. "IDR". Unknown currencies return 400.

    • For display only. A quote is good for 60 seconds.
    Response
    {
      "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.

    Register an endpoint
    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.settled

    The payer confirmed and the money arrived. Subscription charges send this too.

    payment.failed

    The code was accepted, but the transfer failed.

    payment.expired

    The code expired before the payer confirmed.

    refund.completed

    A refund reached the payer.

    refund.failed

    A refund couldn't be sent.

    transfer.completed

    A transfer between your wallets went through.

    transfer.failed

    A transfer couldn't be sent.

    payout.completed

    A payout reached the holder's account. metadata.initiated_by is holder when the holder spent from a connected wallet in BananaCrystal.

    payout.failed

    A payout couldn't be sent.

    wallet.kyc_updated

    A wallet's kyc_status changed. data is the wallet.

    wallet.linked

    The holder connected the wallet to their BananaCrystal account.

    wallet.unlinked

    The wallet was disconnected. by is holder or partner.

    subscription.activated

    The payer confirmed and the first charge went through.

    subscription.payment_failed

    A charge failed. data has failure_reason.

    subscription.canceled

    You canceled it, or its charge failed 3 times. reason is canceled_by_partner or payment_failed.

    deposit.settled

    Money landed in one of your wallets, by any method. data is the deposit.

    session.completed

    A widget deposit settled in the wallet, or its withdrawal settled. data is the session.

    session.canceled

    The holder canceled in the widget, or you canceled the session.

    session.expired

    A 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.

    Event
    {
      "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.

    verify.js
    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);
      },
    );
    verify.php
    <?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);
    verify.py
    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 "", 200

    Retries

    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.

    POST/webhook-endpoints

    Register an endpoint.

    scope webhooks:writereturns 201

    Body (JSON)

    urlstringrequired

    An 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.
    Response
    {
      "id": "we_5b7d9f1a3c5e7a9b1d3f5a7c",
      "url": "https://example.com/webhooks/bananacrystal",
      "events": ["payment.settled", "payment.failed"],
      "created_at": "2026-10-23T09:00:00.000Z",
      "secret": "whsec_…"
    }
    GET/webhook-endpoints

    List endpoints.

    scope webhooks:write
    Response
    { "data": [ { "id": "we_…", "url": "…", "events": null, "created_at": "…" } ] }
    DELETE/webhook-endpoints/:id

    Remove an endpoint. It stops getting events.

    scope webhooks:writereturns 204

    Events

    Every event we've sent you, with the delivery attempts to each endpoint.

    GET/events

    List events, newest first.

    scope webhooks:write

    Query

    limitinteger

    1 to 100. Default 50.

    Response
    {
      "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"
            }
          ]
        }
      ]
    }
    POST/events/:id/resend

    Send an event again to your endpoints.

    scope webhooks:write
    Response
    { "id": "evt_…", "queued": 1 }