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.
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 });
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:
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.
If the key was already used with the same request body → the original response is returned verbatim. The endpoint is not re-executed.
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:
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.
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:
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.
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.
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.
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_…
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.
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.
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"
}
}
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.
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.
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).
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.
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.
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.
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:
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.
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
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).
The price must have recurring.interval set (day, week, month, year).
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:
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":
The response returns checkout_url, embed_urlandclient_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 noui_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).
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.
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)
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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).
GETPOST
/products/{id}/options
List / create option groups.
PATCHDELETE
/options/{id}
Rename / delete an option group.
POST
/options/{id}/values
Add a value to a group.
DELETE
/values/{id}
Delete an option value.
GETPOST
/products/{id}/variants
List / create variants.
PATCHDELETE
/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).
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.
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.
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).
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.
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.
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.
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.