Tessera connector spec¶
This document is for whoever writes a plugin, webhook, or native service on a platform.
Read native events, translate them, and call start, stop, or tips. Load Tessera UI in the browser for viewer wallets and paywalls.
Also read: Getting Started · Viewer wallet · Integrated Platforms
Which of the three to call¶
- Time (video, live, a track or podcast while it plays):
sessions/startthensessions/stop. - A gesture (photo, post, boost, citation, download, "support this"):
tips.
Do not call start for tip-only resources. Use initTipMode in the browser and tips when the viewer taps support.
1. What the plugin must have¶
Reach Tessera¶
- Tessera Base URL: origin the platform server uses to POST. How to pick it: Getting Started. Confirm with
GET {Base URL}/health. - Ingest secret: same value as
TESSERA_INGEST_SECRETon Tessera (.env.example). Used only for HMAC onstartandstop.
Paths below are relative to {Base URL}/api/core. CIRCLE_* keys stay in Tessera .env, not in the plugin.
Base URL vs browser URL
| Used by | Example | |
|---|---|---|
| Base URL | Plugin server (start / stop HMAC) |
http://127.0.0.1:7878 |
| Browser / PUBLIC_URL | Viewer loading paywall.bundle.js and paywall API calls |
https://tessera.example.com or your plugin relay origin |
They are often the same origin in dev. In production the browser usually hits a plugin relay on the platform origin (see Browser UI and relay).
Resolve before you POST¶
The plugin computes these before each POST:
| Field | Meaning |
|---|---|
userId |
Tessera viewer id (see userId format below). Same value on start, stop, and tips. |
resourceId |
videoId, trackId, photoId, postId, streamId, ... |
payoutAddress |
Creator (or primary payee) USDC address on Arc. |
ratePerSecond |
Decimal USDC string. Required on time-based start. Must be >= 0; use a value > 0 for real billing. |
splits |
Optional array. Each entry: { address, fraction, label? }. Sum of fractions <= 1. Remainder goes to payoutAddress. label is log-only. Time-based start only. |
amount |
Decimal USDC string (e.g. "0.100000"). tips only. |
metadata |
Optional. Opaque key/value strings for your connector logs. Tessera billing does not read it today. |
tips has no splits. One payoutAddress per tip.
userId format (critical)¶
userId is not your platform's native account id. Tessera assigns it after the viewer signs in through the paywall (Circle email OTP or Google).
Valid formats (7-256 chars):
| Prefix | Example | When set |
|---|---|---|
email: |
email:viewer@example.com |
Email OTP login |
social: |
social:104083036205001006721 |
Google / Facebook login |
arc_ |
arc_mlbogxpfyg |
Legacy sessions only |
Invalid: user-42, PeerTube account ids, Jellyfin user ids, random UUIDs without a valid prefix.
Rule: the browser paywall and your server must use the same userId.
- Load
paywall.bundle.jsand callinitPaywall()orinitTipMode(). - Viewer completes Circle login in the paywall.
- Paywall stores
userIdinlocalStorageunderarc_cashier_user_id. - Your connector client reads that value and sends it on
start/stoppings to your plugin server. - Your plugin server forwards the same
userIdin HMACstart/stop(and the browser sends it ontips).
If you POST start before the viewer has logged in, or with a platform-native id, register-session and tips will fail.
2. Browser UI and relay¶
Embed the paywall¶
The bundle does not auto-init. Load it from Tessera or from your relay, then call the right init for the resource billing mode.
<!-- Serve from Tessera PUBLIC_URL or from your plugin relay (see below) -->
<script src="https://tessera.example.com/assets/paywall.bundle.js"></script>
<script>
// Exclusive / pay-per-second (locks media until funded)
window.ArcCashier.initPaywall();
// Free / tip-only (optional second argument: default tip amount in USDC, e.g. 0.10)
// window.ArcCashier.initTipMode('0xCreatorAddress...', 0.10);
// Creator earnings panel in your platform settings UI
// window.ArcCashier.initCreatorEarnings({ wallet: '0xCreator...', mount: '#earnings' });
</script>
Admin vs creator
- Platform has an admin and creators: use both. Admin: plugin settings dashboard. Creator:
initCreatorEarnings. - Platform is admin-only: use the admin one (plugin settings dashboard).
API base derivation: paywall resolves ARC_API_BASE from the script URL by stripping /assets/<bundle>. Example:
- Script:
https://tessera.example.com/assets/paywall.bundle.js→ APIhttps://tessera.example.com - Script:
https://peertube.example.com/plugins/tessera/assets/paywall.bundle.js→ APIhttps://peertube.example.com/plugins/tessera
If the bundle is served from the wrong path, all /api/core/* calls go to the wrong host.
Assets
| File | Required | Notes |
|---|---|---|
paywall.bundle.js |
Yes | You load this via <script src>. |
paywall.css |
Auto | Injected by initPaywall, initTipMode, and initCreatorEarnings from the same directory as the script. |
creator-earnings.css |
Auto | Injected by initCreatorEarnings from the same directory as the script. |
Viewer paywall, tip button, and creator earnings are Tessera UI. Plugins call initPaywall / initTipMode / initCreatorEarnings. They do not restyle .arc-tessera-root.
No other static files are required for the viewer paywall.
initPaywall(targetContainer) - optional HTMLElement or CSS selector. Accepted for API compatibility; the overlay currently mounts fullscreen on document.body (player-contained mounts break hit-testing on some hosts).
initTipMode(creatorWallet, tipAmount) - creatorWallet is a 0x EVM address. tipAmount is a USDC number (default 0.10 if omitted). Tips are sent as decimal strings with up to 6 places.
Plugin relay (recommended in production)¶
Tessera enables CORS on all routes, but production setups usually proxy Tessera through the platform so:
- The browser stays on the platform origin (cookies, OAuth redirects).
- Tessera can remain server-to-server only for ingest HMAC.
Proxy at minimum:
| Path | Purpose |
|---|---|
/assets/paywall.bundle.js |
Paywall script |
/assets/paywall.css |
Paywall styles (if not loaded via bundle injection path) |
/assets/creator-earnings.css |
Creator earnings styles (if not loaded via bundle injection path) |
/api/core/* |
Paywall wallet, session, tips, Circle auth |
Live plugins (Integrated Platforms) serve assets and relay API calls. Copy that pattern: expose a stable browser origin; keep Base URL as the server-to-server Tessera address for HMAC.
Tessera does not have to be on the public internet if the platform server and relay can reach it.
Tips flow (browser)¶
POST /v1/tips is called from the browser (paywall tip button), not from your ingest HMAC server. A server POST is only valid if it also sends a live Circle userToken and the session returnAddress. The usual path is:
- Load
paywall.bundle.js. initTipMode(creatorWallet, tipAmount).- Viewer signs in (email or Google) and funds wallet in the paywall UI.
- Paywall calls
POST /api/core/register-session(Gateway deposit). This requires a validuserIdand CircleuserToken. - Viewer clicks tip → paywall calls
POST /api/core/v1/tipswith{ userId, payoutAddress, amount, userToken, returnAddress }. 401= Circle session does not ownreturnAddress, or it does not match the stored Gateway session.402= insufficient Gateway balance.404= no session foruserId(viewer has not completed step 4).
Server-side start / stop do not fund the viewer wallet. Funding is always through the paywall.
3. Plugin settings dashboard¶
Ship an admin page on the platform (your plugin settings UI, webhook config page, or admin panel route). That config belongs to the plugin - not to Tessera .env.
Required for any connector:
| Setting | Maps to | Notes |
|---|---|---|
| Tessera Base URL | Server-to-server POST origin | Used for HMAC start / stop. Confirm: GET {Base URL}/health. |
| Tessera Ingest Secret | TESSERA_INGEST_SECRET |
Copy from Tessera .env.example. Never expose to the browser. |
For time-based billing - add only if your platform meters consumption over time (video, audio, live):
| Setting | Maps to | Notes |
|---|---|---|
| Creator wallet | payoutAddress |
Per-channel, per-artist, or per-resource. |
| Rate per second (USDC) | ratePerSecond on start |
e.g. 0.000100. |
| Platform fee wallet | splits[].address |
Optional. Your platform's cut. |
| Platform fee fraction | splits[].fraction |
e.g. 0.1 = 10%. Remainder → creator. |
For tip-only billing - all platforms need this if they support tips:
| Setting | Maps to | Notes |
|---|---|---|
| Creator wallet | payoutAddress in tips |
Resolve from EXIF, feed author, post metadata - whatever your platform exposes. |
| Default tip amount (USDC) | initTipMode(wallet, amount) |
Optional. Default 0.10 if omitted. |
If your platform is exclusively tip-based (photos, posts, RSS, newsletter, fediverse): you only need the Required fields and the tip-only section above.
4. Native events → Tessera¶
Map each native event to one of the three calls.
POST /v1/sessions/start¶
Time started. HMAC required.
Typical native events: userJoined, PlaybackStart, Play, NowPlaying, exclusive VOD/live start, podcast/music listen start while it is playing.
{
"userId": "email:viewer@example.com",
"resourceId": "content-abc",
"ratePerSecond": "0.000100",
"payoutAddress": "0x...",
"splits": [
{ "address": "0x...", "fraction": 0.1, "label": "platform-fee" }
],
"metadata": { "platform": "your-platform", "channelId": "3" }
}
ratePerSecond: USDC decimal string. Must be>= 0. Values> 0bill per second."0"is accepted but bills nothing; for tip-only content useinitTipModeandtips, notstart.splits: optional; sum of fractions<= 1.- Billing is wall-clock from this call until
stop.stophas no duration field.
Response: { "status": "session_started", "sessionId": "userId" }
Viewer funding: start does not check the viewer's Gateway balance - the paywall does that before unlocking playback. If the viewer is not yet funded, initPaywall() will block media and prompt a deposit before your connector ever sends start.
Session edge cases
| Situation | What to do |
|---|---|
| Switch resource while charging | stop previous userId, then start with new resourceId / rate. |
Second start same userId without stop |
Overwrites the active session (new rate/resource). Prefer stop first. |
| Pause should stop billing | Send stop. Send start again on resume if you still charge. |
| Tab close / disconnect | Send stop on best-effort (beforeunload, websocket leave, idle timeout). |
| Viewer not logged in yet | Do not start until arc_cashier_user_id is available from the paywall client. |
POST /v1/sessions/stop¶
Time ended. HMAC required.
Typical native events: userParted, PlaybackStopped, Stop, leave, pause (if charging should stop), listen end if you already sent start.
Response: { "status": "session_stopped" }
POST /v1/tips¶
One amount, now. No ingest HMAC. Requires a Gateway session for userId plus Circle proof that userToken owns returnAddress, and that address matches the stored session (same pattern as sync-session).
Typical native events: tip button click (browser). Do not POST tips from the plugin server unless you also hold a live Circle userToken for that viewer.
{
"userId": "email:viewer@example.com",
"payoutAddress": "0x...",
"amount": "0.100000",
"userToken": "<circle-user-token>",
"returnAddress": "0x..."
}
Response: { "status": "success", "amount", "payoutAddress" }
PlaybackProgress, ping, and heartbeat: ignore for billing. The meter already runs after start. Resolve payee (wallet, EXIF artist, MBID) in the plugin, then POST.
By surface¶
Live / VOD (Jellyfin, PeerTube)
- Exclusive: play →
start; leave/stop/pause →stop. UI:initPaywall(). - Free: UI:
initTipMode(). Tips from browser. Do notstart. - Progress webhooks: ignore.
Music server (Navidrome, Subsonic, Airsonic, Ampache, ...)
- Platform emits play/stop in real-time:
NowPlaying/play→start; stop/pause →stop. - Platform only emits a scrobble at track end (no live play event): use
tipswith the computed amount. Do not invent astartyou cannot pair with astop. payoutAddress: resolve from track metadata (MusicBrainz artist, Beets custom field). Do not use the platform's native account id.
Music metadata tools (Beets, Maloja, Picard)
These are not billing surfaces - they resolve identity and wallet addresses.
- Beets / Picard: query before POSTing
startortipsto getpayoutAddressandsplitsfrom artist/composer credits. - Maloja: accepts scrobbles inbound → map to
tips(same as scrobble-only music server above). - Do not call
start/stopfrom a tagging tool.
Podcasting + audio (Audiobookshelf, Castopod, AntennaPod)
- Support while it plays: play event →
start; stop/pause →stop. - Support this episode (one-time gesture):
tipsonly. - If the platform uses Podcasting 2.0
<podcast:value>tags or Castopod Premium: those are separate payment rails. Tessera is additive - do not replace them.
Photos / galleries (Immich, PhotoPrism, Lychee, ...)
Photos have no duration - there is nothing to meter. Never call start or stop.
- UI:
initTipMode(creatorWallet). The paywall renders a tip button next to the image. - On tip gesture (download, like, support button):
POST /v1/tipsfrom the browser or your server. payoutAddress: resolve from EXIF metadata (photographer's wallet), not from the platform account system.resourceIdis optional since there is no timed session; pass it viametadataif you need per-photo earnings in stats.
Feeds + RSS (FreshRSS, Miniflux, Wallabag)
Always tips only - reading an article is not a timed session.
resourceId: use the canonical URL of the article (stable, unique per post).payoutAddress: resolve from the feed's<author>or a wallet tag in the feed.- UI: inject
initTipModenext to each item in the reader UI.
Publishing + newsletter (Ghost, WriteFreely, Halo)
Always tips only - a post or newsletter is not metered by the second.
initTipModein the post template or alongside a subscribe button.
Fediverse (Mastodon, Lemmy, Pixelfed)
tipsonly. Do not meter reading a post by the second.- Map a native tip, boost, or donation gesture to
POST /v1/tips. - Or inject
initTipModein the post or profile view.
5. HMAC (start and stop only)¶
Headers:
X-Tessera-Timestamp: unix msX-Tessera-Nonce: unique string (single use)X-Tessera-Signature: hex HMAC-SHA256 oftimestamp.nonce.rawBodywith the ingest secret
Replay window: 60 seconds. Duplicate nonce: 401.
const crypto = require('crypto');
const secret = process.env.TESSERA_INGEST_SECRET;
const rawBody = JSON.stringify({
userId: 'email:viewer@example.com',
resourceId: 'content-abc',
ratePerSecond: '0.000100',
payoutAddress: '0x...',
});
const timestamp = Date.now().toString();
const nonce = crypto.randomUUID();
const signature = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${nonce}.${rawBody}`)
.digest('hex');
await fetch(`${baseUrl}/api/core/v1/sessions/start`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tessera-Timestamp': timestamp,
'X-Tessera-Nonce': nonce,
'X-Tessera-Signature': signature,
},
body: rawBody,
});
Sign the exact bytes you send as the body.
6. Creator earnings API¶
Embed creator balance and withdraw with window.ArcCashier.initCreatorEarnings({ wallet, apiBase?, mount?, title? }). Same Tessera chrome as the viewer widgets: the call injects paywall.css and creator-earnings.css.
wallet(required): creator0xaddress.apiBase(optional): override API origin; defaults to script URL derivation.mount(optional):HTMLElementor selector; defaults todocument.body.title(optional): panel heading.
HTTP routes (relative to /api/core):
GET /creator/balance?address=0x...¶
Response:
{
"status": "success",
"address": "0x...",
"gatewayAvailable": "1.234567",
"gatewayWithdrawable": "1.234567",
"gatewayTotal": "1.234567"
}
POST /creator/prepare-withdraw¶
Body: { "address": "0x..." }
Response when ready: { "status": "ready", "amount", "burnIntent", "typedData" }
Response when balance too low: { "status": "no_funds", ... }
POST /creator/complete-withdraw¶
Body: { "address", "burnIntent", "signature" } (EIP-712 from MetaMask)
Response: { "status": "ready_to_mint", "transferId", "txRequest" }
GET /creator/stats?address=0x...¶
Response:
{
"status": "success",
"stats": [
{ "resourceId": "resource-abc", "amount": 0.05 },
{ "resourceId": "resource-xyz", "amount": 1.20 }
]
}
Each entry is a resourceId your connector sent on start (or via tips) that generated earnings for address. amount is in USDC. Tessera does not interpret the value - it is whatever your connector passed.
7. Errors¶
| HTTP | Endpoint | When |
|---|---|---|
| 400 | start |
Missing userId, resourceId, ratePerSecond, or payoutAddress; invalid address or split fraction |
| 400 | start |
splits fractions sum > 1 |
| 400 | stop |
Missing userId |
| 401 | start, stop |
Missing/invalid HMAC, expired timestamp (> 60 s), or duplicate nonce |
| 400 | tips |
Missing userToken or returnAddress |
| 401 | tips |
Circle session does not own returnAddress, or it does not match the stored Gateway session |
| 402 | tips |
Insufficient Gateway balance |
| 404 | tips |
No Gateway session for userId (viewer has not funded wallet) |
| 500 | stop, creator |
Settlement failed or Tessera server error |
8. Verification checklist¶
Run in order when wiring a new connector:
curl -sS {Base URL}/health→status: healthy- HMAC
POST /api/core/v1/sessions/startwith a testuserId/resourceId→session_started - HMAC
POST /api/core/v1/sessions/stop→session_stopped - Browser loads
{origin}/assets/paywall.bundle.jswithout 404 initPaywall()orinitTipMode()→ Circle login completeslocalStorage.getItem('arc_cashier_user_id')returnsemail:...orsocial:...- Connector server uses that same
userIdonstart/stop - Fund wallet in paywall →
register-sessionsucceeds - Tip button →
POST /v1/tips→success - Creator panel →
GET /creator/balancereturns funds after sessions
Live plugins: Integrated Platforms.