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

  1. Sign up and create your business (organization). You start in test mode.
  2. In the dashboard, go to API keys and create a Test key. Copy the secret straight away: it is shown once.
  3. Optional: under Webhooks, add your HTTPS endpoint and store its signing secret.
  4. Do the rest through the API: add a payment method, create invoices or payment links, and share their url.
Node 18+: create a test payment method, then an invoice
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 customer

The 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 at https://gateway-production-319f.up.railway.app/i/<slug>.
  • Payment link (plink_…): a hosted page at https://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 to Paid.

Authentication

Every request to /v1/* is signed with your API key secret. Send these headers:

X-Api-KeyYour key id (pk_test_… / pk_live_…)
X-TimestampUnix time in seconds. Must be within ±5 minutes of server time.
X-NonceA unique value per request (e.g. a UUID). Replays are rejected.
X-Body-Hashbase64(SHA-256(raw body bytes)). Hash an empty string for GET requests.
X-Signaturebase64(HMAC-SHA256(secret, string-to-sign))
String to sign (lines joined with \n)
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.

C# (.NET): minimal
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-Key header on create calls. Retrying with the same key and body returns the original object. Reusing a key with a different body returns 409.
  • 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.

1. Configuration
// 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
  }
}
2. Signing handler (CheckoutSigningHandler.cs)
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);
    }
}
3. Typed client and DTOs (CheckoutClient.cs)
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);
}
4. Registration (Program.cs)
// 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.
5. Your API: create an invoice and return the customer link (InvoicesController.cs)
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));
}
6. Webhook receiver (CheckoutWebhookController.cs)
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 RecordPaymentAsync on its session. Your webhook receives payment.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

MethodPathScopeDescription
GET/v1/payment-instruments?includeInactive=falseinstruments:readList methods for the key's mode
GET/v1/payment-instruments/{id}instruments:readGet one method
POST/v1/payment-instrumentsinstruments:writeAdd a wallet or bank account (test: active; live: pending approval)
POST/v1/payment-instruments/{id}/disableinstruments:writeStop offering it on new checkouts
Crypto wallet
{
  "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
}
Bank account
{
  "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.

Invoices

MethodPathScopeDescription
POST/v1/invoicesinvoices:writeCreate an invoice
GET/v1/invoices?status=&search=&page=invoices:readList invoices; search matches the number or customer email
GET/v1/invoices/{id}invoices:readInvoice with its checkout sessions
POST/v1/invoices/{id}/voidinvoices:writeVoid an unpaid invoice (cancels its open checkout)
Request
{
  "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.

MethodPathScopeDescription
POST/v1/checkout/sessionssessions:writeCreate a session; returns a one-off url
GET/v1/checkout/sessions/{id}sessions:readSession with declarations, evidence and payments
GET/v1/checkout/sessions?externalReference=|paymentLinkId=|invoiceId=sessions:readLatest 20 matching sessions
POST/v1/checkout/sessions/{id}/cancelsessions:writeCancel an active session
POST/v1/checkout/sessions/{id}/regenerate-linksessions:writeNew url; the old one stops working
GET/v1/checkout/sessions/{id}/evidence/{evidenceId}evidence:readDownload a receipt the payer uploaded
Request
{
  "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

MethodPathScopeDescription
POST/v1/checkout/sessions/{id}/paymentspayments:writeRecord 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.

EventMeaning
payment.claimedThe payer submitted payment details. Not proof of payment.
payment.evidence_addedThe payer uploaded a receipt.
payment.verified_onchainThe crypto transfer was found on a finalized block.
payment.claim_failedOn-chain verification failed (wrong token, wallet or amount, or too old).
payment.claim_rejectedYour back office rejected the claim.
payment.partially_paidConfirmed money, but less than the amount due.
payment.confirmedFully paid. The only event that means paid.
checkout.session.expiredNothing was confirmed in time.
checkout.session.cancelledCancelled by you, or because its invoice was voided.
Payload
{
  "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.

Verify (Node / Express)
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": "..." }
400Validation error. Check error.code and the message.
401Missing or invalid signature, expired timestamp, or a reused nonce.
403The key lacks the scope, the IP is not allowed, or live mode is not enabled.
404Not found. This includes objects in the other mode or owned by another merchant.
409Conflict: duplicate, idempotency key reused, or invalid state.
410The checkout, link or invoice is no longer payable.
429Rate 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.