Pay-per-access
Sell a file or a piece of content once, and hand it over only after the payment confirms on the network.
The rule underneath all of this: the webhook is the only thing that proves payment. A browser arriving on a page proves nothing.
Flow
Customer → Clicks "Unlock" on your site
Your server → Mints an access token, creates the payment with it as order_id
Your server → Stores the token in the customer's session, then redirects
Customer → Pays from their wallet
Brykto → Sends the order.paid webhook
Your server → Marks that token paid
Customer → Returns to your unlock page, which checks the token
Step 1 - Mint a token and create the payment
The token links one customer to one unlock. Make it unguessable, store it before the customer goes anywhere, and pass it as order_id so it comes back on the webhook.
import os
import secrets
import requests
from flask import redirect, session
BASE = "https://bryktopay.com"
API_KEY = os.environ["BRYKTO_API_KEY"]
CONTENT = {
"q3-report": {"title": "Q3 Report (PDF)", "price": 5.00,
"file": "/files/q3-report.pdf"},
}
@app.post("/unlock/<content_id>")
def unlock(content_id):
item = CONTENT[content_id]
token = secrets.token_urlsafe(32)
db.create_access_token(token, content_id, paid=False)
response = requests.post(
f"{BASE}/v1/payment-links",
headers={"X-API-Key": API_KEY},
json={
"amount": item["price"],
"currency": "USD",
"description": item["title"],
"order_id": token,
},
timeout=10,
)
response.raise_for_status()
# So the customer can find their way back after paying.
session["access_token"] = token
payment = response.json()
return redirect(f"{BASE}/pay/{payment['request_id']}")
import crypto from "crypto";
const BASE = "https://bryktopay.com";
const API_KEY = process.env.BRYKTO_API_KEY;
const CONTENT = {
"q3-report": { title: "Q3 Report (PDF)", price: 5.0,
file: "/files/q3-report.pdf" },
};
router.post("/unlock/:contentId", async (req, res) => {
const item = CONTENT[req.params.contentId];
const token = crypto.randomBytes(32).toString("base64url");
await db.createAccessToken(token, req.params.contentId, false);
const response = await fetch(`${BASE}/v1/payment-links`, {
method: "POST",
headers: {
"X-API-Key": API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: item.price,
currency: "USD",
description: item.title,
order_id: token,
}),
});
if (!response.ok) throw new Error(await response.text());
// So the customer can find their way back after paying.
req.session.accessToken = token;
const payment = await response.json();
res.redirect(`${BASE}/pay/${payment.request_id}`);
});
The price is set server-side
It goes straight from your CONTENT table into the API call and never appears in a page. There is nothing for a customer to tamper with.
Step 2 - Mark the token paid in the webhook
@app.post("/webhook/brykto")
def brykto_webhook():
body = request.get_data()
if not verify(body,
request.headers.get("X-Brykto-Signature", ""),
request.headers.get("X-Brykto-Timestamp", "")):
abort(400)
event = request.get_json()
if event["event"] != "order.paid":
return "", 200
data = event["data"]
if db.already_handled(data["tx_hash"]):
return "", 200
record = db.get_access_token(data["order_id"])
if record is not None:
db.mark_access_token_paid(record["token"], tx_hash=data["tx_hash"])
return "", 200
router.post("/webhook/brykto",
express.raw({ type: "application/json" }),
async (req, res) => {
const ok = verify(
req.body,
req.headers["x-brykto-signature"],
req.headers["x-brykto-timestamp"],
);
if (!ok) return res.status(400).end();
const event = JSON.parse(req.body);
if (event.event !== "order.paid") return res.status(200).end();
const data = event.data;
if (await db.alreadyHandled(data.tx_hash)) return res.status(200).end();
const record = await db.getAccessToken(data.order_id);
if (record) await db.markAccessTokenPaid(record.token, data.tx_hash);
res.status(200).end();
});
Step 3 - Serve the file only if the token is paid
The customer returns to your site with the token in their session.
from flask import send_file, session, abort, render_template
@app.get("/access")
def serve():
token = session.get("access_token")
if not token:
abort(404)
record = db.get_access_token(token)
if record is None:
abort(404)
if not record["paid"]:
return render_template("pending.html"), 202
return send_file(CONTENT[record["content_id"]]["file"],
as_attachment=True)
router.get("/access", async (req, res) => {
const token = req.session.accessToken;
if (!token) return res.status(404).send("Not found");
const record = await db.getAccessToken(token);
if (!record) return res.status(404).send("Not found");
if (!record.paid) return res.status(202).render("pending");
res.download(CONTENT[record.contentId].file);
});
The 202 case matters. A customer can arrive a moment before your webhook handler commits. Show a short "confirming your payment" page that refreshes rather than an error.
The simpler option: email it
Because the API cannot return a customer to your site automatically, the session dance above depends on them coming back on their own.
Passing customer_email when you create the payment and sending the file from your webhook handler avoids all of it. No token, no session, no return trip.
record = db.get_access_token(data["order_id"])
if record is not None:
db.mark_access_token_paid(record["token"], tx_hash=data["tx_hash"])
email_file(record["customer_email"], CONTENT[record["content_id"]]["file"])
For most one-off downloads this is the better trade. The customer waits a few seconds for an email instead of navigating back.
Notes
- Make tokens unguessable.
secrets.token_urlsafe(32)or equivalent, never a sequential ID. - Give tokens an expiry, for example 24 hours after payment, so a shared link does not work forever.
- Store handled
tx_hashvalues and ignore repeats. Brykto retries a webhook up to three times. - Payments expire 30 minutes after creation. Mint the token when they click Unlock, not when the page first renders.
Next steps
- E-commerce checkout - the same shape with a cart total
- Webhooks - payload reference and the security checklist