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
| Surface | Runs where | Auth | Use it for |
|---|---|---|---|
| REST API | Your server | Bearer API key | Issuing accounts, reading deposits, releasing funds, payouts. |
| Embed | Your customer's browser | Signed short-lived token | Showing a customer the details to pay into, inside your own portal. |
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.
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.
"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
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
| Status | Code | Meaning |
|---|---|---|
| 401 | missing_credentials | No key sent. |
| 401 | invalid_key | Not recognised — check for whitespace. |
| 401 | revoked_key | Revoked in the dashboard. |
| 403 | insufficient_scope | Read-only key tried to write. |
| 403 | feature_disabled | Global Payouts not enabled. |
| 404 | not_found | Unknown endpoint, or an id that isn't yours. |
| 409 | stripe_not_connected | No Stripe key saved yet. |
| 422 | invalid_amount | Amount missing or not positive. |
| 422 | unsupported_currency | Not usd, gbp or eur. |
| 429 | rate_limited | Over 120/min. See Retry-After. |
| 502 | stripe_error | Stripe refused. Reason in message. |
| 502 | funding_unavailable | Enable 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>
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
| Parameter | Values | Effect |
|---|---|---|
t | token | Required. From the endpoint above. |
theme | light · dark | Defaults 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.
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
| Missing | Do this instead |
|---|---|
| Outbound webhooks to your app | Poll /accounts/{id}/deposits. Stripe's webhooks reach us; we don't forward events to you yet. |
| Idempotency keys | Record your own request ids and check /payouts before retrying a write that timed out. |
| Creating recipients | Use the dashboard; only reading is exposed. |
| Closing an account | Not available — stop using the details. |
Test against a Stripe test key first, and prove one small real transfer end to end before wiring this into anything unattended.