Release a digital product only when the signed invoice.paid webhook arrives and your server has read the same status back from the API. Do not deliver when the buyer returns to your site, and do not deliver on invoice.confirming, because that payment isn't final yet. Issue each licence once, tied to your order ID, so a repeated webhook can't create a second key. Crypto Merchant charges 1% of each paid invoice, with no monthly fee, and buyers usually get their product between about 1 and 20 minutes after paying, depending on the network.
Last checked: 6 November 2026. This guide covers what happens after the payment: delivery, wrong amounts, refunds and access. For the overview and the invoice request, see selling digital goods with Crypto Merchant.
Which delivery setup fits a digital product?
| Setup | Best for | How delivery happens | What you build |
|---|---|---|---|
| Payment link per product | A few products, low volume | You get an email for each payment and send the file yourself | Nothing |
| Invoice per order, with a webhook | Shops, software, courses, licence keys | Your server delivers automatically on invoice.paid | One API call and a webhook endpoint |
| Sticky wallet per customer | Credit packs and account top-ups | Your server adds credit on each confirmed deposit | A customer balance in your own system |
Sticky wallets, which give each customer their own deposit address, are chosen at sign-up. See the sticky wallet docs. The rest of this guide uses the invoice setup.
When is it safe to release the product?
It is safe once the invoice status is paid. The status changes when the payment has its required confirmations and counts as covering the invoice.
| Event or signal | What it means | Deliver? |
|---|---|---|
Buyer lands on your redirect_url | The buyer left the invoice page | No. It proves nothing about payment |
invoice.confirming | The payment was seen, but isn't final | No. Show "payment received, confirming" |
invoice.underpaid | The confirmed payment is short by more than your tolerance | No. The buyer can send the rest or get a refund |
invoice.paid | Confirmed and within your tolerance | Yes |
invoice.expired | Time ran out before any payment was seen | No, but keep the order so it can still be fulfilled (see below) |
The redirect is for the buyer's convenience only. Anyone can open that URL, so it should show the order's state from your database: "waiting for confirmation" until your webhook has done its work, then the download.
How long will the buyer wait?
The wait depends on the network the buyer chooses. These are the times from our network status page on 6 November 2026, worked out as confirmations multiplied by block time:
| Network | Confirmations | Typical wait |
|---|---|---|
| BNB Smart Chain, Base, Arbitrum, Tron, Solana | 15–32 (varies by network) | About 1 minute |
| TON, XRP Ledger | 1 | About 1 minute |
| Ethereum, Polygon | 12 on Ethereum, 64 on Polygon | About 2 minutes |
| Litecoin | 6 | About 15 minutes |
| Bitcoin | 2 | About 20 minutes |
Tell buyers this before they pay. A line like "Your download link arrives by email once the network confirms your payment, usually within minutes; about 20 minutes for Bitcoin" prevents most "I paid, where is my file?" messages.
How do I issue a licence key only once?
Give your licences table a unique key on your order ID, and create the licence inside the job that handles invoice.paid. Webhooks are delivered at least once, so the same event can arrive twice. The webhook retries and reconciliation guide shows how to save events and skip duplicates; this is the delivery step that runs after that.
CREATE TABLE licences (
order_id text PRIMARY KEY,
invoice_id text NOT NULL,
licence_key text NOT NULL,
delivered_at timestamptz,
revoked_at timestamptz
);import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
const API = "https://cryptomerchant.online/api/v1";
// A download link that stops working at expiresAt (Unix seconds).
export function signDownload(orderId, expiresAt, secret) {
const sig = createHmac("sha256", secret).update(`${orderId}.${expiresAt}`).digest("hex");
return `/download?order=${encodeURIComponent(orderId)}&exp=${expiresAt}&sig=${sig}`;
}
export function checkDownload(orderId, expiresAt, sig, secret, now = Date.now() / 1000) {
if (Number(expiresAt) < now) return false;
const expected = createHmac("sha256", secret).update(`${orderId}.${expiresAt}`).digest();
const given = Buffer.from(String(sig), "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}
// Runs from your background job for each saved invoice.paid event. Safe to run twice.
export async function deliverOrder(event, { db, sendDeliveryEmail, fetchFn = fetch }) {
const res = await fetchFn(`${API}/invoices/${event.data.id}`, {
headers: { Authorization: `Bearer ${process.env.CM_SECRET_KEY}` },
});
if (!res.ok) throw new Error(`Invoice read failed: ${res.status}`); // the job tries again later
const invoice = await res.json();
if (invoice.status !== "paid") return;
// The unique key on order_id means a second run finds the first run's licence.
await db.query(
`INSERT INTO licences (order_id, invoice_id, licence_key) VALUES ($1, $2, $3)
ON CONFLICT (order_id) DO NOTHING`,
[invoice.order_id, invoice.id, randomBytes(16).toString("hex")],
);
const { rows } = await db.query(`SELECT licence_key, delivered_at FROM licences WHERE order_id = $1`, [invoice.order_id]);
if (rows[0].delivered_at) return;
const expiresAt = Math.floor(Date.now() / 1000) + 7 * 24 * 3600;
const link = signDownload(invoice.order_id, expiresAt, process.env.DOWNLOAD_SECRET);
await sendDeliveryEmail(invoice.customer_email, rows[0].licence_key, link);
await db.query(`UPDATE licences SET delivered_at = now() WHERE order_id = $1`, [invoice.order_id]);
}If the email fails, the error stops the job before delivered_at is set. The next attempt finds the same licence and sends it again. In the rare case that the email goes out and the final update fails, the buyer gets the same key twice, which does no harm.
Three more points:
- Set the price on your server. Create the invoice from your own catalogue, never from a price sent by the browser. Then a paid invoice for an order is enough to deliver that order's product.
- Send
order_idandcustomer_emailwhen you create the invoice. Both are optional in the API, and this code needs them. - Save
invoice.idwith the order when you create the invoice, so the webhook can be matched to an order you actually created.
How should I protect the download?
Use a link that expires instead of a permanent file URL. The code above signs the order ID and an expiry time, so changing either one breaks the link. Check it with checkDownload on your download route, and also check that the licence has not been revoked.
| Risk | Sensible control |
|---|---|
| The link gets shared | Expiry of a few days, and a limit on downloads per order |
| The buyer loses the email | A "resend my link" page that makes a new link for the order's email address |
| A key gets reused on many devices | An activation limit in your own licence server |
| A file URL is guessed | Store files outside the public web root and serve them only after the check |
Crypto Merchant does not host your files or issue licence keys. It takes the payment and tells your server, so all of the above sits in your own system.
What if the buyer sends the wrong amount?
Most wrong amounts come from an exchange taking its withdrawal fee out of what the buyer typed. Set an underpayment tolerance in your settings, and these rules apply:
- Short by no more than your tolerance: the invoice counts as paid, and you deliver as normal.
- Short by more: the invoice becomes
underpaid. The buyer can send the rest to the same address, and you deliver wheninvoice.paidfollows, or the buyer can get a refund of what they sent. - Too much: the invoice still counts as paid, so you deliver. The extra is held for the buyer, who withdraws it from the invoice page. You don't need to do anything.
Details are in underpaid and overpaid crypto payments.
What if the payment arrives after the invoice expires?
Don't close the order for good on invoice.expired. Invoices stay open for 30 minutes by default (you can set 15 minutes to 24 hours), but an expired invoice's address is still watched for at least 24 hours, and a late payment can still count towards it. Mark the order as "expired, can still be paid", and let a later invoice.paid run the same delivery job.
If you sell something limited, such as a numbered licence or a seat in a course, hold it only for the invoice's time limit and decide in advance what happens if a late payment arrives after it has sold out. A refund link covers that case.
How do refunds work for digital products?
A confirmed crypto payment can't be reversed by a bank or card network, so there are no chargebacks. A refund happens only when you start one. An Admin creates a private refund link from the paid payment in the portal, and the buyer enters their own address for that coin and network. The link stays open for 14 days, the network fee comes out of the refund, and the 1% fee is not returned. See how to refund a crypto payment safely.
Crypto Merchant sends invoice.refunded each time a refund link is claimed, so a partial refund sends one too. Use that event to deal with access, and read the invoice from the API before you act, as with invoice.paid:
| Invoice after the event | What it means | Suggested action |
|---|---|---|
Status refunded | You refunded everything you kept from the payment | Set revoked_at, and stop the key and the download link |
Status still paid, refunded_amount above 0 | A partial refund | Your policy: keep access, or reduce it |
Do buyers in the EU have a right to cancel a digital purchase?
Usually yes, unless they waive it at checkout. EU consumers generally have 14 days to withdraw from an online purchase. Article 16(m) of the Consumer Rights Directive removes that right for digital content only when delivery has started with the buyer's prior express consent and their acknowledgment that they lose the right, and you have confirmed this to them. That usually means a checkbox before payment, plus a confirmation you send afterwards. It applies to consumers, not business buyers, and paying in crypto doesn't change it. This is general information, not legal advice, and rules differ for subscriptions and by country.
What does it cost to sell digital products this way?
Crypto Merchant charges 1% of each paid invoice, with no setup or monthly fee. Expired and cancelled invoices cost nothing. At 1%, a $19 product costs you $0.19, and $18.81 is credited to you.
The buyer also pays a gas fee for moving their payment, shown next to the price and never more than $5. On most networks it is a few cents. USDT on Tron costs more, which matters for cheap products: a buyer of a $5 template will notice it. If you sell low-priced products, tell buyers that other networks are cheaper, or pre-select a coin with the coin field. The API returns the amount as network_fee_usd.
Moving coins later (sweeps and payouts) carries a gas fee too, from your gas balance or from the coins being moved. See pricing for the full list.
What can't be sold?
The Terms prohibit, among other things, goods that infringe intellectual property, stolen data or accounts, and malware or hacking services. Resold licences and "lifetime access" account bundles are the usual trouble spots in digital goods, so check that you have the right to sell what you list. The full list is on the eligibility page. No identity documents are needed to create an account.
More detail: webhooks docs, the API reference and crypto payment links. Crypto Merchant is custodial; see custodial vs non-custodial.