Accept payments
Deneb Pay lets a project accept Minter payments straight to its own address. The money goes directly from the payer's wallet to the merchant's address: Deneb never holds the funds and never confirms a payment for the merchant. The merchant builds the link, watches the network and settles the order itself. Deneb Pay is free right now (like the API).
The flow has three parts, and the merchant is in control of all of them:
- Show the invoice — a payment link that opens the Deneb wallet pre-filled, or a checkout window that works with
any Minter wallet.
- Verify the payment — the merchant's backend reads the transaction from the network and checks its fields against
the invoice (recipient, coin id, exact amount in pip, payload). A JS SDK is planned (ADR 0038); for now the check is a few field comparisons you write yourself.
- Settle — one atomic database write that credits the order exactly once.
The payment link
The primary form opens the Deneb web wallet with a Send screen pre-filled. It is a plain link:
https://wallet.denebnav.app/pay?to=…&coin=…&amount=…&order=…[¬After=…]The link never sends anything by itself: the payer still reviews the screen and signs with their password or passkey. Nothing is signed or sent from the link alone.
Parameters
| Parameter | Rule |
|---|---|
to | The merchant's receiving address: Mx + 40 hex. |
coin | Exactly one of BIP, USDTE, USDTBSC (case-sensitive). |
amount | A decimal string, greater than 0, at most 18 digits after the dot, no sign and no exponent. |
order | The merchant's order id: 1–64 characters of [A-Za-z0-9_-]. The wallet puts it in the transaction payload, exactly dnbpay:<order>. |
notAfter | Optional. The invoice expiry, unix time in seconds. An expired invoice is not signed. |
to, coin, amount and order are required; notAfter is optional. Parsing is strict: every key appears at most once, keys are case-sensitive, and a value that does not match its rule rejects the link — nothing is silently fixed or ignored.
Coins. A symbol is mapped to a fixed coin id in the wallet (BIP = 0, USDTE = 1993, USDTBSC = 3494), never to whatever the network currently answers for that symbol. When you verify the payment, match the coin id, not the symbol.
Amounts are strings. 1 coin = 10^18 pip. Convert the decimal amount to pip by multiplying by 10^18 as a bigint, never with floating point. There is no dollar amount and no exchange rate in the link.
Build the query with URLSearchParams (or your language's equivalent) so every value is encoded:
const invoice = { to: 'Mx…', coin: 'USDTE', amount: '10.5', order: 'ORD-20261020-0007', notAfter: 1760300000 };
const payLink = 'https://wallet.denebnav.app/pay?' + new URLSearchParams(invoice).toString();Validate the values yourself against the rules above; the wallet rejects a link that does not parse strictly.
The checkout window (iframe)
To accept a payment from any Minter wallet — not only the Deneb wallet — embed the checkout window on the public Deneb origin:
https://denebnav.app/checkout?to=…&coin=…&amount=…&order=…[¬After=…]&embed=<your-origin>It takes the same parameters as the payment link, plus embed — the exact https:// origin of the page that frames it (no path, no query, no trailing slash). The window shows the amount and coin, the recipient address, the dnbpay:<order> message (each with a Copy button), a QR code, a timer when the invoice has a notAfter, and a "Pay in the Deneb wallet" button that opens the payment link above.
Only listed origins may embed it. The window can be framed only from an origin in Deneb's allow list; any other origin gets a page that cannot be framed. To have your origin added to the list, contact the Deneb team. The window carries no keys and signs nothing; it only shows the invoice and reports what it saw.
<iframe
src="https://denebnav.app/checkout?to=…&coin=USDTE&amount=10.5&order=ORD-…&embed=https://game.example"
sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"
allow="clipboard-write"
referrerpolicy="no-referrer"
title="Deneb Pay"></iframe>allow-popups allow-popups-to-escape-sandbox are needed by the "Pay in the Deneb wallet" button, which opens the wallet at the top level, never inside the frame. Without allow="clipboard-write" the window shows the text selected for manual copying instead of offering a Copy button.
Status messages are only a hint. The window posts to the parent page a message { "type": "deneb-pay", "status": "waiting" | "seen" | "expired", "order": "…", "hash"? }, with targetOrigin set to your origin. This is a signal to re-check your own backend — never a reason to release the goods. Anyone can fake a postMessage in the payer's browser.
const frame = document.getElementById('deneb-pay');
window.addEventListener('message', (event) => {
if (event.origin !== 'https://denebnav.app') return; // only Deneb
if (event.source !== frame.contentWindow) return; // only our iframe
const m = event.data;
if (!m || m.type !== 'deneb-pay' || typeof m.order !== 'string') return;
if (m.status === 'seen') refreshOrderStatus(m.order); // ask your backend; it decides
});Verify a payment
A payment counts only when the transaction, read from the network, matches the invoice in every one of these ways:
- it is in a block (not in the mempool);
- its type is Send (1) — Multisend and every other type is not a payment;
- its execution code is 0 (success);
- the recipient is the merchant's address;
- the coin id (not the symbol) is the expected one;
- the amount in pip is exactly the invoice amount;
- the payload is byte-for-byte
dnbpay:<order>; - if the invoice has a
notAfter, the block time is not after it.
Minter is a Tendermint chain: a transaction that is in a block is final, so there are no confirmations to wait for. A Send in a block with code 0 is the payment.
A JS SDK with a ready verification helper is planned (ADR 0038); until it ships, the check is a handful of field comparisons you write yourself. All the deciding fields — hash, recipient, coin, amount in pip, payload, execution code — must come from a Minter node, never from an indexer list (an indexer can be stale and carries no execution code). First get the transaction hash: the checkout window reports it in the postMessage hash field, or you watch the address on your own node. Then read the transaction by hash and compare the fields:
const NODE = 'https://your-node.example/v2'; // your Minter node, API v2 base
// The hash arrives with the checkout window's postMessage (`hash`), or from your own node watching the address.
const tx = await fetch(`${NODE}/transaction/${hash}`).then((r) => r.json());
// Compare the transaction's fields with the invoice (ADR 0026 §4). The node may give ids and the code as
// numbers or numeric strings, so coerce them before comparing.
const inBlock = tx.height >= 1; // in a block, not the mempool
const isSend = tx.type === 1; // Send; Multisend is not a payment
const ok = Number(tx.code) === 0; // executed successfully
const toOk = tx.data.to.toLowerCase() === to.toLowerCase(); // recipient
const coinOk = Number(tx.data.coin.id) === 1993; // coin id, not the symbol
const amountOk = BigInt(tx.data.value) === amountPip; // exact amount in pip
const payloadOk = atob(tx.payload) === `dnbpay:${order}`; // payload, byte-for-byte
if (inBlock && isSend && ok && toOk && coinOk && amountOk && payloadOk) settle(order, hash);amountPip is a bigint (the decimal amount × 10^18), never Number. The node gives the value in pip and the payload base64-encoded, so atob decodes it for the byte-for-byte comparison. If the invoice has a notAfter, compare it against the transaction's block time; the node answer carries no block time, so take it from the block (GET /block/{height}) or your indexer. Without a block time, an invoice with notAfter is not credited — on purpose.
A public method that lists one address's transactions (the Deneb "accounts" API) is planned (ADR 0038). Today the public Deneb API serves the latest-transactions feed (GET /v1/transactions) and single addresses (GET /v1/addresses/{addr}), not an address's history — so watch for the hash from the checkout window's event or from your own node.
Settle atomically
The same transaction can arrive twice (two sources, a worker restart), and one order can be paid twice. So settlement is one atomic write, unique by order and by hash, in a single database transaction together with the delivery:
CREATE TABLE payments (
order_id TEXT PRIMARY KEY, -- UNIQUE(order)
tx_hash TEXT NOT NULL UNIQUE, -- UNIQUE(hash)
paid_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE refunds (tx_hash TEXT PRIMARY KEY, order_id TEXT NOT NULL, seen_at TIMESTAMPTZ NOT NULL DEFAULT now());BEGIN;
INSERT INTO payments (order_id, tx_hash) VALUES ($order, $hash)
ON CONFLICT DO NOTHING
RETURNING order_id;
-- a row came back → deliver the goods right here, in this transaction;
-- no row and payments.tx_hash = $hash already exists → the same transaction again, do nothing;
-- no row and order_id is taken by another hash → INSERT INTO refunds (tx_hash, order_id) … ON CONFLICT DO NOTHING; do not deliver.
COMMIT;Deliver the goods only where the INSERT returned a row. A second payment for an already-paid order is recorded "to refund" and handled manually, never credited as a new payment. Issue order ids from your backend as random strings, not sequential numbers, so a neighbour id cannot open someone else's order status.
What is not here yet
- Webhooks — planned, coming later. Watch the network yourself for now.
- Refunds — a second payment is recorded for manual handling; there is no automated refund.
- Dollar prices — amounts are in the coin, no USD conversion in the link or the window.
- No merchant name, description or return address in the first version.