What 'API-first checkout' means here
A hosted-page-first integration assumes a human clicks a button and lands on a checkout you didn't build. That is fine for a storefront. It is the wrong default for a product where a cron job decides what an account owes, a usage-rollup worker closes a billing period, or a self-serve upgrade flow needs to charge inside your own UI without a redirect feeling like a context switch.
API-first means your code is in charge of the checkout. You create the invoice when your billing logic says to, you decide where the customer pays it, and you treat the confirmation as an event your backend consumes — not as a redirect you hope completes. halfin exposes exactly the primitives that lets you do that, and nothing that forces a hosted page into the middle of it.
There are three pieces to wire: the create call (REST or SDK), the payment surface (hosted or self-hosted), and the webhook that tells you it confirmed. The rest of this page is those three pieces and the idempotency discipline that keeps them safe under retries.
- Your billing logic decides when to create an invoice — not a button on a page.
- You choose the payment surface per flow: hosted redirect or self-hosted in your own UI.
- Entitlement follows the signed webhook, not the redirect — the redirect can be lost.
- Idempotency keys make every create call safe to retry from a queue.
Create the invoice from your backend
Invoicing is spec-first REST at api.thehalfin.com/api/v1. You authenticate every merchant request with an X-API-Key header, post the amount and currency, and get back an invoice object with an identifier and a state you can track. Amounts are sent as strings — monetary values are strings end to end, never floats — and the currency is your fiat anchor when you bill fiat-anchored.
Most SaaS billing is fiat-anchored: you price a plan in USD or EUR, and halfin quotes the equivalent in the assets you accept and locks that rate when the invoice activates. The request below creates one invoice for an account's billing cycle. The same call serves a cron-driven renewal, a usage-rollup worker, and a self-serve upgrade button — there is no separate 'recurring' or 'self-serve' endpoint, just the invoicing primitive called when your code decides.
The idempotency_key field is the part that makes this safe to call from a retry-prone client. Set a value derived from your own identifier — the account plus the cycle, here — and a repeat call with the same body returns the existing invoice instead of issuing a second one. A replay with a different body is rejected as an idempotency_key_mismatch rather than quietly doing the wrong thing.
# Fiat-anchored invoice for one billing cycle, created from your backend.
# idempotency_key is scoped to the cycle, so a retried job returns the
# existing invoice instead of billing the account twice.
curl -X POST https://api.thehalfin.com/api/v1/invoices \
-H "X-API-Key: $HALFIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount_fiat": "49.00",
"fiat_currency": "USD",
"description": "Pro plan — acct_42 2026-06",
"external_id": "acct_42",
"idempotency_key": "acct_42-2026-06"
}'
# The response carries the invoice id and its state. From here you either
# redirect to the returned hosted-checkout URL or render your own
# self-hosted checkout against the same invoice. See docs.thehalfin.com
# for the full request and response schema.The typed SDK keeps request shapes honest
If your backend is TypeScript, @halfin/sdk-merchant saves you from hand-writing request bodies and re-checking the spec for field names. It is generated from the same OpenAPI definition as the server, so the types you import are the types the API enforces — your editor autocompletes the body, flags a wrong field at compile time, and types the response so you are not casting JSON by hand.
The SDK is a thin client over the same REST endpoints; it does not hide the API or add behavior, so anything in the docs maps directly to a call. You build a client once and pass it to the operation functions, which take the same body fields you would send over raw HTTP — idempotency_key included. When the spec gains a field, regenerating the SDK brings it into your types; a removed field shows up as a type error rather than a runtime surprise.
import { createHalfin, createInvoice } from "@halfin/sdk-merchant";
const client = createHalfin({ apiKey: process.env.HALFIN_API_KEY });
const { data, error } = await createInvoice({
client,
body: {
amount_fiat: "49.00",
fiat_currency: "USD",
description: "Pro plan — acct_42 2026-06",
external_id: "acct_42",
idempotency_key: "acct_42-2026-06",
},
});
if (error) throw error;
// data.id is the invoice; data.status is its current state.
// Redirect to the hosted checkout URL or render self-hosted.Hosted or self-hosted: choosing the payment surface
Once the invoice exists, the customer has to pay it somewhere. halfin gives you two surfaces against the same invoice object, and an API-first product often uses both depending on the flow.
Hosted checkout is the smallest integration: create the invoice and redirect the customer to the returned checkout.thehalfin.com URL. halfin renders the address, amount, network, and countdown, watches the chain, and confirms the payment. It is the right default for an email payment link, a one-off upgrade, or anywhere you do not need the payment UI inside your own product.
Self-hosted checkout renders against the same API so the payment experience lives entirely inside your product on your own domain — no redirect, no halfin chrome. It costs more integration work because you own the UI, but it keeps a self-serve upgrade or an embedded billing screen feeling native. Either way, your server key never reaches the browser: the customer-facing side uses a short-lived token tied to that single invoice.
| Surface | Where the payment UI lives | Best fit |
|---|---|---|
| Hosted checkout | checkout.thehalfin.com, rendered by halfin | Email payment links, one-off upgrades, smallest integration |
| Self-hosted checkout | Inside your product, on your own domain | Embedded self-serve upgrade, billing screen that must feel native |
| Either surface | Same invoice object, same REST API behind it | Server key stays server-side; the browser gets a per-invoice token |
Entitlement follows the webhook, not the redirect
The redirect back from checkout is a convenience for the human, not a source of truth for your backend. A customer can close the tab, lose connectivity, or pay from a different device than the one that opened the link. If you grant access on the redirect, you grant it on a signal you cannot trust.
The authoritative signal is the webhook. When the invoice resolves, halfin sends an HMAC-signed event to your endpoint. Verify the signature first — that verification is the line between extending an account correctly and extending it on a forged request — then act on the event: invoice.paid extends the entitlement, invoice.expired hands off to your dunning flow, and invoice.underpaid or invoice.overpaid surface a mismatch your billing logic resolves instead of silently granting a full period.
Because invoices carry your external_id, the webhook maps straight back to the account it belongs to. Your handler verifies, looks up the account, updates entitlement, and schedules the next cycle if you bill recurring. No polling loop, no trusting a redirect — just a signed event your backend consumes.
- invoice.confirming — the invoice is live, rate locked, awaiting payment.
- invoice.paid — confirmations met the per-chain threshold; extend entitlement.
- invoice.underpaid / invoice.overpaid — a mismatch your logic resolves, not a silent grant.
- invoice.expired — non-payment; hand off to dunning (reminder, grace, downgrade).
- Always verify the HMAC signature before taking any business action on an event.
Idempotency across the whole billing job
API-first billing runs from schedulers, queues, and webhook handlers — all of which retry. Idempotency is what keeps those retries from compounding into double charges, and halfin's model is the same field on every state-changing call.
On invoice creation, idempotency_key scoped to the account-and-cycle means a retried billing job returns the existing invoice. When you pay out — developer revenue shares, affiliate commissions, partner settlements — each payout line carries its own idempotency_key, so resubmitting a payout run after a network blip settles each recipient exactly once. The discipline is identical on both directions of money movement: derive the key from your own stable identifier, reuse it on retry, and the platform deduplicates for you.
Treat the key as part of the request's identity, not an afterthought. A key tied to the cycle (acct_42-2026-06) survives a job restart; a key tied to wall-clock time does not. Get this right once in your billing and payout code and the rest of the integration tolerates the retries that distributed systems guarantee you will hit.