REST API

All server-side endpoints for sessions, refunds and invoices.

Error reference

Every error type the API can return, with meaning and how to react.

Error reference

All errors are JSON with the same envelope:

{
  "error": {
    "type": "invalid_request",
    "code": "customer_email_invalid",
    "message": "customer_email must be a valid email address",
    "param": "customer_email",
    "request_id": "req_01H…"
  }
}
  • type — high-level family (see table below).
  • code — machine-readable specific error. Always populated for validation and idempotency errors.
  • message — human-readable. Safe to log, do not show verbatim to end customers.
  • param — the offending body field, when applicable.
  • request_id — always log it. Support can look it up instantly.

HTTP status codes

HTTP Meaning Retry?
200 / 201 Success —
400 Client error (bad input) No — fix and resend
401 Auth failed (missing / bad / revoked key) No
402 Payment declined at the network Depends (see decline_code)
403 Key valid but not allowed for this action No
404 Resource not found No
409 Conflict (idempotency reuse, merchant not ready) No — see code
422 Semantically invalid (e.g. session already paid) No
429 Rate limited Yes — respect Retry-After
5xx Southbill server issue Yes — with the same Idempotency-Key

error.type values

Type HTTP When
invalid_request 400 Missing / malformed field. param tells you which.
amount_too_small 400 Below the 2.50 EUR equivalent minimum.
amount_too_large 400 Above the per-charge maximum.
currency_unsupported 400 Currency not in the supported list.
authentication_error 401 Key missing, malformed, revoked, or a legacy test-mode key (only sk_live_ / pk_live_ are accepted).
permission_error 403 Key valid but scope/role forbids this action.
card_error 402 Buyer's card was declined. See code / decline_code.
idempotency_error 409 Same Idempotency-Key reused with a different request body (code = idempotency_key_reused), or a request with the same key is still in flight (code = idempotency_in_flight — retry after a short delay).
configuration_error 400/409 Merchant setup incomplete (missing fee or payment-method configuration).
account_not_ready 409 Payouts account not fully onboarded yet.
product_limit_reached 409 Plan limit for products reached.
already_refunded 422 The charge is fully refunded already.
stripe_error 402/4xx Error surfaced by the payment network.
fx_unavailable / fee_config_missing 409 Rate or fee configuration temporarily unavailable.
resource_conflict 409 e.g. merchant_not_ready, subscription_already_canceled.
not_found 404 The id does not exist under this merchant.
invalid_state 422 session_expired, session_already_completed, charge_disputed.
rate_limit_error 429/503 Too many requests. Back off. code = rate_limiter_unavailable (503) means the limiter is degraded — retry shortly.
api_error 5xx Transient Southbill error. Retry with the same Idempotency-Key.

Idempotency conflict — exact shape

Reusing an Idempotency-Key with a body that differs from the original request always returns:

{
  "error": {
    "type": "idempotency_error",
    "code": "idempotency_key_reused",
    "message": "Idempotency-Key reused with a different request body"
  }
}
  • HTTP status: 409 Conflict
  • Uniqueness scope: (api_key_id, Idempotency-Key), with method + path folded into the request fingerprint.
  • Same key + same body → replays the original response (status + body) verbatim.
  • Same key + different body → the 409 shown above.
  • Keys expire after 24 hours.

Recommended handling

  • Never retry 4xx errors except 409 idempotency_error after you've fixed the body and rotated the key.
  • Always retry 5xx with the same idempotency key, using exponential backoff (1s → 2s → 4s, max 5 attempts).
  • On 429, sleep for the number of seconds in Retry-After (default 1) before the next attempt.
  • Show error.message to internal operators, not to end customers — use a friendly wrapper.

The client_secret

What it is, how long it lives, and how to use it safely.

The client_secret

Each Checkout Session returns a client_secret alongside its id:

{
  "id": "cs_01H…",
  "client_secret": "cs_01H…_secret_a7f9…",
  "checkout_url": "https://payments.southbill.com/c/cs_01H…?cs=cs_01H…_secret_a7f9…",
  "embed_url":    "https://payments.southbill.com/embed/cs_01H…?cs=cs_01H…_secret_a7f9…"
}

What it does

The client_secret authorises one specific browser session to load the hosted checkout for that session. It is scoped to a single cs_… id and cannot be used to create, modify, refund, or list anything.

It is safe to send to the browser (embed page, redirect URL, iframe src). It is not safe to log publicly or share across users — anyone with the value can open that particular checkout.

Lifetime

Event Effect on client_secret
Session created Valid for 24 hours or until session status changes.
Session complete / expired / canceled Immediately invalid — loading the checkout returns session_expired.
Session paid via async method (SEPA, Klarna) Immediately invalid; buyer is redirected to success_url.

There is no way to renew a client_secret. If it expires, create a new Checkout Session and redirect to the new checkout_url.

Correct usage

Backend (your server):

const session = await fetch("https://api.southbill.com/v1/checkout/sessions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SOUTHBILL_SECRET_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": `order_${orderId}`,
  },
  body: JSON.stringify({ amount: 4990, currency: "eur", customer_name, customer_email }),
}).then(r => r.json());

// Redirect the buyer:
res.redirect(303, session.checkout_url);

// OR return only what the browser needs:
res.json({ embed_url: session.embed_url });

Frontend:

<iframe src="{embed_url}" allow="payment" width="100%" height="720"></iframe>

Never send sk_live_… to the browser. The browser only ever sees client_secret / checkout_url / embed_url.

Security notes

  • Treat the client_secret like a one-time link: don't email it, don't index it, don't put it in shared logs.
  • If a buyer abandons a session, you can call POST /v1/checkout/sessions/{id}/expire to invalidate its client_secret early.

Idempotency

Safely retry any POST request

Idempotency

Every POST endpoint in the Southbill API accepts an Idempotency-Key header. Use it to safely retry a request after a network error without creating duplicate resources.

How it works

The uniqueness scope is (api_key_id, Idempotency-Key), with method + path folded into the request fingerprint. When the API receives a POST with an Idempotency-Key:

  1. If the key has never been used on this endpoint → the request is processed normally, and its response (status + body) is stored for 24 hours.
  2. If the key was already used with the same request body → the original response is returned verbatim. The endpoint is not re-executed.
  3. If the key was already used with a different request body → the API returns 409 Conflict:
{
  "error": {
    "type": "idempotency_error",
    "code": "idempotency_key_reused",
    "message": "Idempotency-Key reused with a different request body"
  }
}

If a request with the same key is still being processed, the API returns 409 with code = idempotency_in_flight; retry after a short delay to pick up the stored response.

Keys expire after 24 hours. After that, the same key can be reused for a new request.

Choosing a good key

  • Deterministic per business action: order_12345, sub_2026-07-18_001, refund_ch_abc_partial_1.
  • Do not use timestamps or random UUIDs generated per retry — the whole point is that a retry uses the same key.
  • 1–255 characters, ASCII.

Endpoints that support it

Method Path
POST /v1/checkout/sessions
POST /v1/checkout/sessions/{id}/expire
POST /v1/subscriptions
POST /v1/subscriptions/{id}/cancel
POST /v1/products
POST /v1/products/{id}
POST /v1/products/{id}/prices
POST /v1/products/{id}/default_price
POST /v1/refunds

GET and DELETE requests ignore the header — they are already idempotent by definition.

Retries

Combine Idempotency-Key with exponential backoff for 5xx and 429 responses:

async function postWithRetry(url: string, body: unknown, key: string) {
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.SOUTHBILL_SECRET_KEY}`,
        "Content-Type": "application/json",
        "Idempotency-Key": key,
      },
      body: JSON.stringify(body),
    });
    if (res.status < 500 && res.status !== 429) return res;
    const wait = res.headers.get("Retry-After");
    await new Promise(r => setTimeout(r, (wait ? +wait : 2 ** attempt) * 1000));
  }
  throw new Error("Southbill API unavailable");
}

Never retry a 4xx other than 429 — fix the request first.

Node.js SDK

Official TypeScript client with retries, idempotency and webhook verification

The official Node.js SDK wraps the same REST API documented here. Everything is also reachable with plain HTTP — the SDK just adds retries, idempotency keys, auto-pagination and webhook signature verification.

Install

npm install southbill

Package: npmjs.com/package/southbill

Requires Node.js 18 or newer.

Create a checkout session

import { Southbill } from "southbill";

const southbill = new Southbill(process.env.SOUTHBILL_API_KEY);

const session = await southbill.checkout.sessions.create({
  amount: 4900,
  currency: "EUR",
  customer_email: "ada@acme.com",
  success_url: "https://acme.com/thanks",
});

console.log(session.checkout_url);

Available resources

Namespace Methods
checkout.sessions create, retrieve, list, expire
customers create, retrieve, update, list, del
invoices create, retrieve, update, list, send, void, markPaid, listInstallments, listPayments
products create, retrieve, update, list
payments retrieve, list
prices via products: listPrices, createPrice, setDefaultPrice
refunds create, retrieve, list
subscriptions create, retrieve, update, list, cancel
subscriptionLinks create, retrieve, update, list, archive
events retrieve, list, replay
webhookEndpoints create, retrieve, update, del, list, rotateSecret, listDeliveries
balance retrieve, transactions.retrieve, transactions.list

Payouts, bank details, KYC and API-key management are deliberately absent — those actions stay merchant-controlled in the dashboard and are not exposed to any key or app.

Idempotency

Every POST sends an Idempotency-Key automatically. Supply your own so retries across processes collapse into one operation:

await southbill.invoices.create(params, { idempotencyKey: `inv-${orderId}` });

Pagination

for await (const invoice of southbill.invoices.autoPagingEach({ status: "open" })) {
  console.log(invoice.id);
}

Errors

Network failures, 429 and 5xx are retried twice with exponential backoff. Everything else throws a SouthbillError carrying status, type, param and requestId.

import { SouthbillError } from "southbill";

try {
  await southbill.refunds.create({ payment: "pi_123", amount: 500 });
} catch (error) {
  if (error instanceof SouthbillError) {
    console.error(error.status, error.type, error.param, error.requestId);
  }
}

Verify webhooks

Always verify the raw request body — not a re-serialized object.

import express from "express";
import { Southbill, SouthbillSignatureError } from "southbill";

const southbill = new Southbill(process.env.SOUTHBILL_API_KEY);
const app = express();

app.post("/webhooks/southbill", express.raw({ type: "*/*" }), (req, res) => {
  try {
    const event = southbill.webhooks.constructEvent({
      payload: req.body,
      signature: req.header("Southbill-Signature") ?? "",
      secret: process.env.SOUTHBILL_WEBHOOK_SECRET,
    });

    if (event.type === "invoice.paid") {
      // fulfil the order
    }

    res.sendStatus(200);
  } catch (error) {
    if (error instanceof SouthbillSignatureError) return res.sendStatus(400);
    throw error;
  }
});

Configuration

new Southbill({
  apiKey: process.env.SOUTHBILL_API_KEY,
  baseUrl: "https://api.southbill.com",
  timeout: 30_000,
  maxRetries: 2,
});

All merchant API keys are live keys (sk_live_...). Southbill has no test mode; southbill.livemode reports which mode the client runs in.

Python SDK

Official Python client with retries, idempotency and webhook verification

The official Python SDK wraps the same REST API documented here. It has no third-party dependencies (standard library only) and adds retries, idempotency keys, auto-pagination and webhook signature verification.

Install

pip install southbill

Package: pypi.org/project/southbill

Requires Python 3.8 or newer.

Create a checkout session

from southbill import Southbill

southbill = Southbill()  # reads SOUTHBILL_API_KEY

session = southbill.checkout.sessions.create(
    amount=4900,
    currency="EUR",
    customer_email="ada@acme.com",
    success_url="https://acme.com/thanks",
)

print(session["checkout_url"])

All merchant API keys are live keys (sk_live_...). Southbill has no test mode. is False for them.

Resources

Namespace Methods
checkout.sessions create, retrieve, list, expire
customers create, retrieve, update, list, delete
invoices create, retrieve, update, list, send, void, mark_paid, list_installments, list_payments
products create, retrieve, update, list
payments retrieve, list
prices via products: list_prices, create_price, set_default_price
refunds create, retrieve, list
subscriptions create, retrieve, update, list, cancel
subscription_links create, retrieve, update, list, archive
events retrieve, list, replay
webhook_endpoints create, retrieve, update, delete, list, rotate_secret, list_deliveries
balance retrieve, transactions.retrieve, transactions.list

Payouts, bank details, KYC and API-key management stay merchant-controlled in the dashboard and are intentionally not part of the API surface.

Idempotency

Every POST sends an Idempotency-Key header (random UUID). Pass your own for safe retries across processes:

southbill.invoices.create(idempotency_key=f"inv-{order_id}", customer="cus_123")

Pagination

for invoice in southbill.invoices.auto_paging_iter(status="open"):
    print(invoice["id"])

Errors and retries

Network errors, 429 and 5xx are retried twice with exponential backoff (configurable via max_retries). Everything else raises SouthbillError:

from southbill import SouthbillError

try:
    southbill.refunds.create(payment="pi_123", amount=500)
except SouthbillError as error:
    print(error.status, error.type, error.param, error.request_id)

Webhooks

Verify the raw request body — never a re-serialized object.

import os
from flask import Flask, request
from southbill import construct_event, SouthbillSignatureError

app = Flask(__name__)

@app.post("/webhooks/southbill")
def webhook():
    try:
        event = construct_event(
            payload=request.get_data(),
            signature=request.headers.get("Southbill-Signature", ""),
            secret=os.environ["SOUTHBILL_WEBHOOK_SECRET"],
        )
    except SouthbillSignatureError:
        return "", 400

    if event["type"] == "invoice.paid":
        pass  # handle it

    return "", 200

Signature scheme: Southbill-Signature: t=<unix seconds>,v1=<hex> where the hex digest is HMAC-SHA256(secret, "<timestamp>.<raw body>"). Default clock tolerance is 300 seconds.

Configuration

Southbill(
    api_key=os.environ["SOUTHBILL_API_KEY"],
    base_url="https://api.southbill.com",
    timeout=30.0,
    max_retries=2,
)

Checkout Sessions

Create one-off payments programmatically — the only public payments endpoint today.

Checkout Sessions

A Checkout Session is the primary API endpoint for taking a payment. You create it from your server, redirect the customer to checkout_url, and receive a webhook when it's paid.

Base URL: https://api.southbill.com/v1 Auth: Authorization: Bearer sk_live_…

Create a session

POST /v1/checkout/sessions

curl -X POST https://api.southbill.com/v1/checkout/sessions \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order_12345" \
  -d '{
    "amount": 4990,
    "currency": "eur",
    "customer_name":  "Jane Doe",
    "customer_email": "jane@example.com",
    "reference": "ORDER-12345",
    "description": "Order #12345",
    "line_items": [
      { "name": "Sneaker Runner Pro", "quantity": 1, "amount": 4990, "image_url": "https://…/sneaker.jpg", "price_id": "price_1Tu…" }
    ],
    "success_url": "https://shop.example.com/thanks?o=12345",
    "cancel_url":  "https://shop.example.com/cart"
  }'

Body

Field Type Required Notes
amount integer ✅ Minor units (cents). Minimum 250 (= 2.50 in the currency).
currency string ✅ 3-letter ISO, lowercase.
customer_name string ✅ 2–120 chars. Needed to attribute the payment.
customer_email string ✅ Valid email. Receipt is sent here.
mode string ➖ payment (default) or subscription. For subscription, pass line_items[0].price_id pointing at a recurring price and omit amount.
reference string ➖ Your order ID. Echoed on receipts and webhooks.
description string ➖ Short description shown on the checkout.
line_items array ➖ Display only. Fields: name, quantity, amount, image_url, price_id, product_id.
success_url / cancel_url string ➖ Where the browser is sent after the session ends.
metadata object ➖ Free-form key/value returned in webhooks.

line_items[].price_id is optional — pass it when the item comes from a product you created in the dashboard, so it appears in receipts and analytics with its catalog link. The actual amount charged is amount — the price is not fetched from the catalog.

Response

{
  "id": "cs_01H…",
  "object": "checkout.session",
  "mode": "payment",
  "livemode": true,
  "status": "open",
  "amount": 4990,
  "currency": "EUR",
  "client_secret": "cs_01H…_secret_…",
  "checkout_url": "https://payments.southbill.com/c/cs_01H…?cs=…",
  "embed_url":    "https://payments.southbill.com/embed/cs_01H…?cs=…",
  "expires_at": 1735776000,
  "created": 1735689600
}

Redirect the buyer to checkout_url, or mount embed_url in an iframe.

Hosted vs embedded — your choice, every time

Every Checkout Session response returns three integration handles, for one-time and subscription sessions:

  • client_secret — for a fully custom SDK UI.
  • checkout_url — hosted page on payments.southbill.com. Redirect here → the buyer leaves your site.
  • embed_url — the same session, rendered for an iframe. Mount it → the buyer stays on your site.

Southbill does not use a ui_mode parameter. The session is always both hosted and embeddable; you choose at render time:

  • Redirect / hosted: window.location = session.checkout_url
  • Embedded: <iframe src="{{embed_url}}" allow="payment *">, or the SDK: southbill.checkout({ mode: 'embed', mount: '#southbill-checkout' })

This is identical for mode: "subscription" — pass line_items[0].price_id pointing at a recurring price, then mount embed_url to keep the subscription checkout inline. Do not redirect to checkout_url if you want it on-site.

A browser redirect or iframe postMessage is never proof of payment. Always confirm server-side via webhooks (checkout.session.completed, or for subscriptions invoice.paid / subscription.created).

List, retrieve, update & expire

GET   /v1/checkout/sessions            # newest first: ?limit= &starting_after= &status=
GET   /v1/checkout/sessions/{id}
PATCH /v1/checkout/sessions/{id}      # or POST /v1/checkout/sessions/{id}/update
POST  /v1/checkout/sessions/{id}/expire

Update an open session

An open, unconfirmed session (flow version 5+) can be updated instead of recreated — useful when a cart changes quantity. Updatable fields: amount, line_items, description, reference. Requires checkout:write.

curl -X PATCH https://api.southbill.com/v1/checkout/sessions/cs_123 \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"amount": 5900, "reference": "ORD-1042"}'
{ "id": "cs_123", "object": "checkout.session", "status": "open", "flow_version": 5, "revision": 3, "amount": 5900, "currency": "eur" }

Every successful update increments revision. The call returns 409 when the session cannot be changed: immutable_session (flow version below 5), confirmation_started / confirmation_in_progress (a payment is already running) or revision_conflict (changed concurrently — re-read and retry).

Sessions expire automatically 24 hours after creation. An expired session emits checkout.session.expired.

Idempotency

Send Idempotency-Key: <your-key> on every POST. Keys are scoped to (merchant_id, method, path) and expire after 24 hours.

  • Same key + same body → the original response is replayed verbatim (same status, same body).
  • Same key + different body → 409 Conflict with:
{
  "error": {
    "type": "idempotency_error",
    "code": "idempotency_key_reused",
    "message": "Idempotency-Key reused with a different request body"
  }
}

See Error reference for the full model.

Fees

Fees are calculated in EUR on your active plan and deducted from the merchant's balance in the transaction currency (converted at live FX). See Fees, currency & minimum amounts.

Errors

HTTP error.type error.code Meaning
400 invalid_request field-specific Missing / malformed field.
400 amount_too_small amount_below_minimum amount below the 2.50 minimum.
401 authentication_error invalid_api_key Bad, revoked or wrong-mode key.
402 card_error card_declined Buyer's card was declined.
409 idempotency_error idempotency_key_reused Same key sent with a different body.
409 resource_conflict merchant_not_ready Merchant onboarding incomplete.
429 rate_limit_error rate_limited Slow down. Respect Retry-After.

Button label & wallets

The pay button text is not an API field: it comes from a fixed list in Dashboard → Checkout builder (Pay {amount}, Pay securely, Complete purchase, Buy now, Place order, Donate {amount} for one-time; Subscribe, Subscribe now, Start subscription, Pay subscription for subscriptions). Apple Pay / Google Pay are shown on supporting devices for one-time and subscription sessions; for subscriptions the wallet sheet displays the recurring terms and stores the mandate. See Hosted Checkout.

Variants (catalog options)

A line item can reference a product variant instead of a Stripe price. The variant owns its own mirrored price, so the amount is always resolved server-side — a variant checkout can never be mispriced by the caller.

POST /v1/checkout/sessions
{
  "mode": "payment",
  "line_items": [{ "variant": "b0f4…-uuid", "quantity": 2 }],
  "success_url": "https://example.com/thanks"
}

Rules:

  • variant must be a Southbill variant id (uuid) belonging to your merchant account. Mixing variant and price in one session is rejected (400).
  • The variant, its price and its product must all be active, and the price must be one-time. Recurring prices are rejected — use price_id for subscriptions; variants are not supported in mode: "subscription".
  • amount and currency are derived from the variant price. If you send them and they do not match, the request fails with amount_mismatch / currency_mismatch.
  • Each variant may appear only once — use quantity.

Stock-tracked variants

If the variant has inventory tracking enabled, creating the session reserves the units. If the stock is gone the request fails with 409 out_of_stock and no session is created. A stock-tracked variant must be the only line item of the session; for multi-item carts use the Storefront checkout.

The reservation is consumed when the payment succeeds and released automatically when the session is cancelled, fails or expires. A full refund restores the stock.

The resolved variant_id, product_id and sku are returned on the session line items and included in checkout.session.* webhooks.

Declined attempts in sandbox

A declined test card records a failed payment with failure_code and failure_message and emits payment_intent.payment_failed. The hosted Checkout Session remains open for another card attempt; do not fulfil unless checkout.session.completed is received.

Payments

Read every payment — from checkout, invoices, links and plugins — through one endpoint.

Payments

A Payment is a single money movement from a customer to your account. It is created for you when a Checkout Session completes, an invoice is paid or a payment link is used — you never create one directly.

Base URL https://api.southbill.com · Auth Authorization: Bearer sk_live_… · Scope payments:read (read-only — any non-GET returns 405).

Endpoints

Method Path Purpose
GET /v1/payments List payments, newest first.
GET /v1/payments/{id} Retrieve by payment id, payment_intent or charge.

List payments

curl "https://api.southbill.com/v1/payments?limit=20&status=succeeded" \
  -H "Authorization: Bearer $SOUTHBILL_SECRET_KEY"
const payments = await fetch(
  "https://api.southbill.com/v1/payments?limit=20&status=succeeded",
  { headers: { Authorization: `Bearer ${process.env.SOUTHBILL_SECRET_KEY}` } },
).then((r) => r.json());

for (const p of payments.data) {
  console.log(p.id, p.amount, p.currency, p.status);
}
import os, requests

payments = requests.get(
    "https://api.southbill.com/v1/payments",
    headers={"Authorization": f"Bearer {os.environ['SOUTHBILL_SECRET_KEY']}"},
    params={"limit": 20, "status": "succeeded"},
    timeout=30,
).json()
<?php
$ch = curl_init("https://api.southbill.com/v1/payments?limit=20&status=succeeded");
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("SOUTHBILL_SECRET_KEY")],
]);
$payments = json_decode(curl_exec($ch), true);

Response:

{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "cs_01J…",
      "object": "payment",
      "livemode": true,
      "status": "succeeded",
      "amount": 4990,
      "currency": "eur",
      "application_fee_amount": 132,
      "payment_intent": "pi_3P…",
      "charge": "ch_3P…",
      "checkout_session": "cs_01J…",
      "customer": "cus_9f2c…",
      "customer_email": "jane@example.com",
      "customer_name": "Jane Doe",
      "description": "Order 12345",
      "reference": "ORDER-12345",
      "payment_method_type": "card",
      "metadata": {},
      "source": "checkout",
      "created": 1756000000,
      "succeeded_at": 1756000042
    }
  ]
}

Query parameters

Parameter Behaviour
limit 1–100, default 25.
starting_after Payment id, payment_intent or charge — returns rows created before it.
status succeeded (settled) or pending (not completed yet).
customer Customer id.

Status values

Status Meaning
succeeded Captured; your fee is already deducted.
pending Created but not completed — abandoned checkout or async method still clearing.
refunded / partially_refunded See Refunds.
disputed A chargeback was opened. Handle it in the dashboard.

Rate limit: 300 requests/min per key.

Refunds

Refund a full or partial charge — from the dashboard or with the API.

Refunds

Refunds can be issued through the Dashboard → Transactions → Refund action or with the public API.

Endpoints

Method Path Scope Purpose
POST /v1/refunds refunds:write Issue a full or partial refund.
GET /v1/refunds?charge=ch_… refunds:read List refunds for one charge (payment_intent= also accepted).
GET /v1/refunds/{id} refunds:read Retrieve a single refund.

Listing requires a charge or payment_intent filter — refunds are always scoped to a charge that belongs to your account.

How it works

  1. Merchant clicks Refund on a succeeded transaction and enters an amount (full or partial).
  2. Southbill issues a reversal of the destination transfer on the platform account so the money comes out of the merchant's balance, not Southbill's.
  3. Southbill also reverses the pro-rata application fee — you only keep fees for what the buyer actually paid.
  4. Transaction status becomes refunded (full) or partially_refunded (partial).
  5. A checkout.session.refunded webhook fires.

Rules

  • You cannot refund a disputed charge — resolve the dispute first. The API returns 422 invalid_state with code charge_disputed.
  • Refunds can be issued for up to 180 days after the original charge (processor limit).
  • Partial refunds can be issued multiple times up to the total captured amount.
  • Fee reversal is automatic and visible in the merchant's Wallet as Southbill fee refund.

Webhook payload (checkout.session.refunded)

{
  "type": "checkout.session.refunded",
  "data": {
    "object": {
      "charge_id": "ch_…",
      "session_id": "cs_…",
      "amount_refunded": 1990,
      "amount": 4990,
      "currency": "EUR",
      "reason": "requested_by_customer",
      "fully_refunded": false
    }
  }
}

Customers

Create, update and list the customers you bill — the object invoices and payments attach to.

Customers

A Customer stores the buyer identity you reuse across invoices and payments: email, name, company, tax id, address and metadata. Customers are scoped to your merchant account.

Base URL https://api.southbill.com · Auth Authorization: Bearer sk_live_… · Scopes customers:read, customers:write.

Endpoints

Method Path Scope
POST /v1/customers customers:write
GET /v1/customers customers:read
GET /v1/customers/{id} customers:read
POST /v1/customers/{id} customers:write
DELETE /v1/customers/{id} customers:write

Create a customer

curl https://api.southbill.com/v1/customers \
  -H "Authorization: Bearer $SOUTHBILL_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: cus_create_9781" \
  -d '{
    "email": "jane@example.com",
    "name": "Jane Doe",
    "company": "Doe Ltd",
    "tax_id": "GB123456789",
    "address": { "line1": "1 High Street", "postal_code": "EC1A 1BB", "city": "London", "country": "GB" },
    "metadata": { "crm_id": "4711" }
  }'
const res = await fetch("https://api.southbill.com/v1/customers", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SOUTHBILL_SECRET_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "cus_create_9781",
  },
  body: JSON.stringify({
    email: "jane@example.com",
    name: "Jane Doe",
    company: "Doe Ltd",
    metadata: { crm_id: "4711" },
  }),
});
const customer = await res.json();
import os, requests

customer = requests.post(
    "https://api.southbill.com/v1/customers",
    headers={
        "Authorization": f"Bearer {os.environ['SOUTHBILL_SECRET_KEY']}",
        "Idempotency-Key": "cus_create_9781",
    },
    json={"email": "jane@example.com", "name": "Jane Doe", "company": "Doe Ltd"},
    timeout=30,
).json()
<?php
$ch = curl_init("https://api.southbill.com/v1/customers");
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_POST => true,
  CURLOPT_HTTPHEADER => [
    "Authorization: Bearer " . getenv("SOUTHBILL_SECRET_KEY"),
    "Content-Type: application/json",
    "Idempotency-Key: cus_create_9781",
  ],
  CURLOPT_POSTFIELDS => json_encode([
    "email" => "jane@example.com",
    "name"  => "Jane Doe",
  ]),
]);
$customer = json_decode(curl_exec($ch), true);

Response:

{
  "id": "cus_9f2c41a0b7e34d9a8c15be22",
  "object": "customer",
  "livemode": true,
  "email": "jane@example.com",
  "name": "Jane Doe",
  "phone": null,
  "company": "Doe Ltd",
  "tax_id": "GB123456789",
  "address": { "line1": "1 High Street", "postal_code": "EC1A 1BB", "city": "London", "country": "GB" },
  "shipping": {},
  "metadata": { "crm_id": "4711" },
  "deleted": false,
  "created": 1756000000
}

At least one of email or name is required. Emails are unique per merchant and mode — a duplicate returns 409 customer_exists.

List, retrieve, update, delete

# list (newest first, cursor paging)
curl "https://api.southbill.com/v1/customers?limit=25&starting_after=cus_9f2c41a0b7e34d9a8c15be22" \
  -H "Authorization: Bearer $SOUTHBILL_SECRET_KEY"

# filter by email
curl "https://api.southbill.com/v1/customers?email=jane@example.com" \
  -H "Authorization: Bearer $SOUTHBILL_SECRET_KEY"

# update (POST, partial)
curl https://api.southbill.com/v1/customers/cus_9f2c41a0b7e34d9a8c15be22 \
  -H "Authorization: Bearer $SOUTHBILL_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "phone": "+44 20 7946 0958" }'

# delete (soft)
curl -X DELETE https://api.southbill.com/v1/customers/cus_9f2c41a0b7e34d9a8c15be22 \
  -H "Authorization: Bearer $SOUTHBILL_SECRET_KEY"
const list = await fetch("https://api.southbill.com/v1/customers?limit=25", {
  headers: { Authorization: `Bearer ${process.env.SOUTHBILL_SECRET_KEY}` },
}).then((r) => r.json());

await fetch(`https://api.southbill.com/v1/customers/${list.data[0].id}`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SOUTHBILL_SECRET_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ phone: "+44 20 7946 0958" }),
});
import os, requests

h = {"Authorization": f"Bearer {os.environ['SOUTHBILL_SECRET_KEY']}"}
listing = requests.get("https://api.southbill.com/v1/customers", headers=h, params={"limit": 25}).json()
cid = listing["data"][0]["id"]
requests.post(f"https://api.southbill.com/v1/customers/{cid}", headers=h, json={"phone": "+44 20 7946 0958"})
requests.delete(f"https://api.southbill.com/v1/customers/{cid}", headers=h)

Updates are POST (not PUT/PATCH) and partial — only the fields you send change. DELETE is a soft delete: the customer disappears from lists and cannot be attached to new invoices, while existing invoices keep their snapshot.

List shape & paging

{
  "object": "list",
  "has_more": true,
  "data": [ { "id": "cus_…", "object": "customer" } ]
}
Parameter Behaviour
limit 1–100, default 25.
starting_after Customer id — returns rows created before it.
email Exact match, lowercased.

Rate limits: 300 reads/min and 60 writes/min per key. Creates accept Idempotency-Key.

Invoices

Create, send, void and reconcile invoices over the API — or from the dashboard.

Invoices

Invoices can be created in the dashboard (/dashboard/invoices/new) or over the API. Every invoice produces a hosted payment page at https://payments.southbill.com/i/{token} that anyone with the link can pay — no key needed on the customer side.

Base URL https://api.southbill.com · Auth Authorization: Bearer sk_live_… · Scopes invoices:read, invoices:write.

Endpoints

Method Path Scope Purpose
POST /v1/invoices invoices:write Create a draft (or send immediately with auto_send).
GET /v1/invoices invoices:read List invoices — ?status=, ?customer=, ?limit=, ?starting_after=.
GET /v1/invoices/{id} invoices:read Retrieve one invoice incl. lines.
POST /v1/invoices/{id} invoices:write Update a draft invoice.
POST /v1/invoices/{id}/send invoices:write Finalize → open, mint the public token, email the customer.
POST /v1/invoices/{id}/void invoices:write Void an unpaid invoice.
POST /v1/invoices/{id}/mark_paid invoices:write Record an out-of-band payment (bank transfer, cash).
GET /v1/invoices/{id}/installments invoices:read Instalment plan of a partial invoice, ordered by sequence.
GET /v1/invoices/{id}/payments invoices:read Every payment booked against the invoice, oldest first.

Create and send

curl https://api.southbill.com/v1/invoices \
  -H "Authorization: Bearer $SOUTHBILL_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: inv_9781" \
  -d '{
    "customer": "cus_9f2c41a0b7e34d9a8c15be22",
    "currency": "eur",
    "due_date": "2026-09-30",
    "memo": "Thanks for your business.",
    "auto_send": true,
    "line_items": [
      { "description": "Implementation, September", "quantity": 1, "unit_amount": 120000, "tax_rate": 20 },
      { "description": "Support retainer", "quantity": 3, "unit_amount": 15000, "tax_rate": 20 }
    ]
  }'
const invoice = await fetch("https://api.southbill.com/v1/invoices", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SOUTHBILL_SECRET_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "inv_9781",
  },
  body: JSON.stringify({
    customer: "cus_9f2c41a0b7e34d9a8c15be22",
    currency: "eur",
    due_date: "2026-09-30",
    auto_send: true,
    line_items: [
      { description: "Implementation, September", quantity: 1, unit_amount: 120000, tax_rate: 20 },
    ],
  }),
}).then((r) => r.json());

console.log(invoice.hosted_invoice_url);
import os, requests

invoice = requests.post(
    "https://api.southbill.com/v1/invoices",
    headers={
        "Authorization": f"Bearer {os.environ['SOUTHBILL_SECRET_KEY']}",
        "Idempotency-Key": "inv_9781",
    },
    json={
        "customer": "cus_9f2c41a0b7e34d9a8c15be22",
        "currency": "eur",
        "auto_send": True,
        "line_items": [
            {"description": "Implementation, September", "quantity": 1, "unit_amount": 120000, "tax_rate": 20}
        ],
    },
    timeout=30,
).json()

print(invoice["hosted_invoice_url"])
<?php
$ch = curl_init("https://api.southbill.com/v1/invoices");
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_POST => true,
  CURLOPT_HTTPHEADER => [
    "Authorization: Bearer " . getenv("SOUTHBILL_SECRET_KEY"),
    "Content-Type: application/json",
    "Idempotency-Key: inv_9781",
  ],
  CURLOPT_POSTFIELDS => json_encode([
    "customer"   => "cus_9f2c41a0b7e34d9a8c15be22",
    "currency"   => "eur",
    "auto_send"  => true,
    "line_items" => [[
      "description" => "Implementation, September",
      "quantity"    => 1,
      "unit_amount" => 120000,
      "tax_rate"    => 20,
    ]],
  ]),
]);
$invoice = json_decode(curl_exec($ch), true);

Response:

{
  "id": "inv_01J…",
  "object": "invoice",
  "livemode": true,
  "number": "SPK-INV-2026-0184",
  "status": "open",
  "currency": "eur",
  "subtotal": 165000,
  "tax": 33000,
  "total": 198000,
  "customer": "cus_9f2c41a0b7e34d9a8c15be22",
  "customer_email": "jane@example.com",
  "due_date": "2026-09-30",
  "hosted_invoice_url": "https://payments.southbill.com/i/9f14c0…",
  "paid_at": null,
  "created": 1756000000,
  "lines": {
    "object": "list",
    "data": [
      {
        "id": "li_0",
        "object": "invoice_item",
        "description": "Implementation, September",
        "quantity": 1,
        "unit_amount": 120000,
        "tax_rate": 20,
        "amount": 120000
      }
    ]
  }
}

Request fields

Field Required Notes
line_items[] yes 1–200 items. Each needs description and an integer unit_amount in minor units; quantity > 0, tax_rate 0–100.
currency yes ISO 4217, lowercase (eur, gbp, usd).
customer yes* Customer id. Or pass customer_email directly.
customer_email, customer_name, customer_company, customer_tax_id, customer_address no Override the values copied from the customer.
due_date no YYYY-MM-DD.
memo, notes, metadata no Free-form; returned on every read and webhook.
auto_send no true finalizes and emails in the same call.
payment_mode no full (default), partial (instalment plan) or open (customer chooses the amount).
installment_count for partial 2–6 instalments. Each instalment must stay above the currency minimum.
installment_interval for partial weekly, biweekly, monthly or custom.
installment_interval_days for custom 1–365 days between instalments.
first_installment_due_date for partial YYYY-MM-DD, due date of instalment 1.
installment_reminders_enabled no Default true: emails the customer 3 days before and on each due date.
min_payment_amount, max_payment_amount no Bounds per payment in open mode, minor units.
allow_overpayment no open mode only: accept more than the outstanding amount.

Partial payments

A partial invoice is settled by several payments. Southbill keeps amount_paid and amount_due on the invoice, tracks each instalment separately and moves the invoice through open → partially_paid → paid. Read the plan with GET /v1/invoices/{id}/installments:

{
  "object": "list",
  "has_more": false,
  "data": [
    { "id": "…", "sequence": 1, "amount": 66000, "amount_paid": 66000, "currency": "eur", "due_date": "2026-10-01", "status": "paid" },
    { "id": "…", "sequence": 2, "amount": 66000, "amount_paid": 0, "currency": "eur", "due_date": "2026-11-01", "status": "open" }
  ]
}

GET /v1/invoices/{id}/payments returns each booked payment (source: "stripe" for online payments, source: "manual" for payments recorded out of band) with amount, amount_refunded, status and paid_at. A refund on a paid partial invoice moves it back to partially_paid.

Amounts are integers in the smallest currency unit — 120000 = 1 200.00 EUR. Totals are computed server-side from the line items; you cannot set total directly.

Lifecycle

draft ──send──► open ──pays in full──────────────► paid
  │              │
  │              ├──pays part (partial/open)──► partially_paid ──rest paid──► paid
  │              ├──mark_paid──────────────────► paid
  └──void────────┴──void────────────────────────► void

A refund on a paid invoice with an outstanding balance returns it to partially_paid.

Rule Behaviour
Update Only draft invoices — otherwise 400 invoice_not_draft.
Void Never on a paid invoice (400 invoice_paid); blocked while a payment is in flight (409 invoice_payment_in_progress).
mark_paid Idempotent — a paid invoice returns the invoice unchanged; a void invoice returns 400 invoice_void.
Numbering SPK-INV-YYYY-NNNN, with a separate sequence per mode so test traffic never consumes live numbers.
Minimum Total must be ≥ 2.50 in the invoice currency.

Webhooks

Event When
invoice.created Created over the API.
invoice.sent Finalized and emailed to the customer.
invoice.finalized draft → open.
invoice.paid Paid in full by the customer or via mark_paid.
invoice.payment_succeeded A single payment was booked — carries amount_received. Fires for every payment, including the last one.
invoice.partially_paid The invoice moved to partially_paid (part of the total is settled).
invoice.installment.paid An instalment of a partial invoice is fully settled.
invoice.installment.due An instalment is due today.
invoice.installment.overdue An instalment passed its due date unpaid.
invoice.payment_failed Payment attempt failed.
invoice.voided Voided.

Every one of these is also stored in the Events log, so you can replay after downtime.

Rate limits: 300 reads/min and 60 writes/min per key. All writes accept Idempotency-Key.

Subscriptions

Recurring billing on your Southbill account

Subscriptions

Create recurring subscriptions for customers using a recurring price from your catalog. Subscriptions run on your connected Southbill account; Southbill deducts the platform fee automatically from each renewal invoice.

Base URL: https://api.southbill.com/v1 Auth: Authorization: Bearer sk_live_…

Prerequisites

  1. Create a product and a recurring price (see Products and Prices — prices are created with POST /v1/products/{product_id}/prices; there is no top-level /v1/prices endpoint).
  2. The price must have recurring.interval set (day, week, month, year).

Create a subscription

POST /v1/subscriptions

curl -X POST https://api.southbill.com/v1/subscriptions \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: sub_20260718_001" \
  -d '{
    "price_id": "price_1Tu…",
    "customer_name":  "Jane Doe",
    "customer_email": "jane@example.com",
    "quantity": 1,
    "trial_days": 14,
    "metadata": { "plan": "pro" }
  }'

Body

Field Type Required Notes
price_id string ✅ Recurring price from your catalog.
customer string ➖ Existing Southbill customer (cus_…). Name and email are taken from that customer, and the same processor customer is reused.
customer_name string ✅* 2–120 chars. *Not required when customer is set and has a name.
customer_email string ✅* Valid email. *Not required when customer is set and has an email.
quantity integer ➖ Defaults to 1.
trial_days integer ➖ Free trial before first charge.
metadata object ➖ Free-form key/value returned in webhooks.

Response

{
  "id": "sub_1Tu…",
  "object": "subscription",
  "status": "incomplete",
  "customer": "cus_1Tu…",
  "price_id": "price_1Tu…",
  "quantity": 1,
  "currency": "EUR",
  "amount": 1990,
  "current_period_start": 1735689600,
  "current_period_end":   1738368000,
  "latest_invoice": "in_1Tu…",
  "client_secret": "pi_1Tu…_secret_…",
  "trial_end": null,
  "created": 1735689600
}

customer in the response is your Southbill customer id when you passed one; otherwise it is the id created for this subscription. Processor ids are never returned here.

A subscription.created event is emitted immediately when the subscription is created, before the first payment.

Completing an incomplete subscription

The subscription starts as incomplete. There is no /v1/payment_intents endpoint — confirm the returned client_secret in the browser:

<script src="https://southbill.com/southbill.js"></script>
<script>
  const sb = southbill("pk_live_…");
  await sb.confirmPayment({
    client_secret: "pi_1Tu…_secret_…",
    return_url: "https://your-shop.com/thanks"
  });
</script>

intent_type tells you what the secret belongs to: payment (first invoice is charged now) or setup (trial — only the card is stored). After confirmation the status becomes active (or trialing) and subscription.updated plus the invoice.* events fire.

Embedded (on-site) subscription checkout

The endpoint above returns a client_secret for a custom SDK confirmation. If you instead want Southbill's hosted card form rendered inline on your page, create the session through the Checkout Sessions endpoint with mode: "subscription":

curl -X POST https://api.southbill.com/v1/checkout/sessions \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "subscription",
    "line_items": [{ "price_id": "price_1Tu…" }],
    "customer_email": "jane@example.com",
    "success_url": "https://your-shop.com/thanks",
    "cancel_url":  "https://your-shop.com/cart"
  }'

The response returns checkout_url, embed_url and client_secret for the same session. To keep it on your site, mount embed_url in an iframe — or southbill.checkout({ mode: 'embed', mount: '#southbill-checkout' }). To redirect the buyer away, send them to checkout_url.

There is no ui_mode field — every session is always both hosted and embeddable. For subscriptions the behaviour is identical to one-time: redirect = hosted, embed_url = on-site.

Confirm the subscription server-side via the invoice.paid / subscription.created webhook rather than the browser event.

Retrieve

GET /v1/subscriptions/{id}

List

GET /v1/subscriptions?limit=20

Returns up to 100 subscriptions ordered by creation date (newest first).

Cancel

POST /v1/subscriptions/{id}/cancel

curl -X POST https://api.southbill.com/v1/subscriptions/sub_1Tu…/cancel \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: cancel_sub_1Tu_20260718" \
  -d '{ "at_period_end": true }'
Field Default Meaning
at_period_end true Keep active until the current period ends, then cancel.
at_period_end: false — Cancel immediately. No further invoices.

Status model

Status Meaning
incomplete Waiting for the first payment confirmation.
incomplete_expired First payment was not confirmed within 23 h.
trialing Free trial in progress.
active Paid and current.
past_due Renewal failed. Dunning in progress.
unpaid All retries exhausted, subscription frozen.
canceled Terminated.

Fees

Recurring invoices carry a platform fee computed from your plan and payment method. Fees are settled in EUR and deducted per invoice — same rules as one-off Checkout Sessions. See Fees, currency & minimum amounts.

Idempotency

Send Idempotency-Key: <your-key> on POST /v1/subscriptions and POST /v1/subscriptions/{id}/cancel. Keys are scoped to (merchant_id, method, path) and expire after 24 hours.

  • Same key + same body → original response replayed verbatim.
  • Same key + different body → 409 Conflict with type: "idempotency_error", code: "idempotency_key_reused". See Error reference.

Errors

HTTP error.type error.code Meaning
400 invalid_request field-specific Missing / malformed field, or price is not recurring.
401 authentication_error invalid_api_key Bad, revoked or wrong-mode key.
404 not_found price_not_found / subscription_not_found Not owned by your account.
409 idempotency_error idempotency_key_reused Same key with different body.
409 resource_conflict merchant_not_ready / subscription_already_canceled See message.
429 rate_limit_error rate_limited Respect Retry-After.

Webhooks

Listen for these events (see Event reference for payloads):

  • subscription.created / updated / deleted
  • customer.subscription.trial_will_end — fires 3 days before the trial ends
  • invoice.payment_succeeded — renewal charged
  • invoice.payment_failed — dunning stage advanced (notice → warning → final)

Payment links

Reusable hosted payment pages — one link, unlimited buyers.

Payment links

A payment link is a reusable hosted payment page. Create it once, share the URL with as many buyers as you like — every buyer pays separately and each purchase becomes its own payment.

Links are created in the dashboard (Payment links) or over the API. Both paths share the exact same validation, limits and buyer-data rules.

Base URL: https://api.southbill.com/v1 Auth: Authorization: Bearer sk_live_… (or sk_test_… for sandbox links)

Action Scope
Read (GET) payments:read
Write (POST, PATCH) checkout:write

Writes accept Idempotency-Key. Amounts are integers in minor units, currencies lowercase.

The link address

Addresses are always generated by Southbill: /i/ plus 9 random, case-sensitive characters, e.g.

https://payments.southbill.com/i/sNdnaxXi9

You cannot choose or change the code. Amount and currency are always resolved server-side from the stored link — never from the URL.

Endpoints

Method Path Description
GET /v1/payment_links List links (limit <= 100, starting_after, active, archived)
POST /v1/payment_links Create a link
GET /v1/payment_links/:id Retrieve by id or code
POST or PATCH /v1/payment_links/:id Update a link
POST /v1/payment_links/:id/archive Archive (stops accepting buyers)
POST /v1/payment_links/:id/unarchive Restore an archived link
GET /v1/payment_links/:id/payments Purchases made through the link (limit, status)

Create

curl https://api.southbill.com/v1/payment_links \
  -H "Authorization: Bearer sk_live_…" \
  -H "Idempotency-Key: 4f1c9c0a-…" \
  -H "Content-Type: application/json" \
  -d '{
    "pricing_mode": "product",
    "merchant_price_id": "9f0c…",
    "headline": "Summer workshop ticket",
    "cta_label": "book_now",
    "quantity_mode": "customer",
    "max_quantity": 4,
    "max_uses": 50,
    "expires_at": "2026-12-31T23:59:59Z"
  }'

Fields

Pricing

Field Type Notes
pricing_mode product or custom Default product
merchant_price_id (alias price_id) string Required for product. Must be an active, one-off price you own. Recurring prices need a subscription link.
amount_cents integer Required for custom. Minor units, above the currency minimum, max 100,000,000.
currency string Required for custom (lowercase ISO code). For product it is taken from the price.
allow_custom_amount boolean Buyer chooses the amount (donations, pay-what-you-want)
min_amount_cents / max_amount_cents integer or null Only with allow_custom_amount

Quantity

Field Type Notes
quantity_mode fixed or customer Default fixed
fixed_quantity integer 1–10000, default 1
max_quantity integer 1–10000, default 10 (used with customer)

Payment page

Field Type Notes
headline string, max 120
description string, max 600
custom_message string, max 300 Extra note shown to the buyer
reference string, max 80 Internal reference, carried on the payment
cta_label enum pay_now, pay_amount, buy_now, continue_to_payment, complete_purchase, book_now, donate_now, reserve_now
branding_mode company_name or logo
show_product_image boolean Default true
success_url https URL Redirect after payment

Customer details

Name, email, phone and billing address are always collected. They are the signals fraud screening needs, so a link created over the API can never be weaker than one created in the dashboard. Sending collect_phone: false or collect_address: false returns 400.

Field Type Notes
collect_shipping_address boolean Ask for a separate delivery address
collect_note boolean Free-text field for the buyer
note_label string, max 60 Label for that field

Limits & state

Field Type Notes
max_uses integer or null 1–1,000,000 or null for unlimited. Never accepted below the number of completed purchases.
expires_at ISO-8601 or unix seconds, or null Must be in the future
active boolean Pause/resume. Archived links must be unarchived first.

The payment_link object

{
  "id": "7c2a…",
  "object": "payment_link",
  "livemode": true,
  "code": "sNdnaxXi9",
  "url": "https://payments.southbill.com/i/sNdnaxXi9",
  "active": true,
  "archived": false,
  "pricing_mode": "product",
  "merchant_price_id": "9f0c…",
  "product_name": "Summer workshop ticket",
  "amount": 12000,
  "currency": "eur",
  "allow_custom_amount": false,
  "quantity_mode": "customer",
  "fixed_quantity": 1,
  "max_quantity": 4,
  "cta_label": "book_now",
  "collect_phone": true,
  "collect_address": true,
  "max_uses": 50,
  "used_count": 3,
  "remaining_uses": 47,
  "expires_at": "2026-12-31T23:59:59Z",
  "view_count": 214,
  "stats": { "paid_count": 3, "open_count": 1, "volume": { "eur": 36000 } },
  "created": "2026-09-01T10:00:00Z"
}

Purchases

curl "https://api.southbill.com/v1/payment_links/7c2a…/payments?status=completed" \
  -H "Authorization: Bearer sk_live_…"

Each entry is a payment_link_payment with status, amount, currency, quantity, customer_name, customer_email, payment_intent, created and completed_at.

Slots are reserved when a buyer starts checkout and released automatically when they abandon it, so the last seat is never sold twice. Retrieving a link reconciles those reservations first, so counters are always the truth.

Sandbox

Use an sk_test_… key: links, prices, buyers and the payment page all run in the sandbox against test products and test cards, and come back with livemode: false. Test and live links are strictly separated — a test key never sees live links and vice versa. Test links are removed after 30 days or on a sandbox reset, and they do not appear in the dashboard (the dashboard shows live links only).

SDKs

const link = await southbill.paymentLinks.create({ pricing_mode: "custom", amount_cents: 2500, currency: "eur" });
await southbill.paymentLinks.list({ active: true });
await southbill.paymentLinks.payments(link.id);
await southbill.paymentLinks.archive(link.id);
link = southbill.payment_links.create(pricing_mode="custom", amount_cents=2500, currency="eur")
southbill.payment_links.payments(link["id"])
southbill.payment_links.archive(link["id"])

Apps use the same object under /v1/app/payment_links with scopes payments:read / payments:create; the owner always comes from the installation token.

Subscription links

No-code hosted pages for one-off and recurring payments

Looking for reusable one-off payment links (/i/…)? See Payment links.

Payment links & subscription links

Southbill has two kinds of shareable payment pages. Neither requires a website or an integration.

Link What it charges Created via
Invoice payment link (paylink) One-off amount of a single invoice Dashboard → Invoices, or POST /v1/invoices
Subscription link Recurring price, subscribed by anyone who opens it Dashboard → Subscription links, or POST /v1/subscription_links

Base URL: https://api.southbill.com/v1 Auth: Authorization: Bearer sk_live_…

Invoice payment links

Every invoice has a hosted payment page. Create the invoice, then share hosted_invoice_url — there is no separate link object.

curl -X POST https://api.southbill.com/v1/invoices \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "customer_email": "buyer@example.com",
    "currency": "eur",
    "items": [{ "description": "Design retainer", "amount": 45000, "quantity": 1 }]
  }'

The response contains hosted_invoice_url. The page collects the cardholder name and billing address, supports cards, wallets and the local methods enabled on your account, and may request 3-D Secure authentication based on Southbill's risk assessment. See Invoices for line items, due dates, partial payments and refunds.

Subscription links

A subscription link is a hosted page bound to one recurring price. Anyone who opens the URL can subscribe; each subscriber becomes a normal subscription on your account.

Scopes: subscriptions:read to list/retrieve, subscriptions:write to create, update or archive.

Create

POST /v1/subscription_links

curl -X POST https://api.southbill.com/v1/subscription_links \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: sublink_20260920_001" \
  -d '{
    "price_id": "price_1A…",
    "headline": "Pro plan",
    "description": "Everything in Pro, billed monthly.",
    "cta_label": "subscribe_now",
    "collect_address": true
  }'
{
  "id": "…",
  "object": "subscription_link",
  "livemode": true,
  "slug": "pro-plan",
  "url": "https://payments.southbill.com/s/pro-plan",
  "active": true,
  "price_id": "price_1A…",
  "amount": 2900,
  "currency": "eur",
  "interval": "month",
  "interval_count": 1
}

Key fields:

  • price_id — recurring Stripe price ID. Alternatively pass merchant_price_id (the Southbill price UUID).
  • slug — lowercase letters, digits and hyphens, 3–48 chars. Derived from headline when omitted; a taken slug returns 409 slug_taken.
  • quantity_mode — fixed (default, with fixed_quantity) or customer (buyer picks, up to max_quantity).
  • trial_days_override — overrides the trial of the price, max 730 days.
  • collect_address — collect a billing address on the page.
  • cta_label — one of continue_to_payment, subscribe_now, subscribe, pay_subscription_now, subscribe_for_amount, start_free_trial, get_started, join_now, confirm_subscription.
  • branding_mode — company_name or logo; show_product_image, custom_message and success_url (https) control the rest of the page.

List, retrieve, update, archive

# list (optionally filter by active)
curl "https://api.southbill.com/v1/subscription_links?active=true&limit=20" \
  -H "Authorization: Bearer sk_live_…"

# retrieve by id or slug
curl https://api.southbill.com/v1/subscription_links/pro-plan \
  -H "Authorization: Bearer sk_live_…"

# update (same fields as create, all optional; PATCH behaves identically)
curl -X POST https://api.southbill.com/v1/subscription_links/pro-plan \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "headline": "Pro plan (new)" }'

# archive — stops new subscribers, existing subscriptions keep running
curl -X POST https://api.southbill.com/v1/subscription_links/pro-plan/archive \
  -H "Authorization: Bearer sk_live_…"

Links created through the API appear in the dashboard under Subscription links, and vice versa — they are the same objects.

Events

Subscriptions started from a link emit the normal subscription and invoice events: subscription.created, invoice.payment_succeeded, payment_intent.succeeded. See Events & replay.

Sandbox

Subscription links are live-only. With an sk_test_ key the endpoints return an error — test recurring billing with Subscriptions in the sandbox instead.

Products

Create products in the dashboard or via API — they live directly on your Southbill account.

Products

A Product describes something you sell — a shoe, a subscription tier, a service. Products in Southbill are created directly on your Southbill account — either via the dashboard (Dashboard → Products → New) or via the public API described below.

Fields

Field Description
name Public name shown on receipts, invoices and checkout.
description Long description.
images[] Up to 8 image URLs. First image is used as the thumbnail.
tax_code Optional Southbill tax code (txcd_…).
unit_label e.g. "seat", "month".
statement_descriptor What appears on the buyer's card statement (≤ 22 chars).
url Link back to your product page.
shippable Physical goods = true.
package_dimensions Length / width / height (cm) and weight (g).
metadata Free-form key/value.
tags[] southbill-internal — used for catalog auto-grouping.
default_price The Price object linked as the default.

Minimum price

Every unit_amount must be ≥ 250 minor units (2.50) in the price's currency. Smaller amounts don't cover PSP + Southbill fees.


API — Create a product

POST https://api.southbill.com/v1/products

Authenticate with a secret API key (sk_live_…) — scope products:write. Pass an optional Idempotency-Key header to make retries safe.

curl https://api.southbill.com/v1/products \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: prod-launch-sneaker-v1" \
  -d '{
    "name": "Runner 01",
    "description": "Lightweight everyday shoe",
    "images": ["https://cdn.example.com/runner-01.jpg"],
    "shippable": true,
    "metadata": { "sku": "RUN-01" },
    "tags": ["shoes", "new"],
    "price": {
      "currency": "eur",
      "unit_amount": 8900
    }
  }'

Pass a price object to create the Product and its default Price in one call. Omit it if you want to add prices later.

One-time vs recurring prices

A Product itself is just the catalog item. Whether it is charged once or repeatedly is controlled by the attached Price:

  • One-time paid product: send price.currency + price.unit_amount only.
  • Recurring paid product: send the same price fields plus price.recurring.

Recurring price fields:

Field Description
price.recurring.interval Required for recurring prices. Allowed: day, week, month, year.
price.recurring.interval_count Optional. Defaults to 1. Example: 3 + month = every 3 months.
price.recurring.trial_period_days Optional trial length in days.
price.recurring.usage_type Optional: licensed or metered.

Example — monthly recurring product:

curl https://api.southbill.com/v1/products \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: prod-pro-plan-monthly-v1" \
  -d '{
    "name": "Pro Plan",
    "description": "Monthly access to Pro features",
    "metadata": { "sku": "PRO-MONTHLY" },
    "tags": ["subscription", "pro"],
    "price": {
      "currency": "eur",
      "unit_amount": 2900,
      "recurring": {
        "interval": "month",
        "interval_count": 1,
        "trial_period_days": 14
      }
    }
  }'

The response will include a recurring Price with type: "recurring" and the recurring interval fields.

Response

{
  "id": "prod_ABC123",
  "object": "product",
  "name": "Runner 01",
  "active": true,
  "default_price": "price_XYZ789",
  "prices": [{ "id": "price_XYZ789", "currency": "eur", "unit_amount": 8900, ... }],
  "metadata": { "sku": "RUN-01" },
  "tags": ["shoes", "new"]
}

The returned prod_… and price_… IDs are what you use everywhere else — catalog management and invoice product selection. Recurring prices define the billing terms, but automatic subscription billing requires a recurring-billing flow.


API — Other product operations

Method Path Description
GET /v1/products List products (params: limit, starting_after). Scope products:read.
GET /v1/products/{id} Retrieve a product with all its prices.
POST /v1/products/{id} Update fields (name, description, images, active, metadata, tags, …).
DELETE /v1/products/{id} Archive the product and all its prices (active=false).
POST /v1/products/{id}/default_price Body { "price": "price_…" } — change the default price.

API — Prices on a product

Method Path Description
POST /v1/products/{id}/prices Create a new Price on this product. Fields: currency, unit_amount, nickname, recurring, tax_behavior, lookup_key, set_as_default, metadata.
GET /v1/products/{id}/prices List all prices on this product.

Prices are immutable except for active, nickname, tax_behavior, lookup_key, metadata. To change amount or currency, create a new Price and mark the old one inactive.

curl https://api.southbill.com/v1/products/prod_ABC123/prices \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "currency": "eur",
    "unit_amount": 9900,
    "nickname": "2026 launch price",
    "set_as_default": true
  }'

Using a product

  • In an invoice: New invoice → Add from products → the item is locked to the product's Southbill values.
  • In a Checkout Session: pass line_items[].price_id: "price_…" so receipts and analytics link back to the catalog entry.

Product ID vs Price ID

ID What it is When you use it
prod_… The container — name, description, images. Reporting, catalog management.
price_… The billable price — amount, currency, one-time or recurring. Every transaction and invoice line-item.

Rule of thumb: to charge someone you always need a price (or an ad-hoc amount), never just a product.

Idempotency

All POST endpoints (/v1/products, /v1/products/{id}, /v1/products/{id}/prices, /v1/products/{id}/default_price) accept Idempotency-Key. Same key + same body replays the original response; same key + different body returns 409 Conflict with type: "idempotency_error", code: "idempotency_key_reused". See Error reference.

Inline first price

POST /v1/products accepts either price or default_price with the same price object. Creation is atomic: when successful, the response includes the created price ID in default_price and its object in prices.

Prices

One-time and recurring prices for your products.

Prices

A Price represents how much and how often a product costs. Every product needs at least one price to be sellable.

Types

Type recurring Use for
one_time null Physical goods, single services, invoices.
recurring { interval, interval_count } Subscriptions via POST /v1/subscriptions.

Fields

Field Description
unit_amount Amount in minor units (cents). Must be ≥ 250 (= 2.50).
currency 3-letter ISO code, lowercase.
product The parent prod_… ID.
type one_time or recurring.
recurring.interval day, week, month, year.
recurring.interval_count e.g. 3 → every 3 months.
nickname Internal label (e.g. "Pro monthly EUR").
active false archives it — existing subscriptions keep billing.

Currency lock

Once a price exists, its currency is immutable (Southbill rule). To sell in another currency, create a second price for the same product with a different currency.

Recurring prices in invoices

Recurring products are disabled in the invoice UI — invoices are one-shot documents. To bill on a schedule, use the Subscriptions API (POST /v1/subscriptions).

Creating a price

Prices are created automatically when you create a product from the dashboard. To add additional prices to an existing product, use Dashboard → Products → {product} → Add price or the API: POST /v1/products/{product_id}/prices.

Finding your Price ID

Dashboard → Products — the list shows both the Product ID (prod_…) and the Price ID (price_…) with copy buttons.

Route boundary

Prices are only created and listed below their product: POST /v1/products/{product_id}/prices and GET /v1/products/{product_id}/prices. There is no top-level /v1/prices endpoint. Product creation accepts either price or default_price as the inline first-price field; the response contains the resulting ID in default_price and the price object in prices.

Store API — options & variants

Manage your Southbill Store catalogue, option groups, variants, orders and evidence with your secret API key.

Store API — options & variants

Your Southbill Store catalogue is reachable with the same secret API key you use for the rest of the REST API. Base URL:

https://api.southbill.com/v1/store

So GET https://api.southbill.com/v1/store returns the store itself, GET https://api.southbill.com/v1/store/products lists products.

Authenticate with Authorization: Bearer sk_live_… (server-side only — secret keys are rejected from a browser origin). Write calls accept an Idempotency-Key header.

Required scopes: store:read, products:read, products:write, orders:read, orders:write, files:write. Existing keys were upgraded automatically.

Why variants

A product is the catalogue item ("Runner 01"). A variant is the concrete thing a buyer picks — Size M, Colour Black. Variants are built from option groups (Size) and option values (S, M, L). Each variant references exactly one value per option group and may carry its own price, SKU and stock.

Product "Runner 01"
├─ Option group "Size"   → values S, M, L
├─ Option group "Colour" → values Black, White
└─ Variants: S/Black (SKU RUN-S-BLK), M/Black, …

Endpoints

Method Path Description
GET /store The merchant's store (slug, display name, status, logo/cover URLs).
GET /products List products.
GET /products/{id} Product incl. option_groups and variants.
POST /products Create a product.
PATCH /products/{id} Update a product.
DELETE /products/{id} Archive a product.
POST /products/{id}/prices Create a price (min 250 minor units).
GET POST /products/{id}/options List / create option groups.
PATCH DELETE /options/{id} Rename / delete an option group.
POST /options/{id}/values Add a value to a group.
DELETE /values/{id} Delete an option value.
GET POST /products/{id}/variants List / create variants.
PATCH DELETE /variants/{id} Update / delete a variant.
GET /orders, /orders/{id} Store orders (source store only).
PATCH /orders/{id} Fulfillment updates — shipped/completed need evidence.
POST /orders/{id}/evidence Attach delivery evidence.
POST /checkouts Create a Checkout session for a store product/variant.
POST /files Upload product images, documents or evidence (max 5 MB).

Create an option group

curl https://api.southbill.com/v1/store/products/PRODUCT_ID/options \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "name": "Size", "values": ["S", "M", "L"] }'
{
  "object": "option_group",
  "id": "8df0e320-…",
  "product": "019c72f4-…",
  "name": "Size",
  "values": [
    { "object": "option_value", "id": "9f7e71ab-…", "value": "S", "sort_order": 0 },
    { "object": "option_value", "id": "82170ba4-…", "value": "M", "sort_order": 1 }
  ]
}

Create a variant

curl https://api.southbill.com/v1/store/products/PRODUCT_ID/variants \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: variant-run01-s-blk" \
  -d '{
    "options": [{ "group": "8df0e320-…", "value": "9f7e71ab-…" }],
    "price": "6040da6b-…",
    "sku": "RUN-S-BLK",
    "track_inventory": true,
    "stock_qty": 12
  }'

Presentation fields

Variants accept the same presentation fields as the dashboard editor, on both POST /products/{id}/variants and PATCH /variants/{id}:

Field Type Notes
title string ≤80 Variant headline (falls back to the option combination).
description string ≤2000 Variant-specific description.
image_path string ≤300 or null Storage path from POST /v1/store/files.
features string[] Max 12 entries, each ≤120 chars, duplicates dropped.
delivery_days integer 0–3650 or null Delivery time shown in the storefront.
revisions integer 0–3650 or null Included revisions (service products).
highlight boolean Marks the variant as recommended.
sort_order integer Display order.

All of them are returned on every variant object.

Rules enforced by the API:

  • options must contain exactly one value per option group of the product.
  • A value must belong to the group it is sent with (invalid_options, 400).
  • The same combination cannot exist twice (variant_exists, 409).
  • price must be an active price on the same product; omit it to inherit the product's default price.
  • stock_qty is required when track_inventory is true.
  • The option combination is immutable — delete and recreate the variant to change it.

Deleting

Deletion is guarded so your storefront never ends up with dangling variants:

  • DELETE /values/{id} → 409 value_in_use while a variant uses that value.
  • DELETE /options/{id} → 409 variants_exist while the product still has variants.
  • DELETE /variants/{id} works at any time, except for the implicit default variant of a product.

Checkout for a store product

Create a hosted Checkout session for a catalogue product or variant. Price, currency and stock are resolved server-side from your catalogue — you only name the product.

curl https://api.southbill.com/v1/store/checkouts \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: checkout-run01-0001" \
  -d '{
    "product_id": "019c72f4-…",
    "variant_id": "b6f2b0e0-…",
    "quantity": 1,
    "success_url": "https://yourshop.com/thanks",
    "cancel_url": "https://yourshop.com/cart"
  }'
{
  "ok": true,
  "session_id": "cs_...",
  "checkout_url": "https://payments.southbill.com/c/cs_...?cs=...",
  "amount": 4900,
  "currency": "eur",
  "expires_at": "2026-01-01T12:30:00.000Z"
}

Details:

  • Scope: checkout:write. Secret keys only (never from a browser).
  • The store must be live and approved, the product active and store_visible.
  • Stock is reserved for the session and released automatically when it expires (30 min) or is cancelled.
  • Variant price wins, otherwise the product's default price is used. Recurring prices are rejected (unsupported_price).
  • success_url / cancel_url are optional and must be https; they default to your storefront.
  • The session is a normal Checkout session with source = store, so it produces store.* order events, not plain payment.* only.
  • Errors: product_unavailable (404), variant_unavailable (404), price_unavailable (409), out_of_stock (409), amount_too_small (400).

Sandbox

The identical routes exist in the sandbox through an installed app (App API, environment test). Sandbox writes never touch a payment provider — the catalogue, variants and orders live in isolated sandbox tables and are wiped on a sandbox reset.

Catalog: categories & tags

Organise your product catalog with tag-based auto-grouping.

Catalog: categories & tags

Southbill adds a lightweight catalog layer on top of Southbill Products so you can group and filter products without touching Southbill metadata.

Concepts

Concept Where it lives Purpose
Tag merchant_products.tags[] (Southbill DB) Free-form label, e.g. blue, winter, bestseller.
Category merchant_product_categories (Southbill DB) Named bucket with a single tag. Every product carrying that tag is auto-listed under the category.

Example: create a category Shoes with tag = blue. Every product with blue in its tags[] is now listed under Shoes — no manual assignment.

Managing

  • Dashboard → Products → Categories — full CRUD for categories.
  • Dashboard → Products → {product} → Tags — edit the product's tags.

Filtering

The products list has a category dropdown that filters by the category's tag. Pagination kicks in at 20 products.

API

Not exposed publicly. All catalog operations go through the dashboard function merchant-products (list_categories, create_category, update_category, delete_category, update_product_tags).

Events & replay

Every webhook Southbill emits is stored. Query the log and re-deliver anything you missed.

Events & replay

Southbill persists every event it emits for your account — even when no webhook endpoint existed at the time. That makes recovery after an outage a query instead of a support ticket.

Base URL https://api.southbill.com · Auth Authorization: Bearer sk_live_… · Scopes events:read, events:write.

Endpoints

Method Path Scope Purpose
GET /v1/events events:read List events — ?type=, ?created_gte=, ?limit=, ?starting_after=.
GET /v1/events/{id} events:read Retrieve one event.
POST /v1/events/{id}/replay events:write Re-deliver the event to your webhook endpoints.

Catch up after downtime

curl "https://api.southbill.com/v1/events?type=invoice.paid&created_gte=1756000000&limit=100" \
  -H "Authorization: Bearer $SOUTHBILL_SECRET_KEY"
const since = Math.floor(Date.now() / 1000) - 24 * 60 * 60;
const events = await fetch(
  `https://api.southbill.com/v1/events?created_gte=${since}&limit=100`,
  { headers: { Authorization: `Bearer ${process.env.SOUTHBILL_SECRET_KEY}` } },
).then((r) => r.json());

for (const event of events.data) {
  await handle(event); // your own dispatcher, keyed on event.id
}
import os, time, requests

since = int(time.time()) - 24 * 60 * 60
events = requests.get(
    "https://api.southbill.com/v1/events",
    headers={"Authorization": f"Bearer {os.environ['SOUTHBILL_SECRET_KEY']}"},
    params={"created_gte": since, "limit": 100},
    timeout=30,
).json()
<?php
$since = time() - 86400;
$ch = curl_init("https://api.southbill.com/v1/events?created_gte={$since}&limit=100");
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("SOUTHBILL_SECRET_KEY")],
]);
$events = json_decode(curl_exec($ch), true);

The event object mirrors the webhook body:

{
  "id": "evt_01J…",
  "object": "event",
  "livemode": true,
  "type": "invoice.paid",
  "data": { "object": { "id": "inv_01J…", "status": "paid" } },
  "created": 1756000000
}

Replay an event

curl -X POST https://api.southbill.com/v1/events/evt_01J/replay \
  -H "Authorization: Bearer $SOUTHBILL_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: replay_evt_01J" \
  -d '{ "endpoint": "we_01J" }'
await fetch(`https://api.southbill.com/v1/events/${eventId}/replay`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SOUTHBILL_SECRET_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": `replay_${eventId}`,
  },
  body: JSON.stringify({}), // omit "endpoint" to hit every subscribed endpoint
});
requests.post(
    f"https://api.southbill.com/v1/events/{event_id}/replay",
    headers={
        "Authorization": f"Bearer {os.environ['SOUTHBILL_SECRET_KEY']}",
        "Idempotency-Key": f"replay_{event_id}",
    },
    json={},
    timeout=30,
)
{ "id": "evt_01J…", "object": "event", "replayed": true, "endpoints": 2 }
Rule Behaviour
Target endpoint is optional — without it, every enabled endpoint subscribed to that event type receives the delivery.
No match 400 no_endpoint when no enabled endpoint subscribes to the type.
Signature Replays are signed exactly like the original delivery — see Verify signatures.
Idempotency Send an Idempotency-Key so a retried replay request does not double-deliver.

Handlers must stay idempotent: deduplicate on event.id, because a replay reuses the original id.

Rate limits: 300 reads/min, 60 replays/min per key.

Webhook endpoints

Create, update and inspect webhook endpoints with the API.

Webhook endpoints

Everything the dashboard can do with webhook endpoints is available through the API, so you can provision endpoints per environment from your own tooling.

Endpoints

Method Path Scope Purpose
GET /v1/webhook_endpoints webhooks:read List endpoints (newest first).
POST /v1/webhook_endpoints webhooks:write Create an endpoint.
GET /v1/webhook_endpoints/{id} webhooks:read Retrieve one endpoint.
PATCH /v1/webhook_endpoints/{id} webhooks:write Update url, description, events or enabled.
POST /v1/webhook_endpoints/{id} webhooks:write Same as PATCH, for clients without PATCH.
DELETE /v1/webhook_endpoints/{id} webhooks:write Delete an endpoint.
POST /v1/webhook_endpoints/{id}/rotate_secret webhooks:write Rotate the signing secret.
GET /v1/webhook_endpoints/{id}/deliveries webhooks:read Delivery log of one endpoint.

Secret keys only — webhook management is never allowed from the browser.

Create

curl https://api.southbill.com/v1/webhook_endpoints \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/southbill/webhook",
    "description": "Production",
    "enabled_events": ["checkout.session.completed", "invoice.paid"]
  }'
{
  "id": "…",
  "object": "webhook_endpoint",
  "url": "https://example.com/southbill/webhook",
  "enabled": true,
  "enabled_events": ["checkout.session.completed", "invoice.paid"],
  "secret": "whsec_…",
  "secret_last4": "a91f",
  "created": 1767225600
}
  • secret is returned only by create and rotate_secret. Store it — list and retrieve return secret_last4 only.
  • Omit enabled_events (or send ["*"]) to receive every event.
  • Only public https URLs are accepted; loopback and private network addresses are rejected with 400.

Rotating the secret

POST /v1/webhook_endpoints/{id}/rotate_secret returns a new secret and keeps the previous one valid for 24 hours. During that window every delivery is signed with both secrets, so you can deploy the new secret without dropping events. See Verify signatures.

Delivery log

curl "https://api.southbill.com/v1/webhook_endpoints/{id}/deliveries?status=failed&limit=20" \
  -H "Authorization: Bearer sk_live_..."

Each entry contains event, event_type, status (pending, delivered, failed), attempts, response_code, error and next_retry_at. Failed deliveries are retried automatically with backoff; to push an event again yourself use POST /v1/events/{id}/replay.

Rate limits

300 reads/min and 60 writes/min per API key, like the rest of the API.

Balance & ledger

Read your available balance and every entry behind it.

Balance & ledger

Reconcile payouts, fees and refunds without exporting CSVs: the balance endpoints expose the same ledger your wallet is built on.

Endpoints

Method Path Scope Purpose
GET /v1/balance balance:read Available, pending and instantly available amounts per currency.
GET /v1/balance/transactions balance:read Ledger entries, newest first.
GET /v1/balance/transactions/{id} balance:read Retrieve one entry.

Payouts themselves stay dashboard-only — no API key can move money out of a balance.

Balance

curl https://api.southbill.com/v1/balance \
  -H "Authorization: Bearer sk_live_..."
{
  "object": "balance",
  "available": [{ "amount": 128400, "currency": "eur" }],
  "pending": [{ "amount": 24990, "currency": "eur" }],
  "instant_available": [{ "amount": 90000, "currency": "eur" }]
}

Each list holds one entry per currency; amounts are integers in the smallest currency unit.

Ledger entries

curl "https://api.southbill.com/v1/balance/transactions?type=charge&created_gte=1767225600&limit=50" \
  -H "Authorization: Bearer sk_live_..."
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "txn_…",
      "object": "balance_transaction",
      "type": "charge",
      "source": "ch_…",
      "amount": 4990,
      "fee": 174,
      "net": 4816,
      "currency": "eur",
      "status": "available",
      "available_on": 1767398400,
      "created": 1767225600
    }
  ]
}

Filters: type, currency, created_gte, created_lte, plus limit (1–100, default 25) and starting_after cursor pagination.

Reading the numbers

  • amount is gross, fee is what was deducted, net is what hits the balance — reconcile on net.
  • source points at the object that created the entry (ch_…, re_…, po_…), so you can join entries back to payments, refunds and payouts.
  • available_on is when a pending entry becomes withdrawable.
  • exchange_rate is set on cross-currency entries.
  • Refunds and fee reversals appear as their own negative entries — a refunded payment keeps its original charge entry.