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.
Quickstart
Your first checkout
The Basic integration is just a hosted payment link. Setup happens once in the dashboard.
-
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
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
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
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
BasicLink 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.
<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
ProSign 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.
amountPositive numeric price, for example 49.90currencybtc, usd, eur, or ronreturn_urlOptional HTTPS destination after confirmation<?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);
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());
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:
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
$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.
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
ProFulfill 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:
{
"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
$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);
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);
});
Payment lifecycle
What happens after the click
Checkout opens
The signed payload is verified (when present), price is resolved, and one unused address is reserved.
Quote is locked
Fiat prices are converted to BTC and displayed with a 15-minute countdown.
Chain is checked
SatsCollect checks unconfirmed requests approximately every two minutes using confirmed on-chain totals.
Payment confirms
When the address has received at least the expected BTC amount, the request is marked confirmed and the Pro webhook is attempted.
Customer returns
The checkout notices confirmation and opens the one-time signed redirect, or the built-in confirmation page.
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
| Purpose | Method & path | Auth | Plan |
|---|---|---|---|
| Fixed checkout | GET /pay/{productUuid} | None | Basic |
| Dynamic checkout | GET /pay/{productUuid}?payload=…&signature=… | Payload HMAC | Pro |
| Browser status | GET /pay/{paymentUuid}/status | None | Basic |
| Confirmed redirect | Your return URL | Payment ID HMAC | Basic |
| Confirmation webhook | POST to your webhook URL | Raw body HMAC | Pro |
HMAC algorithm
SHA-256 → lowercase hex
Supported currencies
btc · usd · eur · ron
Price lock
15 minutes
Unpaid cleanup
48 hours after expiry