Quickstart
Take a stablecoin payment for one of your orders, and credit the customer when it lands. Three steps: create an invoice, receive the webhook, credit the customer.
The examples are plain HTTP: curl, and TypeScript using the fetch built into Node 18 and newer. No library to install, and any language works the same way.
The three TypeScript blocks below are run by a test on every change to Settlane. The test runs them against a real Settlane server over a real socket, so if a documented call stops working, the build stops. The curl lines and the sentences around them are ordinary text.
Before you start
- An API key. Ask support@originstats.com. A key starts
sk_live_orsk_test_. Keep it on your server, never in a browser or an app. - What a test key can do today. A
sk_test_key can read, create and update customers, list assets, and send you a test webhook withPOST /v1/webhook_endpoints/{id}/test. It cannot create an invoice or a payout yet: production has no test-mode wallets, soPOST /v1/invoicesanswers409 CONFLICT, and a test identity check stayspending. Build and rehearse with a live key and small amounts (one US dollar is enough). - A webhook endpoint, with its signing secret, created with
POST /v1/webhook_endpoints. That call needs your live key: an endpoint has no mode of its own, so creating one is creating a destination for real money events. Test-mode events go to the same endpoint carrying"livemode": false.
1. Call the API
One small helper for every call. It sends the key, sends JSON, and turns an error answer into an exception you can branch on.
export interface SettlaneConfig {
readonly apiKey: string;
/** Leave it out for https://api.settlane.com. */
readonly baseUrl?: string;
}
/** Every error has a stable `code`. Branch on the code, never on the message. */
export class SettlaneApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly param: string | undefined,
/** The `Settlane-Request-Id` header. Quote it when you ask for help. */
readonly requestId: string | undefined,
) {
super(message);
this.name = "SettlaneApiError";
}
}
export async function settlaneRequest<T>(
config: SettlaneConfig,
method: "GET" | "POST" | "PATCH" | "DELETE",
path: string,
options: { readonly body?: unknown; readonly idempotencyKey?: string } = {},
): Promise<T> {
// Fail at startup, not on your first customer, when the key is missing from the environment.
if (config.apiKey === "") throw new Error("SETTLANE_API_KEY is empty");
const headers: Record<string, string> = { Authorization: `Bearer ${config.apiKey}` };
if (options.body !== undefined) headers["Content-Type"] = "application/json";
if (options.idempotencyKey !== undefined) headers["Idempotency-Key"] = options.idempotencyKey;
const base = (config.baseUrl ?? "https://api.settlane.com").replace(/\/$/, "");
const response = await fetch(`${base}${path}`, {
method,
headers,
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
});
const text = await response.text();
const json: unknown = text === "" ? null : JSON.parse(text);
if (response.ok) return json as T;
const error = (json as { error?: { code?: string; message?: string; param?: string } } | null)?.error;
throw new SettlaneApiError(
response.status,
error?.code ?? "UNKNOWN",
error?.message ?? `HTTP ${String(response.status)}`,
error?.param,
response.headers.get("settlane-request-id") ?? undefined,
);
}
In production that is settlaneRequest({ apiKey: process.env.SETTLANE_API_KEY ?? "" }, …).
Retries. RATE_LIMITED (429) carries a Retry-After header in seconds: wait that long, then try again. Never retry a create on a timeout without sending the same Idempotency-Key again, or you may create a second object.
2. Create an invoice
An invoice is one order. It carries the amount in US cents, the exact token amount the customer must send, and a deposit address that belongs to this invoice for ever.
With curl:
curl https://api.settlane.com/v1/invoices \
-H "Authorization: Bearer $SETTLANE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042" \
-d '{"asset_id":"usdc_eth","amount_cents":2500,"reference":"order-1042","metadata":{"order_id":"1042"}}'
The same call in code:
/** What your own order record needs to keep from an invoice. */
export interface CheckoutHandoff {
readonly invoiceId: string;
/** The address the customer sends to. Reserved for this invoice, permanently. */
readonly payTo: string;
/** The exact token amount, in base units, as a decimal string. */
readonly tokenBaseUnits: string;
/** An EIP-681 URI a wallet can open directly. */
readonly paymentUri: string;
readonly expiresAt: string;
}
/**
* Create an invoice for one of your orders.
*
* THE IDEMPOTENCY KEY IS DERIVED FROM YOUR ORDER ID, NOT GENERATED. `POST /v1/invoices` requires one.
* A call that times out and is sent again a minute later with a NEW key would create a second invoice,
* with a second address, for one order. With a key derived from the order id, the retry returns the
* first invoice.
*/
export async function createCheckout(
config: SettlaneConfig,
order: { readonly id: string; readonly amountCents: number },
): Promise<CheckoutHandoff> {
const invoice = await settlaneRequest<{
id: string;
deposit_address: string;
token_base_units: string;
payment_uri: string;
expires_at: string;
}>(config, "POST", "/v1/invoices", {
body: {
asset_id: "usdc_eth",
amount_cents: order.amountCents,
reference: order.id,
metadata: { order_id: order.id },
},
idempotencyKey: `order-${order.id}`,
});
return {
invoiceId: invoice.id,
payTo: invoice.deposit_address,
tokenBaseUnits: invoice.token_base_units,
paymentUri: invoice.payment_uri,
expiresAt: invoice.expires_at,
};
}
/**
* The hosted payment page for an invoice: a screen you do not have to build. It shows the amount in
* both dollars and tokens, the address as text and as a QR code, a countdown to expiry, and the status
* moving to `paid` without a reload. It is served from the API host.
*/
export function hostedPageUrl(apiBaseUrl: string, invoiceId: string): string {
return `${apiBaseUrl.replace(/\/$/, "")}/pay/${invoiceId}`;
}
Three things about that response worth knowing before you build a screen around it:
token_base_unitsis a decimal string, not a number. A token amount in base units does not fit a JavaScript number. Parse it withBigInt.amount_centsis a string too, but cents are capped at 1,000,000,000 by the API, soNumber(...)is safe on that one.- Show the address and the amount together. Paying the right address with the wrong amount is an underpayment, not a payment, and the invoice stays open.
expires_atis not the end of the story. A payment that confirms after it, but inside the late window, makes the invoicepaid_laterather than losing the customer's money. See the invoice lifecycle below.
GET /v1/assets lists the assets your key's mode can invoice in, each with its chain, its confirmation rule and how long a payment typically takes to arrive. Render that rather than hard-coding usdc_eth as this example does.
3. Receive the webhook and credit the customer
Settlane sends invoice.paid when a payment settles in full. Verify the signature before you trust a byte of it, and verify it against the RAW request body: a re-serialised JSON object is a different string and will not match. The webhook reference explains each step of the check below.
import { createHmac, timingSafeEqual } from "node:crypto";
export type SignatureVerdict =
| { readonly ok: true }
| { readonly ok: false; readonly reason: "missing" | "malformed" | "stale" | "bad_signature" };
const HEX_DIGEST = /^[0-9a-fA-F]{64}$/;
/** Check a `Settlane-Signature` header against the raw body. Uses only `node:crypto`. */
export function verifySettlaneSignature(
rawBody: string,
header: string | undefined,
secret: string,
nowSeconds: number = Math.floor(Date.now() / 1000),
toleranceSeconds = 300,
): SignatureVerdict {
if (header === undefined || header.trim() === "") return { ok: false, reason: "missing" };
// `t=<unix seconds>,v1=<hex>[,v1=<hex>...]`. Split on commas, then each part on its first `=`.
let timestamp: string | undefined;
const signatures: string[] = [];
for (const part of header.split(",")) {
const at = part.indexOf("=");
if (at === -1) continue;
const key = part.slice(0, at).trim();
const value = part.slice(at + 1).trim();
if (key === "t") {
if (timestamp !== undefined) return { ok: false, reason: "malformed" }; // two `t`: refuse
timestamp = value;
} else if (key === "v1") {
signatures.push(value);
}
}
if (timestamp === undefined || signatures.length === 0) return { ok: false, reason: "malformed" };
if (!/^\d+$/.test(timestamp) || !Number.isSafeInteger(Number(timestamp))) return { ok: false, reason: "malformed" };
if (!signatures.every((s) => HEX_DIGEST.test(s))) return { ok: false, reason: "malformed" };
if (Math.abs(nowSeconds - Number(timestamp)) > toleranceSeconds) return { ok: false, reason: "stale" };
// signed = t + "." + rawBody, HMAC-SHA256 with the endpoint secret, compared in constant time.
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`, "utf8").digest();
const matched = signatures.some((s) => timingSafeEqual(Buffer.from(s, "hex"), expected));
return matched ? { ok: true } : { ok: false, reason: "bad_signature" };
}
export interface CreditedOrder {
readonly orderId: string;
readonly invoiceId: string;
readonly amountCents: number;
/** `true` when the payment confirmed after `expires_at` but inside the late window. */
readonly late: boolean;
/** `false` for a test-mode event. Do NOT credit a real customer on one. */
readonly livemode: boolean;
}
export interface WebhookDeps {
/** Your own side: mark the order paid. */
readonly credit: (order: CreditedOrder) => Promise<void>;
/** Your own side: has this event id already been handled? */
readonly alreadyHandled: (eventId: string) => Promise<boolean>;
}
export interface WebhookResult {
readonly status: number;
readonly body: string;
}
/**
* Handle one delivery. Answer 2xx and Settlane stops retrying; answer anything else and it retries on
* a backoff ladder.
*
* BE IDEMPOTENT ON `event.id`. A delivery you answered slowly, or a 500 you recovered from, arrives
* again (that is the retry working, not a bug), so the same `invoice.paid` can reach you more than
* once. `event.id` is the same on every attempt.
*/
export async function handleSettlaneWebhook(
rawBody: string,
signatureHeader: string | undefined,
endpointSecret: string,
deps: WebhookDeps,
): Promise<WebhookResult> {
const verdict = verifySettlaneSignature(rawBody, signatureHeader, endpointSecret);
// A refusal is a 400, never a 500: retrying will not make a bad signature good, and a 5xx would make
// Settlane retry for a day over your own configuration. `missing` and `malformed` are wiring;
// `stale` and `bad_signature` are not.
if (!verdict.ok) return { status: 400, body: verdict.reason };
const event = JSON.parse(rawBody) as {
id: string;
type: string;
livemode: boolean;
payload: unknown;
};
if (await deps.alreadyHandled(event.id)) return { status: 200, body: "duplicate" };
if (event.type === "invoice.paid" || event.type === "invoice.paid_late") {
// The payload of a paid invoice event is the invoice as `GET /v1/invoices` lists it (without the
// payment details that only `GET /v1/invoices/{id}` adds). Read only the fields you use.
const invoice = event.payload as { id: string; reference: string | null; amount_cents: string };
await deps.credit({
orderId: invoice.reference ?? invoice.id,
invoiceId: invoice.id,
amountCents: Number(invoice.amount_cents),
late: event.type === "invoice.paid_late",
livemode: event.livemode,
});
}
return { status: 200, body: "ok" };
}
Wiring that into a server means getting the raw body, which most frameworks have already parsed away from you:
| Framework | What you need |
|---|---|
| Express | express.raw({ type: "application/json" }), then req.body.toString("utf8") |
| Fastify | a raw-body plugin, or addContentTypeParser("application/json", { parseAs: "string" }, …) |
| Next.js route handler | await request.text(), and do not call request.json() first |
The header to read is Settlane-Signature. There are two more you may find useful in a log line: Settlane-Event-Id and Settlane-Event-Type.
The invoice lifecycle
| Status | Terminal? | Money can still be credited? | What it means |
|---|---|---|---|
pending | no | yes | Created; nothing has arrived at the deposit address yet. |
detected | no | yes | A payment has been seen on the chain and is not yet settled. Money is NOT credited here. |
confirming | no | yes | Seen and waiting for the chain's confirmation rule. Still not credited. |
underpaid | no | yes | Money has been credited but it is less than the amount owed, beyond the tolerance. |
paid | yes | no | Settled in full, at or before expires_at. This is the one to ship on. |
paid_late | yes | no | Settled in full, but the confirmed transfer's block time was after expires_at. |
expired | no | yes | Past expires_at with nothing credited. A payment inside the late window can still make it paid_late. |
cancelled | yes | no | Cancelled by the merchant. Money that arrives afterwards is recorded and reported, never credited. |
Credit the customer on invoice.paid, never on invoice.payment_detected. A detected payment has been seen on the chain and is not settled; the chain can still take it back. Settlane credits nothing at that point either, and received_base_units stays at zero.
invoice.settled_payment_reorged is the one that will surprise you. If a chain reverses a payment Settlane had already confirmed, the invoice stays paid — a merchant told paid is never told unpaid — and this event is the only thing that tells you the money is not there. You may already have shipped. Handle it, even if handling it means paging a human.
What to do next
- The webhook reference: the signature algorithm step by step, with a worked example, and every event type.
- Payouts: sending money out.
- Errors: every code, its status, and what to do about it.
- The API reference: every route, with examples, and the OpenAPI file to download.
- Before you go live: the checklist.