App API

Authentication, scopes, rate limits and the core endpoints.

Authentication

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.

Endpoints

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.

Objects & fields

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.

Rate limits & errors

Four enforcement levels, and how to stay under them.

Limits are enforced hierarchically — burst, then installation, then app, then platform. Test and live traffic are counted separately, so sandbox load can never eat live quota.

Per-installation quotas

Group Test Live
Read 120 req/min, burst 10/s 600 req/min, burst 30/s
Write 60 req/min, burst 5/s 300 req/min, burst 15/s
Heavy (analytics, exports) 20 req/min, burst 2/s 60 req/min, burst 5/s

App-wide ceilings

Across all installations of one app: 3 000 req/min in test, 30 000 req/min in live. A generous platform ceiling sits above that as an emergency brake.

Headers

Every response carries:

X-RateLimit-Limit: 600
X-RateLimit-Remaining: 583
X-RateLimit-Reset: 27
Retry-After: 27      # only on 429

Handling 429

async function call(url, token, attempt = 0) {
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (res.status === 429 && attempt < 5) {
    const wait = Number(res.headers.get("Retry-After") ?? 1) * 1000;
    await new Promise((r) => setTimeout(r, wait + Math.random() * 250));
    return call(url, token, attempt + 1);
  }
  return res;
}

Add jitter, cap retries, and never retry a 403.

Staying efficient

  • React to webhooks instead of polling.
  • Cache /v1/merchant; it changes rarely.
  • Use time-window filters for backfills, and run them off-peak.
  • When usage crosses 80 % of an app quota we notify you before throttling bites.