API reference
Base URL https://api.superdrm.com (hosted) or https://api.<your domain> (on-premises). JSON in and out unless stated. Every error is {"error": "<message>", "code": "<code>"} with a 4xx/5xx status.
#Authentication
| Credential | Header / query | Who holds it | Used on |
|---|---|---|---|
Tenant API key sdrm_live_… / sdrm_test_… | Authorization: Bearer <key> | The tenant's servers | /v1/keys*, /v1/cpix, /v1/tokens, /v1/usage*, /v1/me, /v1/attest/*, and the server-relay form of the licence routes |
Licence token (HS256 JWT signed with the tenant's token_secret) | ?lt=<token> | The player, minted by the tenant's backend | /v1/<tenant>/license/* |
| Operator token | Authorization: Bearer <SUPERDRM_ADMIN_TOKEN> | Operators of an install | /v1/admin/* |
API key scopes: keys:write, keys:read, licence:serve, usage:read, admin (grants all). Only a SHA-256 of a key is stored; the first 15 characters are shown as its prefix.
#Service
#GET /
Product banner. No auth.
#GET /health
No auth. 200 when the vault and database answer, 503 otherwise.
{ "ok": true,
"vault": { "provider": "pkcs11", "id": "pkcs11:superdrm-kek-1", "ok": true, "detail": "YubiHSM 2" },
"db": { "ok": true, "detail": "postgres" },
"fairplay_ksm": { "ok": true, "detail": "production credentials · Apple FPS SDK 26.0.4" },
"widevine": { "env": "prod", "partners": ["widevine:prod"], "builtin_uat": false },
"install": { "mode": "licensed", "org": "Acme", "tenant_cap": 10, "tenants": 3 },
"time": "2026-09-16T12:00:00.000Z" }#Key Protect
#POST /v1/keys — scope keys:write
Issue (or fetch, if content_id exists) one content key.
| Body field | Type | Notes |
|---|---|---|
content_id | uuid | Optional; generated when absent. Idempotent: the same id returns the same key with created:false |
label | string | Your reference, e.g. superfile-<file id> |
scheme | cbcs | cenc | Default cbcs |
systems | string[] | Subset of the tenant's enabled systems; default all |
Response: content_id, kid (hex), key (hex, plaintext over TLS), iv (hex), scheme, systems, wv_pssh (base64 pssh box or null), pr_pssh, fp_hls (the URI=… attribute set for EXT-X-KEY), wv_provider, key_source (widevine | local | import), licence_urls (EME key system → URL), fairplay_cert_url, created.
#POST /v1/keys/import — scope keys:write
Bring your own key (a previous vendor's CPIX, your KMS). SuperDRM seals it and licences Widevine for it via provider-supplied keys.
| Body field | Type | Notes |
|---|---|---|
content_id | uuid | Required |
kid, key | 32 hex | Required, 16 bytes each |
iv | 32 hex | Optional; random when absent |
scheme, label, systems | As above | |
wv_pssh, pr_pssh | base64 | Optional; SuperDRM builds a Widevine PSSH when none is given |
200 with the same shape as /v1/keys. 409 when the id already holds a different kid. 400 on bad hex.
#GET /v1/cpix?k=<content_id>&c=<label>&EncryptionScheme=cbcs|cenc — scope keys:write
Same as POST /v1/keys, returned as CPIX 2.3 XML (application/xml). Elements: ContentKey kid explicitIV → PlainValue; one DRMSystem per system with PSSH (Widevine, PlayReady), ContentProtectionData, and HLSSignalingData playlist="media|master" (FairPlay). Header X-SuperDRM-Created: 1|0. A drop-in for the EZDRM key generator.
#GET /v1/keys?limit=100 — scope keys:read
Newest first: content_id, kid, scheme, label, systems, wv_provider, key_source, created_at, revoked_at.
#GET /v1/keys/:content_id[?reveal=1] — scope keys:read (reveal needs keys:write)
Metadata plus PSSH; reveal=1 adds the plaintext key.
#POST /v1/keys/:content_id/revoke — scope keys:write
{"revoked": true|false}. Every later licence for the id is refused with revoked.
#Licence tokens
#POST /v1/tokens — scope licence:serve
Mint a player token server-side instead of signing HS256 yourself.
| Body field | Notes |
|---|---|
content_id | Required |
sub | Opaque user id, recorded on meter rows |
ref | Your reference (file id, share id), echoed to the webhook and meter |
ttl_s | 30 … 2 592 000 (30 days); default 3600 |
policy | Policy overrides (below) |
systems | Restrict to a subset |
Response: token, exp, servers (EME key system → licence URL with ?lt= appended), fairplay_cert_url.
#Token claims (if you sign them yourself)
HS256 JWT over the tenant's token_secret (hex → bytes):
| Claim | Meaning |
|---|---|
cid | Content id, or "*" (only with an auth webhook and ?cid= on the request) |
exp | Unix seconds, required |
sub, ref | As above |
sys | Allowed systems, e.g. ["widevine","fairplay"] |
pol | Policy overrides |
iss, iat | Informational |
#Policy vocabulary
Merged in order: built-in defaults → tenant settings.policy → token pol (or X-SuperDRM-Policy on relays) → auth-webhook policy.
| Field | Default | Widevine | FairPlay | Clear Key |
|---|---|---|---|---|
hw | true | Refuse L3 clients; with key_source local/import also asks Google for security_level: 1 | HDCP required | Refused entirely when true |
hdcp | type0 | — | none | type0 | type1 to the KSM | — |
persist | false | can_persist | persistence | — |
rental_s | — | rental_duration_seconds | rental | — |
playback_s | — | playback_duration_seconds | — | — |
licence_s | 3600 | license_duration_seconds | lease when renew | — |
renew | false | can_renew | lease | — |
tracks | SD_HD | allowed_track_types (SD_ONLY | SD_HD | SD_UHD1 | SD_UHD2) | — | — |
attested | false | Requires a valid TPM attestation token (X-SuperDRM-Attestation or ?at=) on every licence request — any system, Clear Key included. See Attested devices. | same | same |
#Attestation
Management (API key): POST/GET /v1/attest/devices, POST /v1/attest/devices/:id/revoke, POST/GET /v1/attest/images. Device-facing (open; the quote is the credential): POST /v1/<tenant>/attest/challenge → nonce, POST /v1/<tenant>/attest/verify → 15-minute attestation token. Full contract, trust model and client script: Attested devices.
#License Service
CORS is open on these routes (Access-Control-Expose-Headers: X-SuperDRM-Security). <tenant> is the tenant slug.
#POST /v1/<tenant>/license/widevine|fairplay|clearkey|playready
Body: the raw CDM challenge (Widevine bytes; FairPlay SPC raw, or spc=<base64> form, or {"spc":…} JSON; Clear Key {"kids":[…]}). Auth, one of:
?lt=<token>— player form.Authorization: Bearer sdrm_…+?cid=<content_id>+ optionalX-SuperDRM-Policy: {json}— server relay form.
Response: raw licence bytes (application/octet-stream; Clear Key application/json JWK set), header X-SuperDRM-Security: L1|L3|hw|sw, Cache-Control: no-store. Every attempt writes a meter row.
#GET /v1/<tenant>/fairplay/cert
The tenant's FairPlay application certificate (application/pkix-cert). 404 until installed.
#Licence error codes
| HTTP | code | Meaning |
|---|---|---|
| 400 | bad_request | Missing cid on a relay, non-JSON policy header, malformed Clear Key request |
| 401 | unauthorized | No token and no valid API key |
| 403 | forbidden | API key lacks licence:serve |
| 403 | token_invalid | Bad signature, expired, no cid, or wildcard without webhook |
| 403 | system_not_allowed | Token sys excludes this system |
| 403 | webhook_denied | Tenant webhook said no (its reason is the message) |
| 403 | system_disabled | System switched off for the tenant |
| 404 | no_key | Unknown content id (relays fall back to their previous vendor on this) |
| 403 | revoked | Key revoked |
| 409 | system_not_on_key | Key was issued without this system |
| 403 | hw_required | Policy requires hardware; client is L3, or Clear Key was asked |
| 403 | kid_mismatch | Clear Key asked for a kid that is not this content id's |
| 429 | quota_exceeded | The plan's included licences are used up and it has no overage |
| 501 | system_disabled | PlayReady, until licensed |
| 502 | upstream | Google or the KSM errored (message carries their status) |
| 503 | no_provider | No Widevine credentials match the key's provider |
| 503 | no_ksm | FairPlay KSM not configured |
#Authorization webhook
Set auth_webhook on a tenant and SuperDRM POSTs before every token-authenticated licence:
{ "content_id": "…", "system": "widevine", "sub": "user-42", "ref": "share-9", "ip_hash": "…" }Reply {"allow": true, "policy": {…}} or {"allow": false, "reason": "share revoked"} within 2.5 s. Allows are cached 20 s per (content, user, system). Header X-SuperDRM-Tenant carries the slug.
#Usage and self-service
#GET /v1/usage?from=YYYY-MM-DD&to=YYYY-MM-DD — scope usage:read
{ from, to, totals: { widevine: { ok, failed }, … }, keys_issued, daily: [{ day, system, ok, n }] }. Default: last 30 days.
#GET /v1/usage/licences?limit=100 — scope usage:read
Recent meter rows: id, content_id, system, kind (license|renewal|service_cert), security, device_hash, user_ref, ref, bytes_out, ms, ok, error, issued_at.
#GET /v1/me — scope usage:read
Tenant profile, enabled systems, DRM switches (public halves), API keys (prefixes), your scopes.
#POST /v1/me/rotate-token-secret — scope admin
New token_secret (hex), shown once. Old tokens stop verifying immediately.
#Portal
Everything the self-service portal does is plain JSON under /v1/portal/* with the session cookie sdsess; every write needs the X-SuperDRM-Portal: 1 header (that plus SameSite=Lax is the CSRF story). Public: signup, verify-email, login, logout, reset/request, reset/perform, invite/accept. Session: me, keys, usage, events, domains (+/verify), team (+invite/remove), plan, rotate-token-secret, sessions, accounts. See Portal & console.
#Admin (operator token)
| Method | Path | Body / query | Returns |
|---|---|---|---|
| GET | /v1/admin/tenants | Tenants with keys and licences_30d counts | |
| POST | /v1/admin/tenants | slug, name, plan?, auth_webhook?, settings? | 201 tenant + one-time token_secret and api_key |
| PATCH | /v1/admin/tenants/:slug | name?, plan?, status?, auth_webhook? ("" clears), settings? | {ok} |
| POST | /v1/admin/tenants/:slug/keys | name?, scopes?, env? (live|test) | 201 new API key (shown once) |
| POST | /v1/admin/tenants/:slug/keys/:id/revoke | {revoked} | |
| POST | /v1/admin/tenants/:slug/rotate-token-secret | {token_secret} | |
| POST | /v1/admin/tenants/:slug/drm/:system | enabled?, mode? (shared|own), public?, config? (sealed) | {ok} |
| POST | /v1/admin/tenants/:slug/members | email, role (owner|admin|member) | {ok} — the user must already exist |
| GET | /v1/admin/partners | Partner credential rows (public halves) | |
| POST | /v1/admin/partners/:id | public?, config (widevine:prod config: provider, signing_key, signing_iv, host) | {ok} |
| GET | /v1/admin/plans | Plan catalogue | |
| GET | /v1/admin/usage?tenant=&from=&to= | As /v1/usage, all tenants when tenant is omitted | |
| GET | /v1/admin/licences?limit=100 | Meter rows across tenants | |
| GET | /v1/admin/audit?limit=200 | Audit log | |
| GET | /v1/admin/events | Server-sent events: every licence, key and admin action, live | |
| GET | /v1/admin/stats | Counters for dashboards | |
| POST | /v1/admin/users/:email/verify | Manually verify a portal user (support) | |
| POST | /v1/admin/users/:email/master | {"on": true|false} | Grant or revoke the master-admin flag |
| POST | /v1/admin/import | bundle | Import an export bundle (see Operations & migration) |
#Example: relay from an edge function
const r = await fetch(`${SUPERDRM_URL}/v1/${SUPERDRM_TENANT}/license/${system}?cid=${contentId}`, {
method: "POST",
headers: { Authorization: `Bearer ${SUPERDRM_KEY}`, "Content-Type": "application/octet-stream",
"X-SuperDRM-Policy": JSON.stringify({ hw: strict }) },
body: challenge,
});
if (r.status === 404) return relayToPreviousVendor(system, challenge); // no_key: packaged before the switch
if (!r.ok) throw new Error(`superdrm ${r.status}: ${await r.text()}`);
return new Uint8Array(await r.arrayBuffer());curl -s -X POST "https://api.superdrm.com/v1/<tenant>/license/widevine?cid=<content_id>" \
-H "Authorization: Bearer sdrm_live_…" -H 'X-SuperDRM-Policy: {"hw":true}' \
--data-binary @challenge.bin -o licence.bin -w '%{http_code}\n'Updated September 2026