> Note: the qubic-x402-paywall npm package is not published yet, so the > "Node quickstart" below does not install today. Everything else - the > protocol, the Python ticket signer, the facilitator - works now. # qubic-x402-paywall **Charge per HTTP request, in Qubic (QU).** Your API answers unpaid callers with a `402 Payment Required`; they pay from their own wallet; you serve the content. Built for the [x402](https://x402.org) standard, settling through the QPAY contract on Qubic mainnet. - **Your traffic never leaves your server.** The facilitator only answers one question: *was this payment real, and is it yours?* - **Non-custodial.** Payment goes from the buyer's wallet to yours, through a contract that takes the protocol fee and forwards the rest in the same transaction. Nobody holds your money. - **Any language.** This package is a Node convenience. The wire protocol is below — implement it in Python, Go, Rust or a shell script and nothing changes. You need two things from your [Q+Pay dashboard](https://useqpay.com/dashboard): your **Qubic address** and a **ticket signing key**. --- ## The flow ``` caller your server facilitator │ GET /your/api │ │ ├───────────────────────────►│ │ │ 402 + ticket │ │ │◄───────────────────────────┤ (you mint the ticket) │ │ │ │ │ calls QPAY.Pay on the contract, with the ticket's nonce │ ├──────────────────────────────────────────────────────────►│ chain │ │ │ │ GET /your/api │ │ │ X-PAYMENT: │ │ ├───────────────────────────►│ POST /settle │ │ ├─────────────────────────────►│ │ │ { success: true, payer } │ │ │◄─────────────────────────────┤ │ 200 + your content │ │ │◄───────────────────────────┤ │ ``` --- ## Why there is a ticket A Qubic transaction hash is public the moment it is on-chain. Anyone watching the chain can see that somebody paid you, take that hash, and try to claim the content before the real payer does. The ticket stops that. When you answer with a 402 you mint a **random nonce**, before any payment exists, and hand it only to that one caller. They must put that exact nonce **into the payment itself**. A chain-watcher sees the payment but never saw the nonce, so they cannot claim it. This is why you sign tickets with a key of your own, and why that key must never leave your server. --- ## The protocol Everything below is plain HTTP and HMAC. No Q+Pay code required. ### 1. Answer unpaid callers with 402 ```json { "x402Version": 2, "error": "X-PAYMENT header is required", "accepts": [{ "scheme": "exact", "network": "qubic:mainnet", "amount": "20000", "asset": "QUBIC", "payTo": "DBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHQAH", "maxTimeoutSeconds": 120, "extra": { "sellerId": "YOUR_QUBIC_ADDRESS", "resourceId": "/feed", "settlement": "contract" } }], "paymentTicket": "", "paymentTicketField": "paymentPayload.payload.ticket" } ``` `payTo` is the **contract**, `sellerId` is **you**, and `settlement` tells the buyer **how** to pay — never leave them to guess, because a wrong guess under `"contract"` sends QU to a contract that has no way to return it. Get `payTo` and `settlement` from the facilitator: `GET https://useqpay.com/facilitator/supported` returns `settlement.name` and, when that is `"contract"`, `settlement.contractAddress` — the address to put in `payTo`. One field each, nothing to encode. ### 2. Mint the ticket `base64url(claims) + "." + base64url(HMAC-SHA256(payload, yourKey))` ```json { "v": "pt1", "sellerId": "YOUR_QUBIC_ADDRESS", "resourceId": "/feed", "amount": "20000", "nonce": "0123456789abcdef", "iat": 1790000000, "exp": 1790000600 } ``` | Field | Rule | |---|---| | `v` | exactly `"pt1"` | | `sellerId`, `resourceId` | must equal what you put in `accepts[0]` | | `amount` | **string**, in QU | | `nonce` | **exactly 16 lowercase hex chars** (8 bytes) | | `iat`, `exp` | Unix **seconds** | Base64url with `=` padding stripped, and no spaces in the JSON. The signature covers the encoded payload string exactly as you send it.
Python ```python import base64, hmac, hashlib, json, os, time def b64u(b): return base64.urlsafe_b64encode(b).decode().rstrip("=") def issue_ticket(key, seller_id, resource_id, amount, seconds=600): nonce = os.urandom(8).hex() now = int(time.time()) claims = {"v": "pt1", "sellerId": seller_id, "resourceId": resource_id, "amount": str(amount), "nonce": nonce, "iat": now, "exp": now + seconds} payload = b64u(json.dumps(claims, separators=(",", ":")).encode()) sig = b64u(hmac.new(key.encode(), payload.encode(), hashlib.sha256).digest()) return f"{payload}.{sig}", nonce ```
### 3. The caller pays Read `extra.settlement`: **`"contract"`** — invoke the contract's `Pay` procedure. **Not** a plain transfer: the contract has no procedure for one, so the QU would stay there. | | | |---|---| | Destination | `payTo` (the contract) | | Amount | `amount`, in QU | | Input type | `1` | | Input | 72 bytes: seller pubkey (32) ‖ `SHA-256(resourceId)` (32) ‖ nonce (8) | | Tick | at least **current tick + 20**; + 150 if a person approves in a wallet | The seller pubkey is `extra.sellerId` decoded to its 32 bytes; the nonce is the ticket's 16 hex characters decoded to 8 bytes **in the order written** - do not parse them as a number and re-encode it. (The contract happens to read those bytes as a little-endian uint64; that is its business, not a step for you.) The contract takes the fee and forwards the rest to the seller in the same transaction. **`"direct"`** — a plain transfer of `amount` to `payTo`, with the ticket's nonce (those same 8 bytes) as the transaction payload. **The tick.** A Qubic transaction names the future tick it must land in. Ticks are about half a second apart, so `+10` is often missed and the payment never executes; use `+20` or more from a script, and around `+150` when a person has to unlock a phone and approve in their wallet. Stay well inside `maxTimeoutSeconds`. Either way the caller ends up with an ordinary **transaction id**. That is all they send back — the facilitator reads the transaction, sees who paid, and finds the contract's receipt itself. ### 4. They retry with proof ```http X-PAYMENT: ``` ```json { "x402Version": 2, "accepted": { "scheme": "exact", "network": "qubic:mainnet" }, "payload": { "txHash": "", "ticket": "" } } ``` ### 5. Settle, then serve ```http POST https://useqpay.com/facilitator/settle { "paymentPayload": {...}, "paymentRequirements": {...the accepts[0] you issued...} } ``` ```json { "success": true, "payer": "...", "transaction": "" } ``` **Settle before you serve.** There is also `/verify`, but it is read-only — two concurrent requests carrying the same payment would both pass it. **A payment settles once.** The same payment sent again — a buyer resending the header, a double-click, two racing requests — gets `{ "success": false, "errorReason": "payment_already_used", "transaction": "" }`. So serve exactly when `success` is `true`, and nothing else. The `transaction` on a repeat is there so that, if your first settle response was lost, you can match it against your own records. **Try it with a real wallet:** https://useqpay.com/x402/test pays any x402 endpoint from your own Qubic wallet and shows exactly what it returns - the quickest way to check your 402, your ticket and your settle end to end. ### Rejection reasons | Reason | Meaning | |---|---| | `ticket_missing` | No ticket in the payload | | `ticket_bad_signature` | Wrong key, or `sellerId` does not match | | `ticket_wrong_seller` / `_resource` / `_amount` | Claims disagree with `paymentRequirements` | | `ticket_expired` | Past `exp` — issue a fresh 402 | | `invalid_payment_nonce_mismatch` | On-chain nonce is not the one you issued | | `invalid_transaction_state` | Transaction not found, or no QU moved — if it was only just sent, wait a few seconds and retry with the same proof | | `invalid_exact_evm_payload_recipient_mismatch` | Paid to the wrong address (under `"contract"`, anything but the contract) | | `invalid_exact_evm_payload_authorization_value_mismatch` | Paid a different amount than `amount` | | `invalid_exact_evm_payload_authorization_valid_before` | Paid longer ago than `maxTimeoutSeconds` | | `payment_already_used` | Already settled — see above | | `invalid_payload` | No `txHash` in the payload, or it is malformed | | `invalid_network` | Not `exact` / `qubic:mainnet` | | `invalid_x402_version` | Not `2` | --- ## Node quickstart ```bash npm install qubic-x402-paywall ``` ```js const express = require("express"); const { requirePayment } = require("qubic-x402-paywall"); const app = express(); app.get( "/feed", requirePayment({ sellerId: process.env.QUBIC_ADDRESS, ticketKey: process.env.QPAY_TICKET_KEY, // from your dashboard amount: 20000, // QU, per request resourceId: "/feed", }), (req, res) => { // Paid. req.payment = { payer, reference, amount, resourceId } res.json({ data: "the goods" }); } ); app.listen(3000); ``` That is the whole integration. The middleware issues the 402, mints the ticket, settles the payment and populates `req.payment`. ### Options | Option | Default | | |---|---|---| | `sellerId` | — | **Required.** Your Qubic address | | `ticketKey` | — | **Required.** From your dashboard. Never commit it | | `amount` | — | **Required.** QU per request | | `resourceId` | route path | Pass your own if a query parameter changes *what* is sold or its price | | `maxTimeoutSeconds` | `120` | How long a payment stays acceptable | | `onPaid` | — | `async (payment, req) => {}` for your own logging | | `facilitator` | Q+Pay's | `{ url }` if you run your own | | `channel` | — | `{ deposit }` to also offer a prepaid channel — see below | Also exported: `issueTicket`, `verifyTicket`, `readNonce`, `createClient`, `resolvePayTo` — for building the flow by hand. **Zero runtime dependencies.** Node 18+ (it uses the built-in `fetch`). --- ## Prepaid channels Plain x402 is one on-chain payment per request: right for a person buying an article, slow and costly for an agent calling your API five hundred times. A **prepaid channel** takes one payment up front and then spends from it, request by request, with no transaction and no wait. ```js requirePayment({ sellerId, ticketKey, amount: 1000, // QU per request, spent from the channel resourceId: "/quote", channel: { deposit: 100000 }, // what the buyer prepays, once }) ``` Your 402 then carries a `channel` offer next to the usual `accepts`, so a one-off buyer can still pay plain x402. `req.payment` for a channel request is `{ payer, via: "channel", channelId, remaining, amount, resourceId }`, and the response carries `X-CHANNEL-REMAINING`. **How it works** 1. **Deposit.** The buyer pays `deposit` QU exactly like any x402 payment, for the resource `channel:open`, using the ticket in the offer. It goes through the QPAY contract into your wallet at once; the fee is taken once, on the deposit. That opens (or tops up) the channel `:`. 2. **Vouchers.** Every request carries a voucher the buyer signed with their wallet key: "my total spend with this seller is now *N*". The facilitator accepts it only when *N* is exactly the previous total plus your price, so a voucher pays for one request, once, and never more than the price. 3. **Top-up.** When the credit runs out, the next 402 is paid with another deposit on the same channel. A channel belongs to one buyer and one seller, across all your endpoints. Only you can redeem its vouchers: each redeem is signed with your ticket key, so a voucher copied off the wire is worthless to anyone else. **Trust.** The deposit is yours the moment it is paid, the same as prepaid API credit anywhere. Unused credit is yours to refund, and buyers will only prepay amounts they are willing to trust you with, so keep deposits modest. ### Try it `https://useqpay.com/x402/demo/prepaid` is a live endpoint that offers a channel: 1,000 QU a call from a 10,000 QU deposit. The agent side, in one file with no dependency beyond `@qubic-lib/qubic-ts-library`, is [`examples/prepaid-agent.js`](https://useqpay.com/docs/x402/prepaid-agent.js): ```bash curl -o prepaid-agent.js https://useqpay.com/docs/x402/prepaid-agent.js npm install @qubic-lib/qubic-ts-library DRY_RUN=1 QUBIC_SEED=... node prepaid-agent.js https://useqpay.com/x402/demo/prepaid QUBIC_SEED=... node prepaid-agent.js https://useqpay.com/x402/demo/prepaid 5 ``` The first call pays the deposit and waits for it to confirm; the next four are paid by voucher and return at once. ### The wire protocol The 402 body gains: ```json "channel": { "deposit": "100000", "price": "1000", "requirements": { "...": "an x402 requirement at the deposit amount, extra.resourceId = \"channel:open\"" }, "ticket": "", "openHeader": "X-CHANNEL-OPEN", "voucherHeader": "X-CHANNEL-VOUCHER", "channelId": ":", "voucherMessage": "channel::", "balanceUrl": "https://useqpay.com/facilitator/channel" } ``` The buyer: - reads their balance with `GET /` (404 = never opened) → `{ deposit, spent, remaining, ... }`; - if there is no channel or `remaining < price`, pays the deposit (contract `Pay` with the ticket's nonce, as in step 3 above) and sends `X-CHANNEL-OPEN: base64(paymentPayload)` — the same payload an `X-PAYMENT` header carries; - signs the voucher: SchnorrQ over the KangarooTwelve-256 digest of the UTF-8 string `channel::`, with the wallet's key (`@qubic-lib/qubic-ts-library`: `K12(message, digest, 32)` then `schnorrq.sign(privateKey, publicKey, digest)`), and sends `X-CHANNEL-VOUCHER: base64({"channelId","cumulativeAmount","signature"})`, signature as 128 hex characters. Send both headers on the first request; the deposit is settled first, then the voucher is spent. While the deposit is not on chain yet the answer is 402 `invalid_transaction_state` — send the same request again. Any refused voucher comes back with `channelState` (the facilitator's `spent` and `deposit`) so a client that fell out of step can sign the right total. | Reason | Meaning | |---|---| | `voucher_amount_mismatch` | Total is not previous spend + price (replayed or stale) | | `voucher_exceeds_deposit` | Not enough credit left — top up | | `voucher_bad_signature` | Not signed by the channel's buyer | | `channel_not_found` | Never opened — send `X-CHANNEL-OPEN` | | `not_a_channel_deposit` | The deposit was not paid for `channel:open` | The seller side, for other languages: `POST /channel/open` with `{ paymentPayload, paymentRequirements }`, and `POST /channel/redeem` with `{ channelId, sellerId, price, cumulativeAmount, signature, sellerAuth }`, where `sellerAuth` is `hex(HMAC-SHA256(ticketKey, "redeem:::"))`. Both answer 200 `{ success: true, ... }` or 402 `{ success: false, errorReason }`. --- ## What to charge The protocol fee is **0.75%, with a floor of 100 QU**: | Price | Fee | Net | Effective | |---|---|---|---| | 1,000 QU | 100 | 900 | **10%** | | 10,000 QU | 100 | 9,900 | 1.00% | | 100,000 QU | 750 | 99,250 | 0.75% | **Below 13,333 QU the floor costs more than the percentage.** Price a per-request resource at or above that and you pay the headline rate; price it at 1,000 QU and you pay ten times it. **Prepaid channels avoid this:** the fee is paid once, on the deposit, so a 100,000 QU deposit spent at 1,000 QU a request costs 0.75% in total. ## Keys Your **ticket key** signs your 402s. It is not an API credential and should never be sent anywhere. If it leaks, an attacker can forge tickets **only for resources whose payments go to your wallet** — letting them front-run your own buyers, and nothing else. Rotate it in the dashboard; rotating invalidates every 402 already handed out, so do it when you are not selling. ## Licence MIT