SatsCollect
Integration guide

Accept Bitcoin without giving up custody.

SatsCollect gives each checkout a Bitcoin address from your own address pool, watches the chain for payment, and sends the customer back to your application with a verifiable result. No payment API call is required to start a checkout.

1. Your server

Creates a plain product link or signs a dynamic payload.

2. SatsCollect

Locks the rate, assigns an address, and presents the checkout.

3. Your app

Verifies the signed redirect or webhook before fulfilling.

Mainnet Bitcoin: checkouts use real on-chain addresses and confirmed chain data. Keep enough unused addresses in every vendor’s pool before sending customers to checkout.

Quickstart

Your first checkout

The Basic integration is just a hosted payment link. Setup happens once in the dashboard.

  1. 1

    Create a vendor

    A vendor represents one project or storefront. Copy its secret key when it is shown and store it in your server’s environment—it is not a browser key.

  2. 2

    Add fresh Bitcoin addresses

    SatsCollect assigns one address to each checkout. Add a pool of unused addresses you control; an address is never shared between active checkouts.

  3. 3

    Create a product

    Choose a fixed price in BTC, USD, EUR, or RON. Optionally add a thank-you URL to receive the customer after confirmation.

  4. 4

    Send the customer to the product link

    Copy the checkout link from Products and use it as a button, normal link, or server-side redirect.

Fixed-price checkout

Basic

Link directly to a product

A product’s UUID is public and safe to put in HTML. SatsCollect uses the price and currency configured in the dashboard.

HTML
<a href="https://satscollect.com/pay/PRODUCT_UUID">Pay with Bitcoin</a>

Fiat-priced product

USD, EUR, and RON prices are converted to BTC when checkout opens. The displayed quote is locked for 15 minutes.

BTC-priced product

The configured BTC amount is used directly, with no exchange-rate lookup.

Dynamic pricing

Pro

Sign each price on your server

Override the product price by encoding a JSON payload and signing the exact encoded string with HMAC-SHA256. The signature prevents customers from changing the amount or return URL.

FieldValue
amountPositive numeric price, for example 49.90
currencybtc, usd, eur, or ron
return_urlOptional HTTPS destination after confirmation
PHP
<?php
$payload = [
    'amount' => 49.90,
    'currency' => 'usd',
    'return_url' => 'https://shop.example.com/orders/ORD-1042/paid',
];

// URL-safe base64 without padding.
$encoded = rtrim(strtr(base64_encode(json_encode($payload)), '+/', '-_'), '=');
$signature = hash_hmac('sha256', $encoded, $_ENV['SATSCOLLECT_SECRET']);

$checkoutUrl = 'https://satscollect.com/pay/PRODUCT_UUID?'.http_build_query([
    'payload' => $encoded,
    'signature' => $signature,
]);

header('Location: '.$checkoutUrl);
Node.js
import crypto from "node:crypto";

const payload = {
  amount: 49.90,
  currency: "usd",
  return_url: "https://shop.example.com/orders/ORD-1042/paid",
};

const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signature = crypto
  .createHmac("sha256", process.env.SATSCOLLECT_SECRET)
  .update(encoded)
  .digest("hex");

const url = new URL("https://satscollect.com/pay/PRODUCT_UUID");
url.searchParams.set("payload", encoded);
url.searchParams.set("signature", signature);

response.redirect(url.toString());
Keep it server-side. Never expose the vendor secret in JavaScript shipped to the browser, a mobile binary, a public repository, logs, or analytics URLs. Your backend should create the final checkout URL.

Verified redirects

Trust the signature, not the query string

After SatsCollect observes a confirmed payment, the checkout redirects once to the dynamic payload’s return_url, or falls back to the product’s thank-you URL. Two query parameters are appended:

https://shop.example.com/paid?payment_id=1842&token=9a38…

token is the lowercase hex HMAC-SHA256 of the decimal payment_id, keyed with your vendor secret. Validate it with a timing-safe comparison before marking an order paid.

PHP
<?php
$paymentId = $_GET['payment_id'] ?? '';
$token = $_GET['token'] ?? '';
$expected = hash_hmac('sha256', $paymentId, $_ENV['SATSCOLLECT_SECRET']);

if (! hash_equals($expected, $token)) {
    http_response_code(401);
    exit('Invalid SatsCollect token');
}

// Also match payment_id to the expected order, then fulfill idempotently.
Node.js
import crypto from "node:crypto";

const paymentId = String(request.query.payment_id ?? "");
const token = String(request.query.token ?? "");
const expected = crypto
  .createHmac("sha256", process.env.SATSCOLLECT_SECRET)
  .update(paymentId)
  .digest("hex");

const valid = token.length === expected.length &&
  crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));

if (!valid) return response.status(401).send("Invalid SatsCollect token");

Redirect behavior

  • • The redirect is available only after confirmation.
  • • It is intentionally one-time; revisiting it displays an already-processed page.
  • • Existing query parameters on your return URL are preserved.
  • • A browser redirect is a user experience signal. For reliable server-to-server fulfillment, use webhooks.

Webhooks

Pro

Fulfill without waiting for the browser

Set the vendor’s webhook URL in the dashboard. When a payment confirms, SatsCollect sends one JSON POST with these headers:

Content-Type: application/json
X-SatsCollect-Event: payment.confirmed
X-SatsCollect-Signature: <hex HMAC-SHA256>
JSON
{
  "event": "payment.confirmed",
  "payment_request": {
    "id": 1842,
    "uuid": "2dc80cf4-8859-42e7-920b-d51bda70cd02",
    "vendor_id": 12,
    "product_id": 48,
    "product_uuid": "4ffbe5fb-0912-4a8c-a485-40b095f7a3e4",
    "address": "bc1q…",
    "expected_amount": "0.00072410",
    "received_amount": "0.00072410",
    "confirmed": true,
    "paid_at": "2026-09-16T11:42:18+00:00",
    "currency": "usd",
    "fiat_amount": "49.90",
    "payload": "eyJhbW91bnQi…",
    "return_url": "https://shop.example.com/orders/ORD-1042/paid"
  }
}

The signature is the lowercase hex HMAC-SHA256 of the raw request body bytes. Read and verify the raw body before parsing JSON.

PHP
<?php
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SATSCOLLECT_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $rawBody, $_ENV['SATSCOLLECT_SECRET']);

if (! hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Invalid webhook signature');
}

$event = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
if ($event['event'] === 'payment.confirmed') {
    // Fulfill once, keyed by payment_request.id or .uuid.
}

http_response_code(200);
Node.js / Express
app.post("/webhooks/satscollect", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.get("X-SatsCollect-Signature") ?? "";
  const expected = crypto
    .createHmac("sha256", process.env.SATSCOLLECT_SECRET)
    .update(req.body)
    .digest("hex");

  const valid = signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  if (!valid) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString("utf8"));
  // Fulfill idempotently using event.payment_request.id or .uuid.
  return res.sendStatus(200);
});
Current delivery policy: a webhook is attempted when confirmation is first detected, with a 10-second timeout. Automatic retries are not currently guaranteed. Delivery attempts are recorded, and confirmed payments can be resent from the vendor’s transaction list.

Payment lifecycle

What happens after the click

1

Checkout opens

The signed payload is verified (when present), price is resolved, and one unused address is reserved.

2

Quote is locked

Fiat prices are converted to BTC and displayed with a 15-minute countdown.

3

Chain is checked

SatsCollect checks unconfirmed requests approximately every two minutes using confirmed on-chain totals.

4

Payment confirms

When the address has received at least the expected BTC amount, the request is marked confirmed and the Pro webhook is attempted.

5

Customer returns

The checkout notices confirmation and opens the one-time signed redirect, or the built-in confirmation page.

6

Expired address recovers

An unpaid request is removed and its address becomes available again 48 hours after its quote expires.

Opening the same product link again in the same browser session reuses its pending checkout. Signed payloads are isolated by their payload and signature, so different orders do not collide in the session.

Security checklist

Before going live

Keep the vendor secret on your server

Load it from an environment variable or secret manager. Never place it in frontend code.

Sign the exact encoded payload

Do not parse, normalize, or re-encode it between signature creation and checkout URL construction.

Use constant-time comparison

Use hash_equals in PHP or timingSafeEqual in Node for redirects and webhooks.

Verify the raw webhook body

JSON middleware that parses or reformats the body before verification will invalidate the signature.

Fulfill idempotently

Store the payment ID or UUID with a uniqueness constraint before granting access or shipping goods.

Bind payments to your orders

Put an order-specific return URL in the signed payload, then ensure the returned payment ID is expected for that order.

Use HTTPS endpoints

Protect redirect and webhook traffic in transit, and never include secrets in a return URL.

Monitor the address pool

A checkout fails when no unused address is available. Refill well before the count reaches zero.

Troubleshooting

Common integration errors

403 Dynamic pricing requires a Pro plan+

The product belongs to a vendor without an active Pro grant. Use its fixed link or activate Pro.

403 Invalid signature+

The secret, encoded payload, or hex signature differs. Sign the final base64/base64url string—not the original JSON.

500 No available BTC address+

Every address in the vendor pool is reserved or used. Add new, unused addresses in the dashboard.

Webhook signature does not match+

Verify the untouched raw HTTP body. Do not JSON-decode and re-encode it before computing HMAC.

Customer was not redirected+

Redirects happen after a confirmed on-chain payment and depend on the checkout remaining open. Use webhooks for backend fulfillment.

Return URL fields are missing+

For the most portable behavior, encode payloads as URL-safe base64 without padding and include return_url inside the signed JSON.

Protocol reference

At a glance

PurposeMethod & pathAuthPlan
Fixed checkoutGET /pay/{productUuid}NoneBasic
Dynamic checkoutGET /pay/{productUuid}?payload=…&signature=…Payload HMACPro
Browser statusGET /pay/{paymentUuid}/statusNoneBasic
Confirmed redirectYour return URLPayment ID HMACBasic
Confirmation webhookPOST to your webhook URLRaw body HMACPro

HMAC algorithm

SHA-256 → lowercase hex

Supported currencies

btc · usd · eur · ron

Price lock

15 minutes

Unpaid cleanup

48 hours after expiry