Developer documentation
Checkout Gateway API
A REST API over HTTPS with JSON bodies and HMAC-signed requests. Base URL: https://gateway-production-319f.up.railway.app. Field names are camelCase and enum values are strings.
Quickstart
- Sign up and create your business (organization). You start in test mode.
- In the dashboard, go to API keys and create a Test key. Copy the secret straight away: it is shown once.
- Optional: under Webhooks, add your HTTPS endpoint and store its signing secret.
- Do the rest through the API: add a payment method, create invoices or payment links, and share their
url.
import { createHash, createHmac, randomUUID } from "node:crypto";
const BASE = "https://gateway-production-319f.up.railway.app";
const KEY_ID = process.env.CHECKOUT_KEY_ID; // pk_test_...
const SECRET = process.env.CHECKOUT_SECRET; // sk_test_...
async function api(method, path, body) {
const raw = body === undefined ? "" : JSON.stringify(body);
const ts = Math.floor(Date.now() / 1000).toString();
const nonce = randomUUID();
const bodyHash = createHash("sha256").update(raw).digest("base64");
const signature = createHmac("sha256", SECRET)
.update([method, path, ts, nonce, bodyHash].join("\n"))
.digest("base64");
const res = await fetch(BASE + path, {
method,
headers: {
"X-Api-Key": KEY_ID, "X-Timestamp": ts, "X-Nonce": nonce,
"X-Body-Hash": bodyHash, "X-Signature": signature,
"Content-Type": "application/json", "Idempotency-Key": randomUUID(),
},
body: body === undefined ? undefined : raw,
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));
return res.json();
}
await api("POST", "/v1/payment-instruments", {
method: "Crypto", currency: "USDT", network: "TRC20",
walletAddress: "T...your Nile testnet wallet", custodian: "Dfns",
});
const invoice = await api("POST", "/v1/invoices", {
customerName: "Globex", customerEmail: "ap@globex.example", currency: "USD",
lineItems: [{ description: "Consulting", quantity: 2, unitAmount: 100 }],
});
console.log(invoice.url); // send this to your customerThe repository includes scripts/test-mode-e2e.mjs, which runs the whole flow: payment method → link → invoice → simulated payment → invoice paid.
Concepts
- Payment method (
pi_…): where customers send money, either your Fireblocks/DFNS wallet or your bank account. All checkouts share these. There are no per-invoice addresses, so amounts are exact. - Invoice (
inv_…): line items, customer and due date, with a hosted page athttps://gateway-production-319f.up.railway.app/i/<slug>. - Payment link (
plink_…): a hosted page athttps://gateway-production-319f.up.railway.app/l/<slug>. Reusable (each visitor gets their own checkout) or single-use. - Checkout session (
cs_…): one payable attempt. Invoices and links open sessions automatically. You can also create sessions directly for invoices in your own system. - Declaration (
pd_…): what the payer submits, such as a tx hash, or a payer name, last 4 digits and receipt. This is never proof on its own. - Payment (
pay_…): money that was confirmed. Only confirmed payments move a session toPaid.
Authentication
Every request to /v1/* is signed with your API key secret. Send these headers:
| X-Api-Key | Your key id (pk_test_… / pk_live_…) |
| X-Timestamp | Unix time in seconds. Must be within ±5 minutes of server time. |
| X-Nonce | A unique value per request (e.g. a UUID). Replays are rejected. |
| X-Body-Hash | base64(SHA-256(raw body bytes)). Hash an empty string for GET requests. |
| X-Signature | base64(HMAC-SHA256(secret, string-to-sign)) |
POST
/v1/invoices?optional=query
1759312800
3f1c7a52-6a1e-4c69-9f53-0b1f0c6f2a11
47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=Building in .NET? See C# / ASP.NET Core for a ready-to-use handler, client and webhook controller.
using System.Security.Cryptography;
using System.Text;
static HttpRequestMessage Sign(HttpMethod method, string pathAndQuery, string? json, string keyId, string secret)
{
var body = Encoding.UTF8.GetBytes(json ?? "");
var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var nonce = Guid.NewGuid().ToString("N");
var bodyHash = Convert.ToBase64String(SHA256.HashData(body));
var toSign = $"{method.Method}\n{pathAndQuery}\n{ts}\n{nonce}\n{bodyHash}";
var sig = Convert.ToBase64String(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(toSign)));
var req = new HttpRequestMessage(method, pathAndQuery);
if (json is not null) req.Content = new ByteArrayContent(body) { Headers = { ContentType = new("application/json") } };
req.Headers.Add("X-Api-Key", keyId);
req.Headers.Add("X-Timestamp", ts);
req.Headers.Add("X-Nonce", nonce);
req.Headers.Add("X-Body-Hash", bodyHash);
req.Headers.Add("X-Signature", sig);
return req;
}- Sign the exact bytes you send. Re-serializing JSON after signing breaks the signature.
- Keys can be limited to your server IPs and given an expiry. Revoking a key in the dashboard takes effect immediately.
- Send an
Idempotency-Keyheader on create calls. Retrying with the same key and body returns the original object. Reusing a key with a different body returns409. - Keep secrets on your servers. Never put them in browsers or mobile apps.
C# / ASP.NET Core
A complete integration for a .NET 8 Web API. It has a DelegatingHandler that signs every request, a typed HttpClient, a sample controller that creates invoices, and a webhook receiver that verifies signatures. No NuGet packages are needed beyond ASP.NET Core.
// appsettings.json (keep the secret in user-secrets / Key Vault, not in the file)
{
"Checkout": {
"BaseUrl": "https://gateway-production-319f.up.railway.app",
"KeyId": "pk_test_xxx",
"Secret": "", // dotnet user-secrets set "Checkout:Secret" "sk_test_..."
"WebhookSecret": "" // whsec_... from Dashboard → Webhooks
}
}using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
public sealed class CheckoutOptions
{
public string BaseUrl { get; set; } = "";
public string KeyId { get; set; } = "";
public string Secret { get; set; } = "";
public string WebhookSecret { get; set; } = "";
}
/// <summary>Signs every outgoing request to the Checkout Gateway (HMAC-SHA256).</summary>
public sealed class CheckoutSigningHandler(IOptions<CheckoutOptions> options) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
var o = options.Value;
var body = request.Content is null ? [] : await request.Content.ReadAsByteArrayAsync(ct);
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var nonce = Guid.NewGuid().ToString("N");
var bodyHash = Convert.ToBase64String(SHA256.HashData(body));
var pathAndQuery = request.RequestUri!.PathAndQuery; // e.g. /v1/invoices?page=2
var toSign = $"{request.Method.Method}\n{pathAndQuery}\n{timestamp}\n{nonce}\n{bodyHash}";
var signature = Convert.ToBase64String(
HMACSHA256.HashData(Encoding.UTF8.GetBytes(o.Secret), Encoding.UTF8.GetBytes(toSign)));
request.Headers.Add("X-Api-Key", o.KeyId);
request.Headers.Add("X-Timestamp", timestamp);
request.Headers.Add("X-Nonce", nonce);
request.Headers.Add("X-Body-Hash", bodyHash);
request.Headers.Add("X-Signature", signature);
return await base.SendAsync(request, ct);
}
}using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
public sealed record LineItem(string Description, decimal Quantity, decimal UnitAmount);
public sealed record CreateInvoice(string Currency, IReadOnlyList<LineItem> LineItems,
string? CustomerName = null, string? CustomerEmail = null, string? Number = null,
DateOnly? DueDate = null, string? Memo = null, IReadOnlyList<string>? AllowedMethods = null,
IDictionary<string, string>? Metadata = null);
public sealed record Invoice(string Id, string Url, string Number, string Status, decimal Total,
decimal AmountPaid, decimal AmountDue, string Currency, DateOnly? DueDate);
public sealed record CreatePaymentLink(string Title, decimal Amount, string Currency,
bool Reusable = true, string? Description = null, int? MaxPayments = null, string? ExternalReference = null);
public sealed record PaymentLink(string Id, string Url, string Status, string Title, decimal Amount,
string Currency, int PaymentCount);
public sealed record CreatePaymentMethod(string Method, string Currency, string? Network = null,
string? WalletAddress = null, string? Custodian = null, string? BankProvider = null,
string? AccountHolderName = null, string? Iban = null, string? AccountNumber = null, string? SwiftBic = null);
public sealed record PaymentMethod(string Id, string Method, string Mode, string Currency, string Status);
public sealed record RecordPayment(decimal? Amount = null, string? Reference = null, string? Note = null);
public sealed record RecordPaymentResult(string PaymentId, string SessionStatus, decimal AmountReceived, decimal AmountOutstanding);
public sealed record CheckoutError(string Code, string Message);
public sealed class CheckoutApiException(int status, CheckoutError? error)
: Exception(error?.Message ?? $"Checkout API returned {status}")
{
public int Status { get; } = status;
public string? Code { get; } = error?.Code;
}
/// <summary>Typed client for the Checkout Gateway merchant API.</summary>
public sealed class CheckoutClient(HttpClient http)
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public Task<PaymentMethod> AddPaymentMethodAsync(CreatePaymentMethod body, CancellationToken ct = default) =>
SendAsync<PaymentMethod>(HttpMethod.Post, "/v1/payment-instruments", body, idempotencyKey: null, ct);
/// <param name="idempotencyKey">Use your own invoice id: retries then never create duplicates.</param>
public Task<Invoice> CreateInvoiceAsync(CreateInvoice body, string idempotencyKey, CancellationToken ct = default) =>
SendAsync<Invoice>(HttpMethod.Post, "/v1/invoices", body, idempotencyKey, ct);
public Task<Invoice> GetInvoiceAsync(string invoiceId, CancellationToken ct = default) =>
SendAsync<Invoice>(HttpMethod.Get, $"/v1/invoices/{Uri.EscapeDataString(invoiceId)}", null, null, ct);
public Task<Invoice> VoidInvoiceAsync(string invoiceId, CancellationToken ct = default) =>
SendAsync<Invoice>(HttpMethod.Post, $"/v1/invoices/{Uri.EscapeDataString(invoiceId)}/void", null, null, ct);
public Task<PaymentLink> CreatePaymentLinkAsync(CreatePaymentLink body, string idempotencyKey, CancellationToken ct = default) =>
SendAsync<PaymentLink>(HttpMethod.Post, "/v1/payment-links", body, idempotencyKey, ct);
/// <summary>"Mark as paid". In test mode this simulates the confirmation (sends payment.confirmed).</summary>
public Task<RecordPaymentResult> RecordPaymentAsync(string sessionId, RecordPayment body, CancellationToken ct = default) =>
SendAsync<RecordPaymentResult>(HttpMethod.Post, $"/v1/checkout/sessions/{Uri.EscapeDataString(sessionId)}/payments", body, null, ct);
private async Task<T> SendAsync<T>(HttpMethod method, string path, object? body, string? idempotencyKey, CancellationToken ct)
{
using var request = new HttpRequestMessage(method, path);
if (body is not null) request.Content = JsonContent.Create(body, options: Json);
if (idempotencyKey is not null) request.Headers.Add("Idempotency-Key", idempotencyKey);
using var response = await http.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadFromJsonAsync<ErrorEnvelope>(Json, ct).ConfigureAwait(false);
throw new CheckoutApiException((int)response.StatusCode, error?.Error);
}
return (await response.Content.ReadFromJsonAsync<T>(Json, ct))!;
}
private sealed record ErrorEnvelope(CheckoutError? Error);
}// Program.cs
builder.Services.Configure<CheckoutOptions>(builder.Configuration.GetSection("Checkout"));
builder.Services.AddTransient<CheckoutSigningHandler>();
builder.Services
.AddHttpClient<CheckoutClient>((sp, http) =>
{
var o = sp.GetRequiredService<IOptions<CheckoutOptions>>().Value;
http.BaseAddress = new Uri(o.BaseUrl);
http.Timeout = TimeSpan.FromSeconds(15);
})
.AddHttpMessageHandler<CheckoutSigningHandler>();
// Optional: .AddStandardResilienceHandler() (Microsoft.Extensions.Http.Resilience) — safe because
// creates are idempotent (Idempotency-Key) and every retry gets a fresh nonce from the handler.using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/invoices")]
public sealed class InvoicesController(CheckoutClient checkout) : ControllerBase
{
public sealed record CreateInvoiceDto(string CustomerName, string CustomerEmail, List<LineItem> Items, DateOnly DueDate);
/// <summary>Your own API: create the invoice in the gateway and return the link to send to the customer.</summary>
[HttpPost("{orderId}")]
public async Task<IActionResult> Create(string orderId, CreateInvoiceDto dto, CancellationToken ct)
{
try
{
var invoice = await checkout.CreateInvoiceAsync(new CreateInvoice(
Currency: "USD",
LineItems: dto.Items,
CustomerName: dto.CustomerName,
CustomerEmail: dto.CustomerEmail,
DueDate: dto.DueDate,
Metadata: new Dictionary<string, string> { ["orderId"] = orderId }),
idempotencyKey: $"order-{orderId}", ct);
// Persist invoice.Id against your order, then email invoice.Url to the customer.
return Ok(new { invoice.Id, invoice.Number, invoice.Url, invoice.Total });
}
catch (CheckoutApiException ex) when (ex.Status is 400 or 409)
{
return Problem(ex.Message, statusCode: ex.Status, title: ex.Code);
}
}
[HttpGet("{invoiceId}/status")]
public async Task<IActionResult> Status(string invoiceId, CancellationToken ct) =>
Ok(await checkout.GetInvoiceAsync(invoiceId, ct));
}using System.Security.Cryptography;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
/// <summary>Receives Checkout Gateway webhooks. Verifies the signature over the RAW body before trusting anything.</summary>
[ApiController]
[Route("webhooks/checkout")]
public sealed class CheckoutWebhookController(IOptions<CheckoutOptions> options, ILogger<CheckoutWebhookController> log) : ControllerBase
{
private static readonly TimeSpan Tolerance = TimeSpan.FromMinutes(5);
[HttpPost]
[AllowAnonymous]
[RequestSizeLimit(1_048_576)]
public async Task<IActionResult> Receive(CancellationToken ct)
{
using var reader = new StreamReader(Request.Body, Encoding.UTF8);
var body = await reader.ReadToEndAsync(ct); // raw body — do not model-bind first
if (!Verify(Request.Headers["Checkout-Signature"].ToString(), body, options.Value.WebhookSecret))
return BadRequest();
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
var eventId = root.GetProperty("id").GetString()!;
var type = root.GetProperty("type").GetString()!;
var session = root.GetProperty("data").GetProperty("session");
// 1) Idempotency: deliveries are at-least-once — skip if eventId was already processed (unique index in your DB).
// 2) Ordering: ignore if session.updatedAt is older than what you stored for this session.
switch (type)
{
case "payment.confirmed":
var invoiceId = session.TryGetProperty("invoiceId", out var inv) ? inv.GetString() : null;
var reference = session.GetProperty("externalReference").GetString();
var received = session.GetProperty("amountReceived").GetDecimal();
log.LogInformation("Paid: {Reference} ({InvoiceId}) {Amount}", reference, invoiceId, received);
// mark your order / invoice as PAID
break;
case "payment.partially_paid":
case "payment.claimed":
case "payment.verified_onchain":
case "checkout.session.expired":
case "checkout.session.cancelled":
// update status shown to your users; "claimed" is NOT proof of payment
break;
}
return Ok(); // respond 2xx fast; do slow work in a queue
}
private static bool Verify(string header, string body, string secret)
{
long? t = null;
var signatures = new List<string>();
foreach (var part in header.Split(',', StringSplitOptions.TrimEntries))
{
var kv = part.Split('=', 2);
if (kv.Length != 2) continue;
if (kv[0] == "t" && long.TryParse(kv[1], out var ts)) t = ts;
else if (kv[0] == "v1") signatures.Add(kv[1]);
}
if (t is null || signatures.Count == 0) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t.Value) > Tolerance.TotalSeconds) return false;
var expected = Convert.ToHexString(HMACSHA256.HashData(
Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{t}.{body}"))).ToLowerInvariant();
var expectedBytes = Encoding.ASCII.GetBytes(expected);
return signatures.Any(s => CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(s), expectedBytes));
}
}- Test the whole flow without real money: create a test payment method, an invoice and then call
RecordPaymentAsyncon its session. Your webhook receivespayment.confirmed. - Never sign twice: the handler runs once per attempt. A retry policy placed before it gets a fresh nonce and timestamp.
- Keep server clocks in sync (NTP). Requests older than 5 minutes are rejected.
Test and live mode
The key decides the mode: pk_test_ keys only ever see test data and pk_live_ keys only live data. Live keys are available once your business has been verified.
- Crypto in test mode uses real testnets: USDT on TRON Nile and USDC on Ethereum Sepolia. Transaction hashes are verified on-chain and, in test mode, a verified transfer confirms the payment automatically.
- Bank transfers in test mode: no money moves. Use record a payment (or “Simulate payment” in the dashboard) to fire
payment.confirmed. - Live payment methods created through the API start as
PendingApproval. A merchant Admin must approve them in the dashboard, so a leaked key cannot redirect funds.
Payment methods
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /v1/payment-instruments?includeInactive=false | instruments:read | List methods for the key's mode |
| GET | /v1/payment-instruments/{id} | instruments:read | Get one method |
| POST | /v1/payment-instruments | instruments:write | Add a wallet or bank account (test: active; live: pending approval) |
| POST | /v1/payment-instruments/{id}/disable | instruments:write | Stop offering it on new checkouts |
{
"method": "Crypto",
"currency": "USDT", // USDT | USDC (USDC only on ERC20)
"network": "TRC20", // TRC20 | ERC20
"walletAddress": "TXYZ...",
"custodian": "Fireblocks", // Fireblocks | Dfns
"custodianWalletRef": "vault-7" // optional, for reconciliation
}{
"method": "BankTransfer",
"currency": "USD",
"bankProvider": "OpenPayd", // Noah | Mesta | OpenPayd | Tazapay
"accountHolderName": "Acme Ltd",
"iban": "GB33BUKB20201555555555", // or accountNumber (+ routingNumber / sortCode)
"swiftBic": "BUKBGB22",
"bankName": "OpenPayd", "bankCountry": "GB",
"paymentRails": ["SEPA", "SWIFT"]
}USD checkouts can be paid 1:1 in USDT or USDC. Amounts are exact: 2 decimals when bank transfer is offered, 6 for crypto only.
Payment links
| Method | Path | Scope | Description |
|---|---|---|---|
| POST | /v1/payment-links | links:write | Create a link |
| GET | /v1/payment-links?status=&page=&pageSize= | links:read | List links (newest first) |
| GET | /v1/payment-links/{id} | links:read | Get a link |
| POST | /v1/payment-links/{id}/deactivate | links:write | Stop new payers (open checkouts can finish) |
| POST | /v1/payment-links/{id}/activate | links:write | Re-activate |
| GET | /v1/checkout/sessions?paymentLinkId={id} | sessions:read | Checkouts opened from the link |
{
"title": "Consulting – 1 hour",
"description": "Video call",
"amount": 150,
"currency": "USD",
"allowedMethods": ["Crypto", "BankTransfer"], // optional, default: all available
"reusable": true, // false = single use
"maxPayments": 100, // optional (reusable only)
"expiresAt": "2026-12-31T23:59:59Z", // optional
"externalReference": "PRODUCT-42", // optional, echoed in webhooks
"returnUrl": "https://shop.example.com/thanks",
"metadata": { "sku": "CONSULT-1H" }
}The response includes url (https://gateway-production-319f.up.railway.app/l/…). Each payment is a checkout session with paymentLinkId set. Watch for payment.confirmed.
Invoices
| Method | Path | Scope | Description |
|---|---|---|---|
| POST | /v1/invoices | invoices:write | Create an invoice |
| GET | /v1/invoices?status=&search=&page= | invoices:read | List invoices; search matches the number or customer email |
| GET | /v1/invoices/{id} | invoices:read | Invoice with its checkout sessions |
| POST | /v1/invoices/{id}/void | invoices:write | Void an unpaid invoice (cancels its open checkout) |
{
"number": "INV-2026-001", // optional, generated if omitted; unique per mode
"customerName": "Globex Corp",
"customerEmail": "ap@globex.example",
"currency": "USD",
"lineItems": [
{ "description": "Design sprint", "quantity": 3, "unitAmount": 1200 },
{ "description": "Hosting (Oct)", "quantity": 1, "unitAmount": 49.99 }
],
"dueDate": "2026-10-31",
"memo": "Payment due within 30 days",
"allowedMethods": ["Crypto", "BankTransfer"],
"metadata": { "orderId": "SO-1182" }
}Status moves Open → PartiallyPaid → Paid (or Void). When the customer opens url, the invoice's checkout session is reused, or a new one opens for the amount still due if the last one expired.
Checkout sessions
Use these when your own system already has the invoice and you only need a payment page for it.
| Method | Path | Scope | Description |
|---|---|---|---|
| POST | /v1/checkout/sessions | sessions:write | Create a session; returns a one-off url |
| GET | /v1/checkout/sessions/{id} | sessions:read | Session with declarations, evidence and payments |
| GET | /v1/checkout/sessions?externalReference=|paymentLinkId=|invoiceId= | sessions:read | Latest 20 matching sessions |
| POST | /v1/checkout/sessions/{id}/cancel | sessions:write | Cancel an active session |
| POST | /v1/checkout/sessions/{id}/regenerate-link | sessions:write | New url; the old one stops working |
| GET | /v1/checkout/sessions/{id}/evidence/{evidenceId} | evidence:read | Download a receipt the payer uploaded |
{
"externalType": "Invoice", // Invoice | PaymentLink
"externalReference": "INV-1001", // one active session per reference
"amount": 250,
"currency": "USD",
"description": "Website redesign – milestone 2",
"customerName": "Globex", "customerEmail": "ap@globex.example",
"expiresInMinutes": 1440, // 15 min … 60 days, default 24 h
"returnUrl": "https://app.example.com/invoices/1001",
"metadata": { "invoiceId": "1001" }
}The checkout token is in the URL fragment (/pay#cst_…), so browsers never send it to servers, proxies or analytics.
Recording payments
| Method | Path | Scope | Description |
|---|---|---|---|
| POST | /v1/checkout/sessions/{id}/payments | payments:write | Record money received (test: simulate) |
{
"amount": 250, // optional: defaults to the outstanding amount
"reference": "FB-TX-88213", // required in live mode (your bank / custody tx id)
"declarationId": "pd_...", // optional: settle a specific payer submission
"receivedAt": "2026-10-01T10:12:00Z",
"note": "Matched in treasury"
}Test keys include payments:write by default. Live keys get it only when you tick it on key creation, because it marks invoices paid without the gateway seeing the money.
Webhooks
Add endpoints under Dashboard → Webhooks, separately for test and live. We POST JSON, retry with backoff for about 3 days, and only deliver to public HTTPS URLs.
| Event | Meaning |
|---|---|
| payment.claimed | The payer submitted payment details. Not proof of payment. |
| payment.evidence_added | The payer uploaded a receipt. |
| payment.verified_onchain | The crypto transfer was found on a finalized block. |
| payment.claim_failed | On-chain verification failed (wrong token, wallet or amount, or too old). |
| payment.claim_rejected | Your back office rejected the claim. |
| payment.partially_paid | Confirmed money, but less than the amount due. |
| payment.confirmed | Fully paid. The only event that means paid. |
| checkout.session.expired | Nothing was confirmed in time. |
| checkout.session.cancelled | Cancelled by you, or because its invoice was voided. |
{
"id": "evt_...", "type": "payment.confirmed", "created": "2026-10-01T10:12:03Z", "mode": "live",
"data": {
"merchantId": "mch_...",
"session": { "id": "cs_...", "status": "Paid", "externalReference": "INV-2026-001",
"invoiceId": "inv_...", "paymentLinkId": null, "amount": 3649.99, "currency": "USD",
"amountReceived": 3649.99, "customerEmail": "ap@globex.example", "updatedAt": "...",
"declarations": [ ... ], "payments": [ ... ] },
"declaration": { "txHash": "...", "payerName": "...", "evidence": [ ... ] },
"payment": { "provider": "Fireblocks", "providerTransactionId": "...", "amount": 3649.99 }
}
}Each delivery has Checkout-Signature: t=<unix>,v1=<hex> plus Checkout-Event-Id and Checkout-Event-Type. v1 = hex(HMAC-SHA256(signingSecret, `${t}.${rawBody}`)). While a secret is being rotated, two v1 values are sent.
import { createHmac, timingSafeEqual } from "node:crypto";
app.post("/webhooks/checkout", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("Checkout-Signature") ?? "";
const parts = Object.groupBy(header.split(","), (p) => p.split("=")[0]);
const t = Number(parts.t?.[0]?.split("=")[1]);
const sigs = (parts.v1 ?? []).map((p) => p.split("=")[1]);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return res.sendStatus(400);
const expected = createHmac("sha256", process.env.CHECKOUT_WEBHOOK_SECRET)
.update(`${t}.${req.body.toString("utf8")}`).digest("hex");
const ok = sigs.some((s) => s.length === expected.length &&
timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body);
// Idempotent: skip if event.id was already processed.
// Out-of-order safe: ignore if event.data.session.updatedAt is older than what you stored.
res.sendStatus(200);
});- Respond with 2xx within 10 seconds and do slow work asynchronously.
- Deliveries are at least once. De-duplicate on
id. - The delivery log for each endpoint is in the dashboard.
Errors and limits
HTTP/1.1 409 Conflict
{ "error": { "code": "invoice_number_exists", "message": "Invoice INV-2026-001 already exists." }, "traceId": "..." }| 400 | Validation error. Check error.code and the message. |
| 401 | Missing or invalid signature, expired timestamp, or a reused nonce. |
| 403 | The key lacks the scope, the IP is not allowed, or live mode is not enabled. |
| 404 | Not found. This includes objects in the other mode or owned by another merchant. |
| 409 | Conflict: duplicate, idempotency key reused, or invalid state. |
| 410 | The checkout, link or invoice is no longer payable. |
| 429 | Rate limited (about 20 requests/second per key, bursts up to 100). Honour Retry-After. |
Amounts are JSON numbers and timestamps are ISO-8601 UTC. dueDate is a date (YYYY-MM-DD). An OpenAPI description is served at https://gateway-production-319f.up.railway.app/swagger when enabled.