TillsafeDocstillsafe.comRequest early access

Quickstart

Test keys, an invoice, the checkout and a verified webhook, in about ten minutes.

You will create an invoice, open the hosted checkout for it, pay it with a simulated test payment, and receive the signed invoice.paid webhook on your own server. Everything here runs in test mode: test keys only see testnets, and no real money can move.

You need test keys (see step 1), curl and Node.js 18 or later. Every command on this page is run, as written, by the docs' test suite against a real server, so what you see is what the API does today.

1. Get your test keys

During early access, keys are issued on request: request early access and you get a test secret key (cp_test_sk_…) and a test publishable key (cp_test_pk_…). Keep the secret key on your server and never put it in a web page or a URL. The publishable key is the only key a browser may hold.

Put them in your shell, with the API's address:

sh
export API_BASE="https://api.tillsafe.com"
export SECRET_KEY="cp_test_sk_…"
export PUBLISHABLE_KEY="cp_test_pk_…"

2. Create an invoice

Prices are in fiat, as a decimal string. Every POST needs an Idempotency-Key: use something unique to the operation, such as your order number, so a retry after a timeout can never create a second invoice.

sh
curl "$API_BASE/v1/invoices" \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Idempotency-Key: order-1001" \
  -H "Content-Type: application/json" \
  -d '{"amount": "30.00", "currency": "USD", "description": "Order #1001", "metadata": {"order_id": "1001"}}'

The response is the invoice (shortened here). Each payment option is one way to pay: an asset on a network, the exact amount that must arrive, and its own deposit address.

json
{
  "id": "inv_…",
  "object": "invoice",
  "livemode": false,
  "status": "awaiting_payment",
  "amount": { "amount": "30.00", "currency": "USD" },
  "description": "Order #1001",
  "metadata": { "order_id": "1001" },
  "payment_options": [
    {
      "asset": "USDT@…",
      "symbol": "USDT",
      "network": "…",
      "network_name": "…",
      "amount_due": "30",
      "address": "…"
    }
  ],
  "expires_at": "…"
}

Keep the id:

sh
export INVOICE_ID="inv_…"

3. Send the payer to the checkout

The hosted checkout takes the invoice id and your publishable key. Your checkout's host comes with your early-access keys (or is your own, in white-label mode); https://pay.example.com stands in for it here:

https://pay.example.com/?invoice=inv_…&key=cp_test_pk_…

Redirect the payer there, or put it on your own page with the embed loader. The checkout reads the invoice with the publishable key; this is exactly what it sees:

sh
curl "$API_BASE/v1/checkout/invoices/$INVOICE_ID" \
  -H "Authorization: Bearer $PUBLISHABLE_KEY"
json
{
  "id": "inv_…",
  "status": "awaiting_payment",
  "payment_options": [{ "symbol": "USDT", "amount_due": "30" }]
}

The payer view never includes your metadata or customer reference.

4. Receive the webhook

Save this as receiver.js. It verifies the signature on the raw body before trusting anything in it (the webhooks guide explains each step, and has the same receiver in Python, PHP and Go):

Node.js (no dependencies)

receiver.js
const crypto = require("node:crypto");

/** Returns true if `header` is a valid signature of `rawBody` (a Buffer) under `secret`. */
function verifyWebhook(rawBody, header, secret, toleranceSecs = 300, nowSecs = Math.floor(Date.now() / 1000)) {
  let t = null;
  const sigs = [];
  for (const part of String(header || "").split(",")) {
    const i = part.indexOf("=");
    if (i < 0) return false;
    const k = part.slice(0, i).trim(), v = part.slice(i + 1).trim();
    if (k === "t") t = Number(v);
    else if (k === "v1") sigs.push(v);
  }
  if (!Number.isInteger(t) || sigs.length === 0) return false;
  if (Math.abs(nowSecs - t) > toleranceSecs) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
  return sigs.some((s) => {
    const got = Buffer.from(s, "hex");
    return got.length === expected.length && crypto.timingSafeEqual(got, expected);
  });
}

// With node:http (the raw body is what you read off the socket):
require("node:http").createServer((req, res) => {
  const chunks = [];
  req.on("data", (c) => chunks.push(c)).on("end", () => {
    const raw = Buffer.concat(chunks);
    if (!verifyWebhook(raw, req.headers["x-cryptopay-signature"], process.env.WEBHOOK_SECRET)) {
      res.writeHead(400).end("bad signature");
      return;
    }
    const event = JSON.parse(raw.toString("utf8"));
    // Deduplicate on event.id (== the x-cryptopay-event-id header), then handle event.type.
    res.writeHead(200).end("ok");
  });
}).listen(Number(process.env.PORT) || 3000);

With Express, verify before any JSON body parser: app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => verifyWebhook(req.body, …)).

Your endpoint must be reachable over https:// from the internet. While developing, run the receiver on your machine and expose port 3000 with a tunnel of your choice, then register the tunnel's URL:

sh
export WEBHOOK_URL="https://your-tunnel.example/webhooks"
sh
curl "$API_BASE/v1/merchant/settings" \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Idempotency-Key: webhook-setup-1" \
  -H "Content-Type: application/json" \
  -d "{\"webhook_url\": \"$WEBHOOK_URL\"}"
json
{ "webhook_url": "https://your-tunnel.example/webhooks" }

Then create the signing secret. It is shown only in this response:

sh
curl -X POST "$API_BASE/v1/merchant/webhook_secret/roll" \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Idempotency-Key: webhook-secret-1"
json
{ "object": "webhook_secret", "secret": "whsec_…" }

Start the receiver with it:

sh
export WEBHOOK_SECRET="whsec_…"
sh
node receiver.js

5. Pay the invoice

In test mode a test helper sends a simulated payment to the invoice's real deposit address. It goes through the same pipeline as a real one: attribution, confirmations, the invoice's status and your webhooks.

sh
curl "$API_BASE/v1/test_helpers/invoices/$INVOICE_ID/simulate_payment" \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Idempotency-Key: simulate-1001" \
  -H "Content-Type: application/json" \
  -d '{}'

The response is the invoice. A few seconds later, as the simulated confirmations arrive, its status is paid and your receiver has answered 200 to invoice.detected and then invoice.paid. Check the delivery log:

sh
curl "$API_BASE/v1/webhook_deliveries?limit=2" \
  -H "Authorization: Bearer $SECRET_KEY"
json
{
  "object": "list",
  "data": [
    { "object": "webhook_delivery", "event_type": "invoice.paid", "status": "succeeded", "status_code": 200, "invoice": "inv_…" },
    { "object": "webhook_delivery", "event_type": "invoice.detected", "status": "succeeded", "status_code": 200, "invoice": "inv_…" }
  ]
}

That is the whole integration: create invoices on your server, send payers to the checkout, and fulfil on the verified invoice.paid webhook.

Next