kiosk-402

video API

Host video, gate playback, pay per call in USDC. No subscription, no invoice, no card on file — you prepay a balance over x402 and calls draw it down.

the shape of it

  1. Create a key on the developers page.
  2. Top up a credit balance. Your wallet signs a USDC transfer authorization; we settle it on Base and credit you what actually landed.
  3. Call the API. Each billable call deducts from the balance.
  4. Run out and every call answers 402 with a pointer back to the deposit endpoint — so an agent can refill itself without a human.

auth

authorization: Bearer k402_<id>_<secret>

Shown once at creation. A key can spend credits but cannot mint another key — that needs a signed-in wallet.

prices

callcost
POST /api/v1/videos$0.01
POST /api/v1/videos/:uid/playback-token$0.001
storage, per minute of video per month$0.01
delivery, per minute watched$0.002
reads — status, account, usagefree

Storage and delivery are billed once a day. Everything is quoted in micro-USDC internally: 1_000_000 = $1.

selling your own content — paywalls

See the button working, with copy-paste code →

The video API is one use of a more general thing. If you already host something — an article, a download, a video, a product — a paywall gives it a "pay with USDC" button that gates it and pays you.

The money never touches us. pay_to is your address, so the buyer's USDC reaches you in the settling transaction. Our fee is 5% (minimum half a cent) billed to your credit balance per confirmed sale — which means an unpaid fee stops new sales rather than holding your money.

POST /api/v1/paywalls

curl -X POST https://kiosk-402.unsubscribe.llc/api/v1/paywalls \
  -H "authorization: Bearer $KIOSK_KEY" -H "content-type: application/json" \
  -d '{"title":"My paid article","price_minor":500000,
       "pay_to_evm":"0xYourWallet","access_ttl_seconds":604800,"mode":"token"}'

{ "id": "pw_…", "verify_secret": "…",          # shown once — store it
  "embed_url": "…/embed/pay/pw_…",
  "checkout_url": "…/pay/pw_…",
  "embed_snippet": "<iframe src=…>" }

price_minor $0.01–$500 · mode is token, redirect or content · pay_to_svm adds Solana · allowed_origins restricts where the button may be embedded (default: anywhere).

Two embeds, two jobs

/embed/pay/<id>Your content, our payment. Takes money for something you host and posts a signed token to your page. The USDC settles to your wallet; we bill 5% to your credits afterwards.
/embed/item/<slug>Our content, on your page. Carries a published kiosk-402 article or video onto your site; the reader pays and it opens in the frame. Splits 50/50 with the creator, as on the storefront.

Both take the same appearance parameters and both report their own height.

Appearance

accentButton colour, hex, with or without #. accentText sets the label on it.
bg · text · borderCard background, text, border. Hex.
radius · buttonRadiusCorners in px, 0–32.
themeauto (default), light, dark.
compact · framecompact=1 is the button alone; frame=none drops the card.
label · title · descYour words instead of ours.

Colours are accepted only as hex literals and rebuilt server-side of the parser — a CSS custom property would otherwise take var(…), url(…) and more from whatever page embedded the widget.

Put the button on your page

<iframe src="https://kiosk-402.unsubscribe.llc/embed/pay/pw_…"
        style="border:0;width:100%;max-width:420px;height:210px"
        title="Pay with USDC"></iframe>

<script>
window.addEventListener('message', (e) => {
  if (e.data?.type !== 'kiosk402:paid') return;
  // e.data.token is a JWT signed with your paywall's verify_secret
  fetch('/unlock', { method: 'POST', body: JSON.stringify({ token: e.data.token }) });
});
</script>

The iframe holds no wallet code. Clicking it opens checkout on our origin, because a buyer approving a payment should be able to see whose address bar they are looking at — inside a cross-origin iframe they cannot. When it completes, the token is posted back to your page.

Verify the token on your server

It is a standard JWT, HS256, signed with your paywall's verify_secret. Verify it offline with any library on any stack — no call to us on every page view:

# Node
import jwt from 'jsonwebtoken';
const claims = jwt.verify(token, process.env.KIOSK_PAYWALL_SECRET);
// { item: 'pw_…', payer: '0x…', sale: '0x…', iat, exp }
if (claims.item !== 'pw_…') throw new Error('wrong paywall');
// exp is already enforced by verify(); serve the content.
# Python
import jwt
claims = jwt.decode(token, os.environ["KIOSK_PAYWALL_SECRET"], algorithms=["HS256"])

The secret is per paywall, so a leak compromises one item rather than all of them. sale is the on-chain nonce — unique per purchase, and a good idempotency key if you record entitlements your side.

Modes

tokenWe return the JWT and nothing else. Your site decides what access means. Most flexible.
redirectWe send the buyer to your redirect_url with ?kiosk_token=… appended. Good for a plain link with no JavaScript.
contentWe hand back a content_url you gave us. Simplest, and only as private as that URL.

two ways to deliver a video

Uploading is the same either way. What differs is who decides a viewer may watch.

1 — hosted on kiosk-402

Publish the video to the catalog with a price. Buyers pay per view over x402, the 50/50 split applies, and the watch page, the paywall and the receipts are ours. You run nothing and write no player code. Use this when the audience is the kiosk-402 audience.

2 — embedded on your own site

Keep the video private and decide for yourself who may watch. Your backend calls playback-token at the moment it grants access, and drops the returned URL into an iframe. We never see your users, and whatever you use to authenticate them — sessions, subscriptions, a different chain entirely — stays yours.

# your backend, once you have decided this viewer is allowed
POST /api/v1/videos/$UID/playback-token   {"ttl_seconds": 3600}
→ { "playback": { "iframe": "https://customer-….cloudflarestream.com/eyJ…/iframe" } }
<!-- your page -->
<iframe src="{{ playback.iframe }}"
        style="border:0;width:100%;aspect-ratio:16/9"
        allow="accelerometer; gyroscope; encrypted-media; picture-in-picture;"
        allowfullscreen></iframe>

Or skip our player entirely and hand playback.hls to hls.js, Video.js, or an Apple device's native player.

The token is the fence, so mint it late and keep it short. Every video is stored with signed-URL enforcement on, which means the uid alone plays nothing. Mint one token per viewer per session rather than embedding a long-lived token in a public page: a token that leaks stops working on its own, whereas one baked into your HTML is a key you handed to everyone who viewed source. If you are gating by your own subscription, set the TTL to the shorter of the session and the subscription.

A note on what this does not do: the token controls the fetch, not the screen. Anyone who can watch can record. This is access control, not DRM, and no HTML5 embed is.

endpoints

POST /api/v1/videos

Reserves an upload and returns a one-time URL. The file goes straight to Cloudflare — it never passes through us, so there is no request-size limit.

curl -X POST https://kiosk-402.unsubscribe.llc/api/v1/videos \
  -H "authorization: Bearer $KIOSK_KEY" \
  -H "content-type: application/json" \
  -d '{"title":"my clip","max_duration_seconds":600}'

{ "uid": "…", "upload_url": "https://upload.…", "charged_minor": 10000,
  "balance_minor": 4990000 }

curl -F file=@clip.mp4 "$UPLOAD_URL"

GET /api/v1/videos/:uid

Poll until status is ready. Transcoding takes roughly as long as the clip. Free.

{ "uid": "…", "status": "ready", "duration_seconds": 42.5, "size_bytes": 1048576 }

Other states: uploading, processing, error (with error_code).

POST /api/v1/videos/:uid/playback-token

Mints a signed, expiring token. Videos are stored with signed-URL enforcement on, so the uid alone plays nothing — this is the only way to watch.

curl -X POST https://kiosk-402.unsubscribe.llc/api/v1/videos/$UID/playback-token \
  -H "authorization: Bearer $KIOSK_KEY" \
  -d '{"ttl_seconds":3600}'

{ "token": "eyJ…", "expires_at": 1789470703,
  "playback": { "iframe": "https://customer-….cloudflarestream.com/eyJ…/iframe",
                "hls":    "…/manifest/video.m3u8",
                "dash":   "…/manifest/video.mpd" } }

Maximum TTL is 24 hours — Cloudflare's ceiling, not ours. Mint a new one per viewer session rather than sharing a long-lived token.

GET /api/v1/account · GET /api/v1/usage

Balance, key info, and 30-day spend — free, because you should never have to spend credits to discover you are out of credits. /account also breaks the spend down by what it went on, which is the part that tells you what to change:

{ "balance_minor": 4000000,
  "spend_30d_minor": 21000,
  "spend_breakdown_minor": {
    "uploads": 10000, "playback_tokens": 3000,
    "storage": 5000, "delivery": 3000, "paywall_fees": 0 },
  "playback_tokens_30d": 3,
  "videos": { "ready": 2, "pending": 0, "minutes_stored": 12.4 },
  "paywalls": { "items": 1, "sales": 8, "gross_minor": 4000000 } }

Paywall gross_minor is what buyers paid you, settled straight to your wallet — it is not a balance held here.

GET /api/v1/paywalls

Your paywalls with per-item sales, revenue, our fees, and how many buyers still have live access. Free.

POST /api/v1/credits/deposit

The x402 loop. Call it once to get a challenge, sign it, call it again with the signature.

# 1. ask
POST /api/v1/credits/deposit   {"amount_minor": 5000000}
→ 402
{ "x402Version": 1, "accepts": [{ "scheme": "exact", "network": "base",
    "maxAmountRequired": "5000000", "payTo": "0x…", "asset": "0x…",
    "extra": { "name": "USD Coin", "version": "2" } }] }

# 2. sign the EIP-3009 TransferWithAuthorization, base64 the payload

# 3. ask again
POST /api/v1/credits/deposit   -H "x-payment: <base64>"
→ 200
{ "ok": true, "credited_minor": 5000000, "balance_minor": 5000000, "tx_hash": "0x…" }

We wait for the transaction to confirm before crediting, so this call can take a few seconds. If your client times out, the balance still lands — the deposit is reconciled within the hour, and replaying the same signed payload is safe: it reports the original receipt rather than charging again.

GET /api/v1/support · POST /api/v1/support

Monthly support, paid the same way as credits. GET lists tiers and your current standing; POST runs the x402 loop for one period.

POST /api/v1/support   {"tier":"supporter","months":1}
→ 402 with payment requirements, sign, repeat with X-PAYMENT
→ { "ok": true, "tier": "supporter", "current_period_end": 1792062703 }

There is no stored payment method and no automatic renewal, because an x402 authorization moves one amount once. Extending is another explicit payment, and paying before the period ends adds to it rather than restarting it.

GET /api/me/profile · PATCH /api/me/profile

Display name, handle and bio for the signed-in wallet. Session auth only — an API key cannot rename its owner.

when you run out

→ 402
{ "reason": "insufficient_credits", "balance_minor": 200, "required_minor": 1000,
  "shortfall_minor": 800,
  "deposit": { "url": "https://kiosk-402.unsubscribe.llc/api/v1/credits/deposit",
               "min_amount_minor": 1000000 } }

limits

Over a limit is 429. Errors are { "error": "<code>", "message": "…" }; messages are deliberately generic.