Help Center Developer Docs Pay-per-access

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_hash values 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