Authentication, scopes, rate limits and the core endpoints.
Bearer tokens, modes and installation context.
Base URL:
https://api.southbill.com/v1/app
Every request carries an installation access token:
curl "https://api.southbill.com/v1/app/payments?limit=10" \
-H "Authorization: Bearer sbat_live_…"
const res = await fetch("https://api.southbill.com/v1/app/payments?limit=10", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new Error((await res.json()).error?.type ?? res.statusText);
const { data, has_more } = await res.json();
import requests
res = requests.get(
"https://api.southbill.com/v1/app/payments",
params={"limit": 10},
headers={"Authorization": f"Bearer {access_token}"},
timeout=30,
)
res.raise_for_status()
payments = res.json()["data"]
<?php
$ch = curl_init("https://api.southbill.com/v1/app/payments?limit=10");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer {$accessToken}"],
]);
$body = json_decode(curl_exec($ch), true);
$payments = $body["data"];
The token identifies the app and the merchant. There is no merchant id parameter — you cannot address a merchant that has not installed you.
Getting a token
Tokens come from the OAuth flow at install time (authorization_code, PKCE supported) and are renewed with the refresh_token grant. See Installs & OAuth.
| Property |
Value |
| Access token |
sbat_live_… / sbat_test_…, valid 1 hour (expires_in: 3600) |
| Refresh token |
sbrt_live_… / sbrt_test_…, single use and rotating — store the new one on every refresh |
| Mode |
Baked into the token, cannot be switched |
| Scopes |
Exactly what the merchant approved |
| Reissue |
Issuing a new access token revokes the installation's previous access tokens |
Because refresh tokens rotate, refreshing twice with the same token invalidates the installation's tokens — serialise your refresh calls.
Failure modes
| Status |
error.type |
Do this |
| 401 |
invalid_token |
Refresh, then retry once |
| 403 |
insufficient_scope |
Request the scope in a new version; do not retry |
| 403 |
merchant_suspended |
Back off, retry later, keep data |
| 401 |
installation_inactive |
Installation revoked or suspended — stop calling |
| 404 |
not_found |
Object does not exist for this merchant |
| 404 |
unknown_endpoint |
Path typo or unsupported resource |
| 405 |
method_not_allowed |
Method not supported on this route. Reads are GET; Store writes use POST / PATCH / DELETE with the products:write, files:write or orders:write scope |
| 429 |
rate_limit_exceeded |
Honour Retry-After |
Error bodies are uniform:
{ "error": { "type": "insufficient_scope", "message": "Missing required scope: payments:read" } }
Server-side only
The client secret and refresh tokens must never reach a browser or a mobile binary. Public clients use PKCE and keep only the short-lived access token.
Everything the App API exposes today.
The App API is how your app reads and writes the merchant's data. Paths below are relative to the base URL — /payments means https://api.southbill.com/v1/app/payments. Payments, payouts, invoices, subscriptions and analytics are read-only. The Store surface (products, prices, options, values, variants, files, orders) also supports writes with the products:write, files:write and orders:write scopes — no merchant secret key is needed, the installation's sbat_live_… / sbat_test_… token is enough. Unsupported methods return 405 method_not_allowed.
Write requests should send an Idempotency-Key header; a repeated key returns the original result instead of creating a second object.
Base URL: https://api.southbill.com/v1/app · Auth: Authorization: Bearer sbat_live_… (see Authentication).
Endpoints
| Method |
Path |
Scope |
What it gives you |
| GET |
/merchant |
merchant:read |
Profile of the merchant that installed your app: company, country, VAT number, status. Call once after install to label the connection. |
| GET |
/payments |
payments:read |
Processed payments, newest first. Live traffic supports ?status=succeeded. |
| GET |
/payments/{id} |
payments:read |
One payment. The {id} is the charge_id in live and the row id in sandbox. |
| GET |
/refunds |
refunds:read |
Sandbox: refund rows. Live: refunded charges with the running amount_refunded. |
| GET |
/disputes |
disputes:read |
Chargebacks including evidence_due_by and past_due. |
| GET |
/payouts |
payouts:read |
Bank settlements. Restricted scope — reconciliation tools only. |
| GET |
/balance |
payouts:read |
Available and pending balance of the merchant. |
| GET |
/products |
products:read |
The merchant's catalog (no prices in v1). |
| GET |
/products/{id} |
products:read |
One product. |
| GET |
/invoices |
invoices:read |
Issued invoices. Live only — sandbox returns an empty list. |
| GET |
/invoices/{id} |
invoices:read |
One invoice. |
| GET |
/subscriptions |
subscriptions:read |
Recurring agreements with status and period end. |
| GET |
/analytics/summary |
analytics:read |
Aggregated volume for a window: ?days=30 (1–365). |
Store write endpoints
| Method |
Path |
Scope |
What it does |
| GET |
/store |
store:read |
The merchant's Southbill Store: name, slug, branding, status. |
| POST |
/products |
products:write |
Create a store product (name, description, images, active/visible flags). |
| PATCH |
/products/{id} |
products:write |
Update a product, incl. default_price_id. |
| DELETE |
/products/{id} |
products:write |
Archive a product (soft delete — stays referenced by past orders). |
| POST |
/products/{id}/prices |
products:write |
Add a price (minor units + currency). |
| GET |
/products/{id}/options |
products:read |
Option groups (Size, Colour) with values. |
| POST |
/products/{id}/options |
products:write |
Create an option group. |
| PATCH |
/options/{id} |
products:write |
Rename / reorder an option group. |
| DELETE |
/options/{id} |
products:write |
Delete an option group — 409 while variants reference it. |
| POST |
/options/{id}/values |
products:write |
Add an option value. |
| DELETE |
/values/{id} |
products:write |
Delete a value — 409 while a variant uses it. |
| GET |
/products/{id}/variants |
products:read |
Variants incl. their option combination. |
| POST |
/products/{id}/variants |
products:write |
Create a variant. Duplicate combinations return 409. |
| PATCH |
/variants/{id} |
products:write |
Update price, stock, SKU or option combination. |
| DELETE |
/variants/{id} |
products:write |
Delete a variant. The implicit default variant cannot be deleted. |
| POST |
/files |
files:write |
Upload product images / documents (max 5 MB). |
| GET |
/orders |
orders:read |
Store orders. Only Southbill Store payments create orders. |
| GET |
/orders/{id} |
orders:read |
One order. |
| PATCH |
/orders/{id} |
orders:write |
Update fulfillment status, tracking, carrier. |
| POST |
/orders/{id}/evidence |
orders:write |
Attach delivery evidence. |
| POST |
/orders |
orders:write |
Sandbox only — create an order fixture to test fulfillment. |
Subscription links
| Method |
Path |
Scope |
What it does |
| GET |
/subscription_links |
subscriptions:read |
List the merchant's subscription links. |
| GET |
/subscription_links/{id} |
subscriptions:read |
One subscription link. |
| POST |
/subscription_links |
subscriptions:write |
Create a subscription link (amount in minor units, currency, interval). |
| POST |
/subscription_links/{id} |
subscriptions:write |
Update a subscription link. |
| POST |
/subscription_links/{id}/archive |
subscriptions:write |
Archive a subscription link. |
Subscription links are only available on live installations; the merchant and store are always taken from the installation, never from the request body.
Ownership is always derived from the access token, never from ids in the body: an app can only touch the store it was installed on, in the environment of its token.
Field-by-field descriptions of every response are in Objects & fields.
Query parameters
| Parameter |
Where |
Behaviour |
limit |
all list endpoints |
1–100, default 25. |
offset |
all list endpoints |
Row offset, default 0. |
status |
/payments (live) |
Exact match, e.g. ?status=succeeded. |
days |
/analytics/summary |
1–365, default 30. |
Unknown query parameters are ignored, not rejected. There are no date-range filters in v1 — page by offset and stop at the first object older than your cursor.
Pagination
curl "https://api.southbill.com/v1/app/payments?limit=50&offset=100" \
-H "Authorization: Bearer sbat_live_…"
async function* allPayments(accessToken) {
for (let offset = 0; ; offset += 50) {
const res = await fetch(
`https://api.southbill.com/v1/app/payments?limit=50&offset=${offset}`,
{ headers: { Authorization: `Bearer ${accessToken}` } },
);
const page = await res.json();
yield* page.data;
if (!page.has_more) return;
}
}
def all_payments(access_token):
offset = 0
while True:
page = requests.get(
"https://api.southbill.com/v1/app/payments",
params={"limit": 50, "offset": offset},
headers={"Authorization": f"Bearer {access_token}"},
timeout=30,
).json()
yield from page["data"]
if not page["has_more"]:
return
offset += 50
<?php
$offset = 0;
do {
$ch = curl_init("https://api.southbill.com/v1/app/payments?limit=50&offset={$offset}");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer {$accessToken}"],
]);
$page = json_decode(curl_exec($ch), true);
foreach ($page["data"] as $payment) {
// handle $payment
}
$offset += 50;
} while ($page["has_more"]);
{ "object": "list", "data": [ … ], "has_more": true }
Lists are sorted newest first and carry no total count. Iterate until has_more is false. New objects arrive while you page, so for exports read the first page frequently rather than deep-paging a moving list.
Test vs live
The token decides the data source: a sandbox token reads your environment's simulated data, a live token reads the merchant's real data. Field names differ between the two — see the table at the top of Objects & fields.
Amounts and timestamps
Amounts are integer minor units with a lowercase ISO currency: { "amount": 2500, "currency": "eur" } is €25.00. Never use floats. Timestamps are unix seconds (UTC integers) — including arrival_date, issue_date and due_date, which are midnight UTC of the respective day. See Objects & fields.
Errors
{ "error": { "type": "insufficient_scope", "message": "Missing required scope: payments:read" } }
See Error & status codes.
The shape of every resource you can read.
This page describes the actual JSON the App API returns, field by field.
Sandbox and live return the same object shape. The only difference is the data source and the livemode flag — you never need two parsers.
Global rules
| Rule |
What it means |
| Amounts are integers in minor units |
2500 with "currency": "eur" is €25.00. Never use floats. |
| Currencies are lowercase ISO-4217 |
eur, chf, usd. |
| Timestamps are unix seconds |
created, arrival_date, due_date, current_period_end are integers (UTC seconds), or null. |
livemode tells you the source |
false = your sandbox environment, true = the installed merchant's real data. |
| IDs are opaque |
Sandbox uses UUIDs, live uses processor IDs (ch_…, dp_…, po_…). Never parse them. |
| Objects are additive |
Ignore unknown fields instead of failing. |
object names the type |
payment, refund, dispute, payout, product, invoice, subscription, merchant, balance, analytics_summary, list. |
Fields that do not exist in one environment are null |
e.g. fee / net are sandbox-only, past_due is live-only. |
Lists are { "object": "list", "data": [...], "has_more": bool } and accept ?limit= (max 100) and ?offset=.
Merchant — GET /merchant
{ "object": "merchant", "id": "…", "business_name": "Ada GmbH", "email": "ops@ada.de",
"country": "DE", "city": "Berlin", "vat_number": "DE123456789", "reference": "M-10423",
"status": "approved", "plan_id": "…", "livemode": true, "created": 1755423672 }
| Field |
Meaning |
business_name |
Legal/company name from onboarding. |
status |
Onboarding state — only approved merchants process live payments. |
plan_id |
The merchant's Southbill plan; null in sandbox. |
created |
Unix seconds of the account creation. |
Payment — GET /payments, GET /payments/{id}
{ "object": "payment", "livemode": true, "id": "ch_3Nk91x", "payment_intent": "pi_3Nk91x",
"charge": "ch_3Nk91x", "amount": 2500, "amount_refunded": 0, "fee": null, "net": null,
"currency": "eur", "status": "succeeded", "refunded": false, "disputed": null,
"description": "Order #1042", "customer_email": "ada@example.com", "customer_name": "Ada Lovelace",
"payment_method_type": "card", "card_brand": "visa", "card_last4": "4242", "card_country": "DE",
"receipt_url": "https://…", "failure_code": null, "failure_message": null,
"created": 1755423672 }
| Field |
Meaning |
id |
Use it for GET /payments/{id}. Live: the charge id. Sandbox: the row id. |
amount / amount_refunded |
Minor units. amount - amount_refunded is what the merchant kept. |
fee / net |
Simulated Southbill fee and net amount — sandbox only, null in live. |
status |
succeeded means paid. Also pending, failed, canceled. |
refunded |
true once fully refunded; a non-zero amount_refunded with false is a partial refund. |
created |
Unix seconds; also the sort key of the list. |
Filter live payments with ?status=succeeded.
Refund — GET /refunds
{ "object": "refund", "livemode": true, "id": "ch_3Nk91x_refund", "charge": "ch_3Nk91x",
"payment_intent": "pi_3Nk91x", "amount": 500, "currency": "eur",
"status": "succeeded", "reason": null, "created": 1755500530 }
In live, one row represents the running refunded total of a charge (reason is null). In sandbox, each refund row is returned individually including its reason.
Dispute — GET /disputes
{ "object": "dispute", "livemode": true, "id": "dp_7c", "charge": "ch_3Nk91x",
"payment_intent": "pi_3Nk91x", "amount": 2500, "fee": null, "currency": "eur",
"status": "needs_response", "reason": "fraudulent", "evidence_due_by": 1756684799,
"evidence_submitted": false, "evidence_submitted_at": null, "past_due": false,
"closed_at": null, "created": 1755602100 }
| Field |
Meaning |
status |
needs_response → under_review → won or lost. |
evidence_due_by |
Hard deadline in unix seconds. |
evidence_submitted |
Boolean — whether evidence was filed. Present in both environments. |
evidence_submitted_at |
Unix seconds; sandbox only (null in live — use evidence_submitted). |
past_due |
Live only — null in sandbox. |
closed_at |
Sandbox only; in live read status for the outcome. |
Payout — GET /payouts
{ "object": "payout", "livemode": true, "id": "po_1M2", "amount": 128400, "currency": "eur",
"status": "paid", "method": "standard", "destination_bank_name": "N26",
"destination_last4": "4321", "arrival_date": 1755561600,
"failure_code": null, "failure_message": null, "created": 1755561600 }
Product — GET /products, GET /products/{id}
{ "object": "product", "livemode": true, "id": "…", "name": "T-Shirt", "description": "…",
"active": true, "images": [], "tags": [], "unit_label": null, "url": null,
"created": 1750000000, "updated": 1755000000 }
Prices are managed through the Store surface in App API v1. Create them with POST /products/{id}/prices using the products:write scope. The product response does not inline a price list; use the dedicated price route. Option groups and variants are available through /products/{id}/options and /products/{id}/variants.n App API v1: create them with POST /products/{id}/prices using products:write. The product object itself does not inline a price list; retrieve or manage prices through that dedicated route.
Invoice — GET /invoices, GET /invoices/{id}
{ "object": "invoice", "livemode": true, "id": "…", "invoice_number": "SPK-INV-2026-0042",
"status": "paid", "currency": "eur", "subtotal_amount": 10000, "tax_amount": 1900,
"total_amount": 11900, "customer_name": "Ada GmbH", "customer_email": "ada@example.com",
"customer_type": "business", "issue_date": 1755000000, "due_date": 1757000000,
"paid_at": 1755600000, "created": 1755000000 }
Live only. In sandbox, the list is empty and GET /invoices/{id} returns 404.
Subscription — GET /subscriptions
{ "object": "subscription", "livemode": true, "id": "…", "status": "active",
"currency": "eur", "amount": 1990, "quantity": 1,
"customer_email": "ada@example.com", "customer_name": "Ada Lovelace",
"current_period_start": 1755000000, "current_period_end": 1757592000,
"cancel_at_period_end": false, "created": 1750000000 }
Balance — GET /balance
{ "object": "balance", "livemode": true, "available": [{ "amount": 128400, "currency": "eur" }],
"pending": [{ "amount": 25000, "currency": "eur" }] }
Analytics — GET /analytics/summary?days=30
{ "object": "analytics_summary", "livemode": true, "period_days": 30, "currency": "eur",
"gross_amount": 1284000, "net_amount": 1240000, "payment_count": 412,
"refunded_amount": 12000 }
`period_days` echoes the requested window (max 90). `net_amount` is
`gross_amount - refunded_amount`. Only succeeded payments are counted.