External user references (API)
The companion guide for the B2B API key flow, where the ref travels on each HTTP body instead of a JWT claim. Read →
By default, the widget hands an access code back to the end-user on every successful registration, and the user is expected to bring it back later to update or read their work. That model is intentional: it works even when your platform has no user accounts of its own.
If your platform does have its own user accounts, you can do better. The widget supports a second mode where every session is pinned to a stable identifier of your choosing — your internal user UUID, an artist ID in your CRM, a member number, anything. The platform tags each registration with that identifier and uses it to scope every subsequent listing, update or download made under the same identifier.
| Access code (default) | external_user_ref | |
|---|---|---|
| Where the identifier lives | Returned per work, end-user holds it | Pinned on the JWT, your backend holds it |
| What the end-user has to remember | A 68-char code per work | Nothing — they log in to your platform |
| Update flow | User types the access code | User picks a work from your work-selector UI |
| Reading the user’s catalog | One-by-one via each access code | Built-in list view, scoped server-side |
| Suits platforms without accounts | ✅ | ❌ (you need a way to identify your users) |
| Suits platforms with their own logins | ✅ | ✅ (recommended) |
| Webhook payload | access_code present | access_code is null, external_user_ref echoed back |
Switching the widget into ref-scoped mode is two changes:
external_user_ref field on the body sent to POST /v1/sessions.external-user-id="<same ref>" on the <ats-widget> element.Both values are the same string. The JWT claim is the authoritative copy — the widget attribute only drives the UI (it tells the widget to render the work-selector and the catalog view).
Your backend already calls POST /v1/sessions to mint widget JWTs (see Authentication). Add external_user_ref to the body — that’s all.
app.post('/api/ats-token', async (req, res) => { const user = await getAuthenticatedUser(req); if (!user) return res.status(401).json({ error: 'Unauthorized' });
const response = await fetch('https://organizations.api.allfeat.org/v1/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Origin': 'https://yoursite.com', }, body: JSON.stringify({ secret_key: process.env.ALLFEAT_SECRET_KEY, // Pick the action type that matches the widget mode you're rendering: // register → mode="register" // update_version → mode="update" // access → mode="download" action_type: req.body.action_type, allowed_network: 'testnet', external_user_ref: user.id, // ← your internal identifier }), });
const { token } = await response.json(); res.json({ token });});curl -X POST https://organizations.api.allfeat.org/v1/sessions \ -H "Content-Type: application/json" \ -H "Origin: https://yoursite.com" \ -d '{ "secret_key": "csk_your_secret_key", "action_type": "register", "allowed_network": "testnet", "external_user_ref": "user_42" }'<ats-widget site-key="cpk_your_public_key" ats-url="https://ats.api.allfeat.org" network="testnet" mode="register" external-user-id="user_42"></ats-widget>The same external-user-id value powers mode="register", mode="update", and mode="download" — set it once when you render the widget and let the user switch modes.
Each token narrows to one action_type. When the user switches from registering to updating, mint a new token with the matching action type (and the same external_user_ref).
async function switchMode(newMode) { widget.setAttribute('mode', newMode); const { token } = await fetch('/api/ats-token', { method: 'POST', body: JSON.stringify({ action_type: newMode === 'register' ? 'register' : newMode === 'update' ? 'update_version' : 'access', }), headers: { 'Content-Type': 'application/json' }, }).then(r => r.json()); widget.setToken(token);}The widget hits the same /v1/works/init|prepare|confirm endpoints as the default flow — only the server’s side effect changes:
external_user_ref = "user_42".widget.addEventListener('allfeat:complete', (e) => { console.log(e.detail.atsId); // 1024 console.log(e.detail.txHash); // 0xabc… console.log(e.detail.accessCode); // undefined — by design in this mode});Instead of an access-code text field, the widget renders a work-selector: a paginated list of all works tagged with the current external_user_ref. The user picks one, the form pre-fills from its latest version, and they push a new version on chain.
The wizard sub-steps in this mode:
| Sub-step | Description |
|---|---|
work_select | Paginated work list filtered to this end-user, with search |
file | Optional new asset file (skip to reuse the existing one) |
title | Pre-filled, read-only |
creators | Pre-filled from the latest version, editable |
review | Summary before submit |
A new mode, available only when external-user-id is set. It renders the end-user’s catalog as a list view. The user can drill into any work to:
<ats-widget site-key="cpk_..." ats-url="https://ats.api.allfeat.org" network="testnet" mode="download" external-user-id="user_42"></ats-widget>Use action_type: "access" when minting the JWT for download mode.
external_user_refThe platform treats the value as opaque — there is no validation beyond a non-empty, ≤ 255-byte check. A few rules of thumb:
VARCHAR(255).You stay in full control of the mapping. The platform never resolves a ref back to a human — your dashboard does.
You don’t have to store anything to make this work — the widget loads the user’s catalog by ref. But if you want richer dashboards on your platform you can keep a per-work mapping; it’s especially useful for joining against your own metadata:
Your Database┌──────────┬────────┬─────────────────────┬────────────┐│ user_id │ ats_id │ tx_hash │ created_at │├──────────┼────────┼─────────────────────┼────────────┤│ user_42 │ 1024 │ 0xabc… │ 2026-05-01 ││ user_42 │ 1037 │ 0xdef… │ 2026-05-15 │└──────────┴────────┴─────────────────────┴────────────┘You get the atsId and txHash on the allfeat:complete event of each registration. The user_id here is the same as the external_user_ref you pinned at mint time.
The JWT is still ~5 min, single-use for write actions, scoped to a single action_type. The external_user_ref is just one more claim baked into it — minting works exactly the same:
widget.addEventListener('allfeat:token-expired', async (e) => { const { token } = await fetch('/api/ats-token', { method: 'POST', body: JSON.stringify({ action_type: e.detail.pendingAction === 'download' ? 'access' : e.detail.pendingAction === 'submit' && widget.getAttribute('mode') === 'update' ? 'update_version' : 'register' }), headers: { 'Content-Type': 'application/json' }, }).then(r => r.json()); widget.setToken(token);});Two server-side checks keep things tight, both invisible to the widget:
external_user_ref = "user_42" cannot read or mutate a work tagged with "user_43", even within the same organization. Cross-user requests return 404 on reads, 403 on version-update writes.You can run both models in parallel forever — there’s no flag day. Works registered before you adopt external_user_ref keep their access code; works registered after carry the ref instead. Your end-users see whichever path the work happens to have been created under.
If you want to backfill the ref on legacy works, there’s no API for that today — write a one-off DB migration on your side, or contact the Allfeat team if you need a bulk operation.
External user references (API)
The companion guide for the B2B API key flow, where the ref travels on each HTTP body instead of a JWT claim. Read →
Update mode
Reference for the update wizard sub-steps and behavior. Read →
Attributes & methods
The full list of widget attributes including external-user-id.
Read →