Pay with HPAY button
The Pay with HPAY button and the verifyPayment check shipped in Games/Biome 1.21.0. They are Beta: tested
by the project's own test suites, not independently audited. Start with small amounts.
The flow is non-custodial. The payer's wallet signs, and the money goes straight to your address. Hartii holds no keys
or funds, takes no fee, and adds no contract. The browser message you get back is a hint, not proof. Only
verifyPayment, run by you on your server, proves a payment.
A live builder is at https://hartiibiome.com/hpay/button. It takes an address, an amount and a reference, makes one
order and renews it before it ends, shows the real button, lists every message the pop-up sends, runs the check,
prints the snippets below and downloads one complete example page. The snippets it prints are run by the test suite
against recorded chain data.
Add the button
<script src="https://hartiibiome.com/hpay.js"></script>
<button data-hpay-request="https://hartiibiome.com/pay?to=<your 0x00 address>&amt=1500000000000000000&ref=order-1001&chain=9&v=1"></button>
<script>
document.addEventListener('hpay:submitted', function (event) {
// event.detail.txHash and event.detail.ref are a hint for your page, not proof.
// Send them to YOUR server. It checks the payment before it ships anything.
});
</script>hpay.js is one classic script with no dependencies. It turns every element that has data-hpay-request into a
button (label from data-hpay-label, default "Pay with HPAY"). Add data-hpay-auto="false" to the script tag to turn
that off. Your server makes the link for each order (see Orders on a server); never take the
amount from the browser.
Or from your own code, inside a click (browsers only open pop-ups after one):
HPAY.pay({ to: '<your 0x00 address>', amt: '1500000000000000000', ref: 'order-1001' }).then(
function (result) { /* result.txHash, result.ref, result.request */ },
function (error) { /* error.code: 'blocked', 'busy', 'closed' or 'invalid-request' */ }
);| API | What it does |
|---|---|
HPAY.requestUrl(fields) | The link for these fields. Throws a TypeError with code invalid-request for anything the pay page would refuse. |
HPAY.pay(linkOrFields, options) | Opens the pay page in a pop-up. Resolves when the wallet returns a transaction id: { txHash, ref, request, popup, verified: false }. verified is always false. Options: onSubmitted, onUpdate. |
HPAY.button(element, linkOrFields, options) | Makes a button. Options: label, unstyled, onSubmitted, onUpdate, onClosed, onError. The element also fires hpay:submitted, hpay:closed and hpay:error with the result or error in event.detail. |
HPAY.mount(scope) | Turns [data-hpay-request] elements into buttons. Safe to call again. |
HPAY.verifyPayment(request, txHash, options) | Loads hpayVerify.mjs from the same origin as the script and runs the check below. Run it on your server. |
Fields are to, amt, usd, ref, tag, exp and memo, with the rules in HPAY Request v1.
amt is exact wei as text or BigInt, never a number. Unknown fields are refused.
Errors: invalid-request and bad-origin are TypeErrors. blocked (pop-up blocked), busy, closed and no-window
are Errors.
- A second click for the same order focuses the open window and returns the same promise.
- A call for a different order while a window is open is rejected with
busyand focuses the open window. - After
closeda payment may still have been sent, and a phone or Blip may never report back at all, so look in your own records first.
The pop-up protocol
The pop-up is the hosted pay page (/pay?...&popup=1), never an iframe: hartiibiome.com cannot be framed. It must
carry a valid v1 request, or it shows the reason and does nothing. Every message is
{ source: 'hpay', v: 1, type, ... }.
| From | Message | Meaning |
|---|---|---|
| pay page to opener | ready | Sent once to * when the page loads. It carries nothing. |
| opener to pay page | hello | The opener's answer, sent to the pay page's origin only. |
| pay page to opener | submitted { txHash, ref } | The wallet returned a transaction id. |
| pay page to opener | confirmed { txHash, ref, confirmations } | The page saw the payment succeed on the chain. |
| pay page to opener | failed { txHash, ref } | The transaction failed on the chain. |
Why a handshake: the pay page cannot know who opened it. It learns the opener's real origin from event.origin of the
hello, which a page cannot fake, and sends results to that origin and no other. Only the window that opened the
pop-up is listened to, only the first answer counts, and an opaque origin is never used. A page that opens the pay page
and never says hello hears ready and nothing else. The pay page tells the payer who asked ("This payment was
requested by" and the opener's origin) and shows a Close this window button after paying. hpay.js accepts a result only from
the window it opened, from its own origin, in exactly the right shape, for the order it asked about, and it never posts
to *.
Shop checklist
- Build the request on your server, one order at a time, each with its own tag (the last digits of the amount), an expiry, and the moment you made it. Never trust an amount, address or reference that came back from the browser.
- The message is a hint for your page. Only
verifyPayment, run by you, proves a payment. - Keep every transaction id you accept, so one payment cannot pay two orders. The reference is not on the chain, so it does not prove which order a payment was for. The tagged amount and the time window do.
- Your content security policy must allow
script-src https://hartiibiome.comforhpay.js. To callHPAY.verifyPaymentin the browser it also needsconnect-src https://rpc.quai.networkand to allow importinghttps://hartiibiome.com/hpayVerify.mjs. Both files are served withAccess-Control-Allow-Origin: *andCross-Origin-Resource-Policy: cross-origin, and are cached for an hour. - Pop-ups must be opened by a click. If the browser blocks one,
HPAY.payrejects withblocked.
Check a payment
verifyPayment(request, txHash, options) is one file with no imports, for Node 18 or newer (a server) or a browser.
Save https://hartiibiome.com/hpayVerify.mjs next to your code:
import { verifyPayment } from './hpayVerify.mjs';
// order = { request, createdAt }, made once on your server when the shopper pressed Buy
const result = await verifyPayment(order.request, txHash, { createdAt: order.createdAt });
if (result.ok) { /* hand over the goods, once for this txHash */ }request is the order's own request: the link, a URL, search params, or an object with to, amt (string or BigInt,
exact wei), tag, exp, ref and usd. It must have:
- a
0xaddress (a name can change owner:needs-address), - the exact
amtit was tagged with, - a
tagthat is the last 1 to 9 digits ofamtand is not all zeros, - an
exp.
options.createdAt (Unix seconds) is required: it is when your server made the order. It may be at most a week
before exp (bad-created-at otherwise), because a createdAt of 1 would accept every old payment of the same amount.
Other options: graceSeconds (0 to 86400, default 0: extra time after exp that you choose to honour), confirmations
(1 to 1000, default 12, a placeholder until finality is measured), rpcUrl (default the public Cyprus-1 node),
fetchImpl, timeoutMs (default 8000) and now (Unix seconds). minimumWei no longer exists: asking for it throws
no-minimum, because "at least this much" accepts a payment made for something else.
A payment is confirmed only if the node says all of this:
- the transaction is in a block with receipt status 1;
- it is a plain transfer, or a QuaiRelay send whose receipt holds the matching
Relayedevent (nothing else counts as a payment); - the money went to the request's address;
- the amount is exactly the request's tagged
amt; - the block time is at or after
createdAtminus 60 seconds, and at or beforeexpplusgraceSeconds(the matcher's window, plus 60 seconds of slack before the order was made, which the matcher does not have); - the block the node returns is the block the receipt names (same hash and number, else
node-mismatch); - it has at least
confirmations.
A payment outside the window is a mismatch, whatever its amount (reason early before the window, window after
it).
What this proves, and what it does not. It proves that a payment of this order's exact tagged amount reached the address inside the order's time window. It does not prove who paid. It does not prove which order the payment was meant for if you reuse an amount, a tag or a transaction id. That is why each order needs its own tag and why you keep every accepted hash. A closed window proves nothing either way. It never accepts a payment because a reference matches, since the reference is not on the chain.
The result is { ok, status, reasons, checks, payment, request, ref, window, checkedAt }. window is
{ notBefore, until }, the Unix seconds the check used.
status | Meaning |
|---|---|
confirmed | Every rule holds. ok is true. |
confirming | Everything else holds; waiting for more confirmations. Look again. |
pending | Sent, but not in a block yet (or the node returned a status it does not recognise). |
not-found | The node does not know the transaction yet. |
failed | The transaction failed, so nobody was paid. |
mismatch | Wrong recipient, wrong amount, outside the time window, or not a plain transfer or relay send. reasons says which. |
checks is { recipient, amount, window, confirmations } (true or false; null where a check could not run). The
Button page labels them "Right address", "Exact tagged amount", "Inside the order's time window" and "Confirmations".
payment is { hash, method, payer, recipient, amountWei, paidAt, blockNumber, confirmations, required }, where
method is plain, relay or other.
It never says "not paid" when it could not look. A node that cannot be reached throws an Error with code
node-unreachable, and a node that answers about something else throws node-mismatch. A request or option that
cannot be trusted throws a TypeError with a code before any network call: bad-hash, bad-confirmations,
no-minimum, needs-amount, needs-tag, weak-tag, needs-expiry, needs-created-at, bad-created-at,
bad-grace, bad-request or needs-address.
Privacy: only the transaction hash (and the block hash the node returned) is sent to the node. Not the order reference, the dollar price or the memo.
Orders on a server
Node 18 or newer. Save https://hartiibiome.com/hpayRequest.mjs and https://hartiibiome.com/hpayVerify.mjs (each one
file, no imports) next to your code. This is the snippet the Button page prints, trimmed:
import { buildRequest, generateTag, tagAmount } from './hpayRequest.mjs';
import { verifyPayment } from './hpayVerify.mjs';
const SHOP = '<your 0x00 address>';
const openOrders = [];
const closedTags = []; // { tag, freeAt }
const accepted = new Map(); // txHash in lower case -> the order reference it paid
const TAG_HOLD_SECONDS = 60 + 30 * 60; // the check's minute of slack, plus half an hour
// Tags no new order may use: open orders, and closed ones until their window and the hold are over.
export function tagsInUse(now = Math.floor(Date.now() / 1000)) {
return [...openOrders.map((o) => o.request.tag), ...closedTags.filter((c) => c.freeAt > now).map((c) => c.tag)];
}
// Call closeOrder(order) when an order is paid or has expired.
export function closeOrder(order) {
const at = openOrders.indexOf(order);
if (at >= 0) openOrders.splice(at, 1);
closedTags.push({ tag: order.request.tag, freeAt: order.request.exp + TAG_HOLD_SECONDS });
}
export function makeOrder(ref, priceWei) {
const tag = generateTag({ inUse: tagsInUse() });
const amount = tagAmount(priceWei, tag); // your price, plus the tag in the last digits
if (!amount.ok) throw new Error(amount.reason);
const createdAt = Math.floor(Date.now() / 1000);
const built = buildRequest({ to: SHOP, amt: amount.amountWei.toString(), tag, ref, exp: createdAt + 900 });
if (!built.ok) throw new Error(built.reason);
const order = { request: built.request, createdAt, url: built.url };
openOrders.push(order);
return order; // the browser opens order.url with HPAY.pay(order.url)
}
export async function checkPayment(order, txHash) {
const used = accepted.get(txHash.toLowerCase());
if (used !== undefined) return used === order.request.ref ? 'paid' : 'already-used';
const result = await verifyPayment(order.request, txHash, { createdAt: order.createdAt });
if (!result.ok) return result.status; // 'confirming', 'pending', 'not-found', 'failed' or 'mismatch'
accepted.set(txHash.toLowerCase(), order.request.ref);
return 'paid';
}A node that cannot be reached throws node-unreachable. That is never "not paid": try again later. The lists live in
memory here so the example runs; keep them in your database.
Dollar-priced orders
import { generateTag, requestFromQuote } from './hpayRequest.mjs';
export async function makeOrderInDollars(usd, ref, openOrders = []) {
const quote = await (await fetch('https://hartiibiome.com/api/hpay/quote?usd=' + usd)).json();
if (!quote.ok) throw new Error(quote.reason); // never guess a price
const createdAt = Math.floor(Date.now() / 1000);
const tag = generateTag({ inUse: openOrders.map((order) => order.request.tag) });
const built = requestFromQuote(quote, { to: SHOP, tag, ref, now: createdAt }); // { ok, request, url }
if (!built.ok) throw new Error(built.reason);
return { request: built.request, createdAt, url: built.url };
}The request ends when the quote does (15 minutes). Open order.url with HPAY.pay(order.url) and later run
verifyPayment(order.request, txHash, { createdAt: order.createdAt }). Keep closed tags out of reuse as in the
previous snippet.
Honest limits
- Beta, not independently audited.
- A shop that skips
verifyPaymentis trusting the browser. - A phone or Blip may never report back, and a closed window proves nothing, so a shop checks its own records.
- The dollar figure in a link is the link maker's own price. The chain only records QUAI.
- 12 confirmations is a placeholder until finality on Quai has been measured on mainnet. You can set it per call.