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

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:

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:

FrameworkWhat you need
Expressexpress.raw({ type: "application/json" }), then req.body.toString("utf8")
Fastifya raw-body plugin, or addContentTypeParser("application/json", { parseAs: "string" }, …)
Next.js route handlerawait 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

StatusTerminal?Money can still be credited?What it means
pendingnoyesCreated; nothing has arrived at the deposit address yet.
detectednoyesA payment has been seen on the chain and is not yet settled. Money is NOT credited here.
confirmingnoyesSeen and waiting for the chain's confirmation rule. Still not credited.
underpaidnoyesMoney has been credited but it is less than the amount owed, beyond the tolerance.
paidyesnoSettled in full, at or before expires_at. This is the one to ship on.
paid_lateyesnoSettled in full, but the confirmed transfer's block time was after expires_at.
expirednoyesPast expires_at with nothing credited. A payment inside the late window can still make it paid_late.
cancelledyesnoCancelled 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