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 sb_at_live_…"

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 sb_at_live_… / sb_at_test_…, valid 1 hour (expires_in: 3600)
Refresh token sb_rt_live_… / sb_rt_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 installation_suspended Back off, retry later, keep data
404 not_found Object does not exist for this merchant
404 unknown_endpoint Path typo or unsupported resource
405 method_not_allowed v1 is read-only; only GET
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 the merchant's data. Paths below are relative to the base URL/payments means https://api.southbill.com/v1/app/payments. Version 1 is read-only: every endpoint is a GET, anything else returns 405 method_not_allowed. Write scopes exist in the scope reference but are not callable in v1; they are granted case by case after review.

Base URL: https://api.southbill.com/v1/app · Auth: Authorization: Bearer sb_at_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).

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 sb_at_live_…"
{ "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 ISO-8601 UTC; arrival_date, issue_date and due_date are dates without a time part.

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

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": null, "amount": 2500, "fee": null, "currency": "eur",
  "status": "needs_response", "reason": "fraudulent", "evidence_due_by": 1756684799,
  "evidence_submitted_at": null, "past_due": false, "closed_at": null, "created": 1755602100 }
Field Meaning
status needs_responseunder_reviewwon or lost.
evidence_due_by Hard deadline in unix seconds.
past_due Live only — null in sandbox.

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 not exposed in App API v1.


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, "days": 30, "currency": "eur",
  "gross_amount": 1284000, "net_amount": 1240000, "payment_count": 412,
  "refund_amount": 12000, "refund_count": 3 }

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.