Bankable

Developer documentation

Integrating Bankable

Two ways in: a REST API for your servers, and an embeddable panel for your customers' browsers. Most integrations use both.

Overview

SurfaceRuns whereAuthUse it for
REST APIYour serverBearer API key Issuing accounts, reading deposits, releasing funds, payouts.
EmbedYour customer's browserSigned short-lived token Showing a customer the details to pay into, inside your own portal.
Base URL

Everything is served from your dashboard domain, not ours: https://payments.yourcompany.com/api/v1. Your customers never see a Bankable URL.

Get a key

Sign in, open API, and create one. Choose read only for reporting integrations, read and write for anything that issues accounts or moves money. The key is shown once and stored only as a hash — if you lose it, revoke and reissue.

Authentication

Authorization: Bearer bk_live_xxxxxxxxxxxxxxxxxxxxxxxx

Every endpoint except /ping requires it. If your stack strips Authorization headers, send the same value as X-Api-Key.

Server-side only

A write key can move money. Never put it in browser JavaScript, a mobile app, or a git repository. That is exactly what the embed token below exists to avoid.

Responses

{
  "success": true,
  "data":    { ... } or [ ... ],
  "error":   null,
  "meta":    { "total": 42, "limit": 25, "offset": 0 }
}

Branch on error.code, never on the message text.

Amounts are in major units

"amount": 125.50 is £125.50, not 125½ pence. Stripe's own API uses minor units — if you're porting code, this is the line that will bite you.

Endpoints

GET/pingNo key needed. Confirms your URL.
GET/meConfirms the key and shows its scopes.
POST/accountsIssue account details.
GET/accountsList them.
GET/accounts/{id}One account, with balance.
GET/accounts/{id}/depositsMoney received.
POST/accounts/{id}/releaseMove a balance. Real money.
POST/accounts/{id}/embed-tokenMint a browser view link.
GET/payoutsRelease history.
POST/payouts/globalPay a third party.
GET/recipientsWho you can pay.

Issuing account details

curl -X POST https://payments.yourcompany.com/api/v1/accounts \
  -H "Authorization: Bearer bk_live_xxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "reference": "customer-4471", "currency": "usd" }'
{
  "success": true,
  "data": {
    "id": "cus_Nx8Qw2vK1LmR4t",
    "reference": "customer-4471",
    "account_holder": "Your Company Ltd",
    "bank_name": "Example Bank",
    "account_number": "00012345",
    "routing_number": "021000021",
    "currency": "USD",
    "created_at": "2026-08-13T19:40:02+00:00"
  },
  "error": null
}

Store data.id against your customer — every other call needs it.

PHP example

Using the drop-in client (no Composer required):

<?php
require_once __DIR__ . '/BankableClient.php';

$bankable = new BankableClient(
    getenv('BANKABLE_API_KEY'),
    'https://payments.yourcompany.com/api/v1'
);

try {
    // 1. Issue details when a customer needs to pay you
    $account = $bankable->createAccount('customer-4471', 'usd');
    save_to_your_db($customerId, $account['id']);

    // 2. Show them what to pay into
    echo $account['account_number'], ' / ', $account['routing_number'];

    // 3. Later: what have they sent?
    foreach ($bankable->listDeposits($account['id']) as $d) {
        echo $d['received_at'], ' ', $d['amount'], ' from ', $d['sender_name'], "\n";
    }

} catch (BankableException $e) {
    error_log('Bankable: ' . $e->errorCode . ' - ' . $e->getMessage());
}

Errors

StatusCodeMeaning
401missing_credentialsNo key sent.
401invalid_keyNot recognised — check for whitespace.
401revoked_keyRevoked in the dashboard.
403insufficient_scopeRead-only key tried to write.
403feature_disabledGlobal Payouts not enabled.
404not_foundUnknown endpoint, or an id that isn't yours.
409stripe_not_connectedNo Stripe key saved yet.
422invalid_amountAmount missing or not positive.
422unsupported_currencyNot usd, gbp or eur.
429rate_limitedOver 120/min. See Retry-After.
502stripe_errorStripe refused. Reason in message.
502funding_unavailableEnable bank transfers for that currency in Stripe.

The embed

A ready-made panel showing one customer their account details, balance received, and copy buttons — dropped into your own portal as an iframe.

It exists so you never put an API key in a browser. Your server mints a token for one specific customer; the resulting URL shows that customer's details and nothing else, and stops working when it expires.

1. Mint a token, server-side

curl -X POST https://payments.yourcompany.com/api/v1/accounts/cus_Nx8Qw2vK1LmR4t/embed-token \
  -H "Authorization: Bearer bk_live_xxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "expires_in": 900 }'
{
  "success": true,
  "data": {
    "token": "eyJ0ZW5hbnQ...",
    "url": "https://payments.yourcompany.com/embed.php?t=eyJ0ZW5hbnQ...",
    "expires_at": "2026-08-13T20:15:00+00:00"
  },
  "error": null
}

expires_in is seconds, from 60 to 86400. Default 900 (15 minutes). Requires only the read scope.

2. Drop the URL into an iframe

<iframe
  src="https://payments.yourcompany.com/embed.php?t=THE_TOKEN"
  style="width:100%; max-width:500px; height:420px; border:0"
  title="Payment details"
  loading="lazy"></iframe>
Mint it per page load

Don't cache the URL or store it in your database. Generate a fresh one each time the page renders — it's one fast call, and short lifetimes are what make a leaked URL harmless.

Options

ParameterValuesEffect
ttokenRequired. From the endpoint above.
themelight · darkDefaults to light. Append &theme=dark.

The panel is responsive and sizes itself to the iframe. About 420px tall suits most layouts; a little more if a balance is showing.

What the embed will never do

It is display-only. There are no controls in it that can move money, change settings, or reach any other customer's data — so it is safe to show to an end customer who is not your staff.

Rate limits

120 requests per minute per key. Every response carries the current state:

X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117

Over the limit returns 429 with Retry-After in seconds.

Not supported yet

MissingDo this instead
Outbound webhooks to your appPoll /accounts/{id}/deposits. Stripe's webhooks reach us; we don't forward events to you yet.
Idempotency keysRecord your own request ids and check /payouts before retrying a write that timed out.
Creating recipientsUse the dashboard; only reading is exposed.
Closing an accountNot available — stop using the details.
Before going live

Test against a Stripe test key first, and prove one small real transfer end to end before wiring this into anything unattended.