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
- Create a key on the developers page.
- Top up a credit balance. Your wallet signs a USDC transfer authorization; we settle it on Base and credit you what actually landed.
- Call the API. Each billable call deducts from the balance.
- Run out and every call answers
402with 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
| call | cost |
|---|---|
| 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, usage | free |
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
accent | Button colour, hex, with or without #. accentText sets the label on it. |
|---|---|
bg · text · border | Card background, text, border. Hex. |
radius · buttonRadius | Corners in px, 0–32. |
theme | auto (default), light, dark. |
compact · frame | compact=1 is the button alone; frame=none drops the card. |
label · title · desc | Your 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
| token | We return the JWT and nothing else. Your site decides what access means. Most flexible. |
|---|---|
| redirect | We send the buyer to your redirect_url with ?kiosk_token=… appended. Good for a plain link with no JavaScript. |
| content | We 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
- reads — 600 per 10 minutes per key
- writes — 60 per 10 minutes per key
- playback tokens — 300 per 10 minutes per key
- 5 active keys per account
- deposits — $1 minimum, $1,000 maximum
Over a limit is 429. Errors are
{ "error": "<code>", "message": "…" }; messages are deliberately generic.