# Easy2257 Partner API
> REST API for outsourcing 18 U.S.C. § 2257 compliance: performer ID verification, content logging, and 7-year record retention.
## Docs
- [Endpoint and discovery](https://docs.easy2257.com/docs/ai-assistant)
- [cancel](https://docs.easy2257.com/docs/api/collab-scenes/cancel)
- [create](https://docs.easy2257.com/docs/api/collab-scenes/create)
- [extend](https://docs.easy2257.com/docs/api/collab-scenes/extend)
- [get](https://docs.easy2257.com/docs/api/collab-scenes/get)
- [publication-remove](https://docs.easy2257.com/docs/api/collab-scenes/publication-remove)
- [publications](https://docs.easy2257.com/docs/api/collab-scenes/publications)
- [Is there a sandbox?](https://docs.easy2257.com/docs/api/faq)
- [What your platform never has to store](https://docs.easy2257.com/docs/api)
- [certificates](https://docs.easy2257.com/docs/api/productions/certificates)
- [get](https://docs.easy2257.com/docs/api/productions/get)
- [What a compliance certificate is](https://docs.easy2257.com/docs/api/productions)
- [list](https://docs.easy2257.com/docs/api/productions/list)
- [revoked-certificates](https://docs.easy2257.com/docs/api/productions/revoked-certificates)
- [verify-certificate](https://docs.easy2257.com/docs/api/productions/verify-certificate)
- [production.documented](https://docs.easy2257.com/docs/api/productions/webhooks)
- [confirm-compliance-copy](https://docs.easy2257.com/docs/api/solo-accounts/confirm-compliance-copy)
- [create](https://docs.easy2257.com/docs/api/solo-accounts/create)
- [deactivate](https://docs.easy2257.com/docs/api/solo-accounts/deactivate)
- [delete-content](https://docs.easy2257.com/docs/api/solo-accounts/delete-content)
- [get](https://docs.easy2257.com/docs/api/solo-accounts/get)
- [log-content](https://docs.easy2257.com/docs/api/solo-accounts/log-content)
- [reactivate](https://docs.easy2257.com/docs/api/solo-accounts/reactivate)
- [check](https://docs.easy2257.com/docs/api/talent/check)
- [HTTP status decoder](https://docs.easy2257.com/docs/api/troubleshooting)
- [create](https://docs.easy2257.com/docs/api/upload-sessions/create)
- [v2026-10-25](https://docs.easy2257.com/docs/changelog)
- [Who it fits](https://docs.easy2257.com/docs/guides/partners)
- [The deal in one sentence](https://docs.easy2257.com/docs/guides/platforms)
- [What you get](https://docs.easy2257.com/docs/guides/productions)
- [What you get](https://docs.easy2257.com/docs/guides/solo-creator)
- [Before you start](https://docs.easy2257.com/docs)
- [1. Register your webhook endpoint](https://docs.easy2257.com/docs/quickstart)
- [Solo accounts](https://docs.easy2257.com/docs/webhooks/events)
- [Register an endpoint](https://docs.easy2257.com/docs/webhooks)
- [Retry schedule](https://docs.easy2257.com/docs/webhooks/retries)
- [Headers on every delivery](https://docs.easy2257.com/docs/webhooks/signatures)
## Full Content
### https://docs.easy2257.com/docs/ai-assistant
Easy2257 runs a [Model Context Protocol](https://modelcontextprotocol.io) server at `https://easy2257.com/api/mcp`. It lets a producer's AI assistant read that producer's compliance state and answer in plain language: which scenes still need something, what expires next, whether a production is ready to finalize, whether a certificate someone sent them is genuine. It works with Claude, ChatGPT, Gemini, Copilot, Cursor, and any other client that speaks MCP.
It is read-only by design. Nothing reachable through it can create, change, sign, or delete a record, activate a scene, or spend money, and it never returns performer identity data.
**This is not the Partner API.** The Partner API (`/api/v1`, `ez_live_` keys from the partner portal) is for platforms and can write records. The MCP connection (`/api/mcp`, `ez_mcp_` keys from a producer's account, or sign-in) is for producers and can only read. A producer who wants to ask their assistant about their records wants this page. A platform integrating Easy2257 wants the [Quickstart](/docs/quickstart).
| | Partner API | MCP connection |
|---|---|---|
| Who uses it | Platforms integrating Easy2257 | Producers and solo creators, through an AI assistant |
| Can it write | Yes: provisions creators, opens scenes, delivers certificates | No. Sixteen read-only tools |
| Credential | `ez_live_` key, created at [partner.easy2257.com/api-keys](https://partner.easy2257.com/api-keys) | `ez_mcp_` key, created at [Account, then MCP Keys](https://easy2257.com/profile/account/api-keys), or OAuth 2.1 sign-in |
| Base URL | `https://easy2257.com/api/v1` | `https://easy2257.com/api/mcp` |
| Reference | The rest of this site | This page, plus the producer-facing guide at [easy2257.com/ai-assistant](https://easy2257.com/ai-assistant) |
## Endpoint and discovery
- **Transport:** Streamable HTTP, stateless. Clients POST JSON-RPC to the endpoint; there is no session to hold between calls.
- **Server card:** [`/.well-known/mcp/server.json`](https://easy2257.com/.well-known/mcp/server.json)
- **OAuth discovery:** [`/.well-known/oauth-authorization-server`](https://easy2257.com/.well-known/oauth-authorization-server) and [`/.well-known/oauth-protected-resource`](https://easy2257.com/.well-known/oauth-protected-resource)
- **Unauthenticated requests** answer `401` with a bearer challenge whose `resource_metadata` points at the protected-resource document, which is what an MCP client needs to start a sign-in on its own.
## Authentication
Two credentials, one authorization story. A pasted key and a sign-in token resolve to the same account, the same tools, and the same scoping; nothing downstream can tell them apart.
### MCP keys
- Created by the producer at **Account, then MCP Keys**. Prefix `ez_mcp_`. Up to ten active keys per account.
- The secret is shown once at creation and stored only as a hash. Rotate or revoke any key at any time. Every key on the account is revoked automatically when the password is reset.
- Send it as a bearer token:
```http
Authorization: Bearer ez_mcp_...
```
- Keys are live only. They need an active subscription; every plan includes MCP access.
### Sign-in (OAuth 2.1)
For clients that connect by sign-in instead of a pasted key (ChatGPT, Claude on the web and desktop, and others with a connectors screen).
| | |
|---|---|
| Grant | Authorization code with PKCE, `S256` only. No implicit, password, or client-credentials grants. |
| Scope | `mcp:read`, the only scope. It is read-only access to the signed-in producer's own records. |
| Client identification | Client ID Metadata Documents: `client_id` is an `https` URL that serves the client's own metadata (name, redirect URIs). There is no dynamic registration endpoint. Public clients only (`token_endpoint_auth_methods_supported: ["none"]`). |
| Authorize | `https://easy2257.com/oauth/authorize` |
| Token | `https://easy2257.com/api/oauth/token` |
| Revoke | `https://easy2257.com/api/oauth/revoke` |
| Lifetimes | Access token 1 hour. Refresh token 30 days idle, rotated on every use. |
The producer approves the connection on an Easy2257 consent screen that names the client and states what it can and cannot read. Connected clients are listed under **Account, then MCP Keys** with a one-click disconnect, which revokes the grant immediately.
### Refusals
| Response | Meaning | What a client should do |
|---|---|---|
| `401` with `WWW-Authenticate: Bearer` | No credential, or one that is unknown, revoked, or expired | Re-authenticate |
| `403` `ACCOUNT_LOCKED` | The account is temporarily locked | Stop. Re-authenticating will not help |
| `403` `SUBSCRIPTION_REQUIRED` | No active subscription entitles the connection | Stop and tell the producer; billing is fixed on the site, not by the agent |
Neither response reveals whether a given key exists. Locked and unentitled accounts deliberately answer `403` rather than `401`, so an agent refused over billing does not loop on re-authentication.
## Connecting a client
```bash
claude mcp add --transport http easy2257 https://easy2257.com/api/mcp \
--header "Authorization: Bearer ez_mcp_..."
```
Cursor, VS Code, Windsurf, Gemini CLI, Codex CLI, and most editor assistants take a block like this in their MCP config:
```json
{
"mcpServers": {
"easy2257": {
"type": "http",
"url": "https://easy2257.com/api/mcp",
"headers": {
"Authorization": "Bearer ez_mcp_..."
}
}
}
}
```
In the assistant's connector or integration settings, add a custom connector with the URL `https://easy2257.com/api/mcp`. The client reads the discovery documents, sends the producer to Easy2257 to sign in and approve, and holds a token no one copied. There is no key to paste.
## Tools
Sixteen tools, all read-only. Results are always scoped to the authenticated account; a record that belongs to someone else comes back as `found: false`, the same as one that does not exist.
### Productions and scenes
| Tool | Returns |
|---|---|
| `list_productions` | The account's productions, newest first, with documentation status, scene counts by status, and the state of the latest compliance certificate. Paginated with `nextCursor`. |
| `get_production` | One production with its scenes, each carrying its status and a count of what is still outstanding (pending invites, unverified identities, unsigned forms and documents, missing releases). |
| `get_scene_compliance` | Per-participant compliance state for one scene, by id or `SCENE-` code: invite state, identity verification state and freshness, form and document counts, model release. |
| `list_compliance_gaps` | Scenes with outstanding work and what is outstanding on each. Filter by `attestationSource` to see every scene carrying a given provenance. |
| `list_expiring_documents` | Assigned documents expiring within a number of days, across the account's scenes. |
### Certificates and archives
| Tool | Returns |
|---|---|
| `get_certificate` | Every compliance certificate issued for one production with its lifecycle state (`CURRENT`, `SUPERSEDED` with `supersededBy`, `REVOKED`). Delivery state is included when the caller owns the linked partner registration. |
| `verify_certificate` | Checks a certificate belonging to any account: pass the full JWT to verify its signature, or a code to check its state. Lookups of certificates you do not own are rate limited. |
| `get_archive_status` | The sealed archive for a finalized production: when it was sealed, whether it is locked, headline counts, and the SHA-256 hashes of the package and report. |
### Account
| Tool | Returns |
|---|---|
| `get_account_status` | Plan, subscription state, scene allowance for the current period, whether the 2257 custodian statement is published, and the most recent acquirer report period. |
| `get_removal_requests_summary` | Removal requests linked to the account's own productions or scenes: counts by status and legal basis, plus open items with deadlines. |
| `list_notifications` | Notification types and timestamps with the record each concerns. Titles and bodies are not returned. |
| `list_webhook_deliveries` | Delivery attempts for the webhook endpoints registered under the account's own partner registration: event type, HTTP status, attempts, outcome. Payloads are not returned. |
| `get_compliance_requirements` | What a complete record looks like: document tiers, participant roles, the checks that produce a gap, and the document templates this account can use. |
### Solo creators and public codes
| Tool | Returns |
|---|---|
| `list_content_logs` | A solo creator's own content logs: code, title, type, platform, date, depiction count, and the public lookup URL. Requires a solo creator plan. |
| `get_content_log` | One content log by `LOG-` code or id, with its attestation state and public lookup URL. |
| `verify_record` | Any Easy2257 record by its public code (`SCENE-`, a project code, or `LOG-`) and the evidence state behind it: complete or not, how many participants are satisfied, outstanding, or awaiting an undecided identity. Lookups of records you do not own are rate limited. |
### Resources
The server also publishes six `easy2257://` resources (`data-model`, `closed-scenes`, `certificates`, `provenance`, `removals`, `solo`). They explain the parts of the data model that surprise people: how a person maps to a record, what can and cannot change after a scene closes, when certificates are delivered, why an imported record never upgrades to a verified one, and what happens to a record after it is removed. An assistant that reads them before explaining an odd-looking result will explain it correctly.
## What comes back, and what never does
Every tool returns compliance state: ids, public codes (`PROD-`, `SCENE-`, `LOG-`), statuses, counts, timestamps, SHA-256 hashes, and short labels of the caller's own records (a production title, a key name).
No tool returns performer names, dates of birth, ID numbers, images, file paths, document contents, IP addresses, notification bodies, webhook payloads, or anything about a removal-request claimant. Those fields are not filtered out of responses; they are never selected in the first place, and an automated sweep calls every tool against poisoned rows on every build to keep it that way. This is why the connection is safe to point at a shared assistant: there is no identity data in it to leak.
## Limits
- **One account per credential.** A key or token sees the account that issued it and nothing else.
- **Foreign lookups are budgeted.** `verify_record` and `verify_certificate` on records the caller does not own are rate limited per credential. Once the budget is spent, the tool answers the same way whether or not the code exists. Lookups of your own records are not budgeted.
- **No paid or attesting path.** Scene activation, checkout, attestations, and signatures happen at easy2257.com, by a person, on purpose. An assistant asked to do any of those should point the producer at the site.
- **Every call is logged** against the credential that made it, and a producer can see when each key or connection was last used.
Producer-facing setup guide: [easy2257.com/ai-assistant](https://easy2257.com/ai-assistant).
Listed in the official MCP registry as `io.github.Agaveis/easy2257-mcp`. The registry entry, the connector's privacy statement, and example prompts are at [github.com/Agaveis/easy2257-mcp](https://github.com/Agaveis/easy2257-mcp).
### https://docs.easy2257.com/docs/api/collab-scenes/cancel
**No refund.** Cancelling does not return the scene fee or the performer onboarding fees: they paid for record creation and for verification sessions that were already started. Only scenes still in `pending_signatures` can be cancelled, and only by the partner that created them.
### https://docs.easy2257.com/docs/api/collab-scenes/create
**The uploader is the producer.** Whoever this call is made for is recorded as the 18 U.S.C. § 2257 primary producer, and Easy2257 is Custodian of Records. Content stays on your platform until the `collab_scene.greenlit` webhook fires.
**Not idempotent.** Every call creates a new scene, emails every performer, and charges fees: $5 per scene (500 cents), plus $25 (2500 cents) for each performer who does not already have an active Easy2257 account or an approved ID check from a collab scene in the last 12 months. The $25 is once per person, not once per scene. Guard it behind your own dedupe key.
**On a platform-funded account, collab scenes bill your platform invoice.** If your partner account pays for its creators, Easy2257 switches collab scenes on per account. Once it has, the fees above go on your platform's next monthly invoice instead of the creator's card: `fees.billedTo` reads `platform_invoice`, and `charged` stays `false` because nothing is taken when the scene is created. Until it is switched on, this call returns `409` with code `collab_scenes_not_available_on_platform_pays` before anything is created; log each creator's own uploads through `POST /api/v1/solo-accounts/{accountId}/content` and ask Easy2257 to switch collab scenes on. A switched-on account with no active or past-due platform fee subscription gets `409` `collab_platform_billing_unavailable`, also before anything is created.
### https://docs.easy2257.com/docs/api/collab-scenes/extend
**Sign once, publish everywhere.** Once every performer has signed on one platform, the uploader can syndicate the scene to any other Easy2257 partner platform without a second signing round: the model releases already cover multi-platform distribution. Each platform then registers its own publication URL and gets its own acquirer reports.
**No request body.** Authorization is the `sceneId` itself, which the scene's creating partner shares with you when the syndication is arranged. Extending charges your partner account $2 (200 cents, USD), off-session against your partner owner user's saved payment method, at most once per partner per scene. The extension is recorded even if that charge fails: check `fee.charged` in the response, and re-POST to retry an unpaid fee without being charged twice.
### https://docs.easy2257.com/docs/api/collab-scenes/get
**What you get back depends on whether you created this scene.** Both audiences receive `performerCount`, `consentedCount`, `releaseSignedCount` and `refusedCount`. The creating partner also receives the `performers` roster and the `fees` block, because it supplied those addresses and paid those fees. A partner that reached the scene by extending somebody else's receives the counts only, the same policy `POST /extend` has always applied. Treat `performers` and `fees` as optional in your client.
### https://docs.easy2257.com/docs/api/collab-scenes/publication-remove
**Your publication only.** Removing yours does not touch another platform's publication of the same scene, and you cannot address theirs. The scene's creating partner is notified with `collab_scene.publication_removed`, which is how a syndicated scene stays auditable across platforms.
### https://docs.easy2257.com/docs/api/collab-scenes/publications
**SHA-256 is required.** Compute the digest of the exact file you published and pass it as `depictionSha256`. That hash is what satisfies 28 CFR 75.2(f) record integrity.
If you did not create the scene, call `POST /extend` first. Without it this endpoint returns `403` with code `extension_required`.
### https://docs.easy2257.com/docs/api/faq
## Is there a sandbox?
No. There is no sandbox and no sandbox host. Keys beginning `ez_test_` are rejected with `403` and code `TEST_MODE_UNAVAILABLE` before any handler runs, and only `ez_live_` keys work.
The reason is that every v1 handler writes production records, provisions real Easy2257 users, and sends real email. A test key that reached those handlers would do all three against live data, which is precisely what a sandbox is supposed to prevent.
Rehearse against production with mailboxes you control, then DELETE the content you logged. Note that new partner accounts are issued a **test** key, so create a live key at [`partner.easy2257.com/api-keys`](https://partner.easy2257.com/api-keys) before your first call.
## How is idempotency handled?
With natural keys on the resource, not an `Idempotency-Key` header. Nothing reads that header.
| Endpoint | Deduplicated on |
|----------|-----------------|
| `POST /api/v1/solo-accounts` | `(your partner account, externalUserId)` and `(your partner account, email)` |
| `POST /api/v1/solo-accounts/{id}/content` | `externalContentId`, falling back to `contentUrl` |
| `POST /api/v1/collab-scenes/{id}/publications` | `(your partner account, contentUrl)` |
Replays return the existing record with `200` instead of `201`, and content-log replays add `"duplicate": true`.
`POST /api/v1/collab-scenes` and `POST /api/v1/upload-sessions` are **not** idempotent: each call creates a new scene or session, and the scene call also emails performers and charges fees. Dedupe those on your side.
## What does a creator have to approve, and when?
Only when the email you provision already belongs to an Easy2257 user. Easy2257 will not hand an existing user's identity records to a platform on that platform's say-so, so it emails them and asks.
- The account stays unlinked and `POST` or `DELETE` on its content returns `409` (`CONSENT_PENDING`) until they approve.
- The account activates and `solo_account.verified` fires only after approval.
- If they decline, those routes return `409` with `CONSENT_DECLINED`, and they are never emailed about your platform again.
- The approval email is throttled to at most one per creator per day, so re-provisioning is safe but is not a way to nag.
Your provisioning response is identical in every one of these cases, so no code change is needed at step 1 and your integration cannot be used to probe whether one of your users already has an Easy2257 account.
## Which statuses can I actually see?
Exactly four: `pending_id_verification`, `subscribed_pending_id`, `active`, `suspended`. Unlock uploads on `active` only.
Internal states are collapsed into `pending_id_verification` so that account existence and consent decisions are not observable. Do not code against any other value.
Note that the `solo_account.verified` webhook carries `status: "verified"`, not `"active"`. Match on `event.type`.
## Where do webhooks get delivered?
To the endpoints you register at [`partner.easy2257.com/webhooks`](https://partner.easy2257.com/webhooks), and only those. The `callbackUrl` field on a solo account, collab scene, or upload session is stored for your reference and subscribes you to nothing.
Each endpoint has its own `whsec_` signing secret, so you can rotate one integration without touching another. Deliveries carry `X-EZ2257-Signature`, `X-EZ2257-Event`, and `X-EZ2257-Delivery`, and are retried immediately, then at 1 minute, 5 minutes, 30 minutes, and 2 hours, with a 30-second timeout per attempt.
## How do I rotate an API key without downtime?
Create the new key, deploy your client with it, confirm traffic has moved by watching `lastUsedAt` in the portal, then revoke the old one. A 24-hour overlap is conventional, and you can hold up to 10 active keys.
Keys are stored as SHA-256 hashes. The plaintext is shown once at creation and never again, so copy it into your secret store immediately. If you lose it, rotate.
## When does Easy2257 return a 409?
Six cases, all of them state conflicts rather than replay errors:
| Code | Where |
|------|-------|
| `CONSENT_PENDING` | Content routes, while a creator has not yet approved the link |
| `CONSENT_DECLINED` | Content routes, after a creator refused |
| `duplicate_publication` | Registering a `contentUrl` your partner account already registered |
| `collab_scenes_not_available_on_platform_pays` | `POST /v1/collab-scenes` from a platform-funded account whose collab scenes Easy2257 has not switched on |
| `collab_platform_billing_unavailable` | `POST /v1/collab-scenes` from a platform-funded account that is switched on but holds no active or past-due platform fee subscription |
| `uploader_not_solo_active` | `POST /v1/upload-sessions`, or `POST /v1/solo-accounts/{id}/content`, when the uploader has no active solo account |
One caveat on that last code, because it is the only one that is not a `409` everywhere: `POST /v1/collab-scenes` returns the same code as a `400`, since there the identifier failing to resolve is a bad request rather than a state you can wait out. Match on `code` together with the route you called, not on `code` alone.
Replays never produce a `409`. They return the original record with `200`.
## Are there rate limits?
Yes: **300 requests per 60 seconds, keyed by API key**, applied to every `/api/v1` request that presents an API key in the `Authorization: Bearer` header. It is a sliding window, and all v1 endpoints share one bucket per key, so it is a budget for each key's whole integration, not per endpoint — and other services on the same egress address do not draw it down. Requests without a key fall back to 60 per 60 seconds per client IP, and a per-address flood ceiling of 1200 per 60 seconds bounds keyed traffic during abuse. Other `/api` paths outside `/api/v1` carry their own limits.
Responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (epoch milliseconds). Over the limit you get `429` with `Retry-After` in seconds and a body of `{ error, retryAfter, limit, remaining, reset }`.
The normal integration shape stays well under this: one provisioning call per creator, one log call per upload, webhooks for the rest. Pace backfills and nightly sweeps, and honor `Retry-After`.
## What is the versioning policy?
The version is in the path. There is no version header, and no response header signals a version, a deprecation, or an end-of-life date.
Additive changes ship without notice: new optional fields, new endpoints, new event types, new error codes. Ignore fields you do not recognize, and treat an unknown `code` as its HTTP status. Anything breaking ships as a new path version, and the [changelog](/docs/changelog) records what moved and what to do about it.
## How long are records retained?
Easy2257 enforces a 7-year retention guard on 28 CFR Part 75 records: performer ID material, cross-reference index entries, and content logs. It holds regardless of account suspension or deletion.
Inside that window a takedown marks a depiction removed and stamps who removed it and why, rather than erasing it, which is what makes the trail survive the removal. Outside the window the record is deleted outright.
If your platform deletes user data under GDPR or CCPA, the Easy2257 records are exempt for the retention window. Tell the user that at deletion time.
## What should I include in a support ticket?
- Your partner id, from the portal
- The approximate UTC timestamp of the request
- The request body with PII redacted, plus the response status and body
- For webhook issues: the event id (`evt_...`) and your endpoint URL
Email `api@easy2257.com`.
### https://docs.easy2257.com/docs/api
Easy2257 is a REST API over HTTPS. Every endpoint lives under `https://easy2257.com/api/v1/`. The endpoint reference is in the left nav; the conventions below hold across all of it.
## What your platform never has to store
No endpoint in this API returns a performer's legal name, date of birth, government ID image, or Easy2257 user id. The only identifiers you hold are the `accountId` Easy2257 issues and the `externalUserId` you supplied yourself.
That is the point of naming Easy2257 as Custodian of Records. The ID material never lands in your database, so it cannot leak from it, it cannot be subpoenaed out of it, and there is nothing for you to produce when an inspector calls: the inspection goes to Easy2257.
## Authentication
Send your API key as a Bearer token on every request:
```
Authorization: Bearer ez_live_abc123...
```
| Key prefix | Result |
|-----------|--------|
| `ez_live_` | Accepted. |
| `ez_test_` | Rejected with `403` and code `TEST_MODE_UNAVAILABLE`, before any handler runs. |
**There is no sandbox and no sandbox host.** Test-mode keys are refused outright because every v1 handler writes production records, provisions real users, and sends real email. The rejection body is:
```json
{
"error": "Test-mode keys cannot be used against the production API. A sandbox environment is not yet available; use your ez_live_ key.",
"code": "TEST_MODE_UNAVAILABLE"
}
```
Rejected calls still land in your request log at [`partner.easy2257.com/api-usage`](https://partner.easy2257.com/api-usage), so a misconfigured key is visible rather than silent.
**New partner accounts are issued a test key.** Your first task is to create a live key at [`partner.easy2257.com/api-keys`](https://partner.easy2257.com/api-keys). A first integration that fails with `403` almost always has the default test key wired in.
The secret is shown **once** at creation and stored only as a SHA-256 hash, so there is no reveal later. Copy it straight into your secret store; if you lose it, rotate. Up to 10 active keys per partner.
To rotate without downtime: create the new key, deploy with it, confirm traffic has moved by watching `lastUsedAt` in the portal, then revoke the old one. A 24-hour overlap is conventional.
## Idempotency
There is no `Idempotency-Key` header, and nothing reads one if you send it. Replay safety is a property of the resource instead, keyed on values you already send:
| Endpoint | Deduplicated on |
|----------|-----------------|
| `POST /api/v1/solo-accounts` | `(your partner account, externalUserId)` and `(your partner account, email)` |
| `POST /api/v1/solo-accounts/{accountId}/content` | `externalContentId`, falling back to `contentUrl` when you omit it |
| `DELETE /api/v1/solo-accounts/{accountId}/content/{externalContentId}` | The depiction itself. A second call returns `{ "deleted": false }`. |
| `POST /api/v1/collab-scenes/{sceneId}/extend` | `(your partner account, sceneId)`. A re-POST retries an unpaid extension fee instead of charging again. |
| `POST /api/v1/collab-scenes/{sceneId}/publications` | `(your partner account, contentUrl)`. A repeat returns `409` / `duplicate_publication`. |
| `POST /api/v1/collab-scenes/{sceneId}/publications/{pubId}/remove` | The publication's `removedAt`. A repeat returns the original value. |
Replays return the existing record with `200` instead of `201`. Content-log replays also carry `"duplicate": true`.
Two consequences worth designing around. The email scope on provisioning means a creator's consent decision sticks even if your own user id for them changes. And a retry fired after a timeout cannot produce a second account, a second charge, or a second email.
**Two endpoints are not idempotent: `POST /api/v1/collab-scenes` and `POST /api/v1/upload-sessions`.** Each call creates a new scene or session, and the scene call also sends invitation emails and charges fees. Guard both behind your own dedupe key.
## Errors
Errors are a flat envelope. The message lives in `error`, and the HTTP status is the contract:
```json
{ "error": "email, externalUserId, and callbackUrl are required" }
```
Most endpoints add a machine-readable `code`. Authentication rejections do not: a `401` body carries only `error`, so switch on the status there.
The collab-scene and upload-session routes also add `field`:
```json
{ "error": "detectedFaceCount must be an integer between 1 and 50", "code": "invalid_field", "field": "detectedFaceCount" }
```
| HTTP status | Meaning |
|-------------|---------|
| 400 | Invalid request: missing or malformed parameter |
| 401 | Missing `Authorization` header, or a key that is unknown, revoked, or expired |
| 403 | Only three causes: a test-mode key (`TEST_MODE_UNAVAILABLE`), a partner account that is not active, or registering a publication on a scene you have not extended (`extension_required`) |
| 404 | Not found, **and** the answer for anything belonging to another partner |
| 409 | State conflict: `CONSENT_PENDING`, `CONSENT_DECLINED`, `duplicate_publication`, or `uploader_not_solo_active` on the content and upload-session routes (on `POST /v1/collab-scenes` that same code is a `400`); `collab_scenes_not_available_on_platform_pays` or `collab_platform_billing_unavailable` on `POST /v1/collab-scenes` from a platform-funded account |
| 422 | Understood, but the resource is in a state that does not allow it |
| 429 | Rate limited |
| 500 | Server error: retry with exponential backoff |
**Solo-account and production reads return `404`, never `403`, for a resource that is not yours.** An `accountId`, its content, or a production belonging to another partner is indistinguishable from one that does not exist. That is deliberate: it means the API cannot be walked to discover which creators are on which platform, including yours.
Collab scenes are the exception, because cross-partner syndication is a supported flow there rather than an error. `POST /collab-scenes/{sceneId}/publications` can return `403` with `extension_required`: the scene exists, but your partner account has not extended onto it yet. Call `POST /extend` first, and handle the `403` alongside `404`.
Switch on status and on `code`. Message text is not part of the contract and can change.
## Rate limits
**300 requests per 60 seconds, keyed by API key**, applied by edge middleware to every `/api/v1` request that presents an API key in the `Authorization: Bearer` header. It is a sliding window, and all v1 endpoints share one bucket per key, so calls made with one key add up together — but the budget is the key's own: other services behind the same NAT address, or other traffic from your egress IP, do not draw it down. Other `/api` paths outside `/api/v1` carry their own limits and are not part of this budget.
Requests to `/api/v1` **without** an API key fall back to a per-client-IP limit of 60 per 60 seconds. A per-address flood ceiling of **1200 requests per 60 seconds** also applies to keyed traffic; it exists to stop unauthenticated floods, sits at the combined budget of four fully-used keys, and a normal integration never encounters it.
Every response carries the current state:
```
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 298
X-RateLimit-Reset: 1714000060000
```
`X-RateLimit-Reset` is epoch **milliseconds**. Over the limit you get `429` with `Retry-After` in seconds and this body:
```json
{
"error": "Too many requests. Please slow down.",
"retryAfter": 14,
"limit": 300,
"remaining": 0,
"reset": 1714000060000
}
```
In practice the integration shape stays far under this: one provisioning call per creator, one log call per upload, and webhooks for everything else. Backfills and nightly sweeps are where you need to pace yourself, so honor `Retry-After` and back off with jitter:
```javascript
async function withRetry(fn, maxAttempts = 4) {
for (let i = 0; i < maxAttempts; i++) {
try { return await fn(); }
catch (err) {
if (err.status !== 429 || i === maxAttempts - 1) throw err;
const wait = err.retryAfter
? err.retryAfter * 1000
: Math.min(1000 * 2 ** i + Math.random() * 100, 10000);
await new Promise(r => setTimeout(r, wait));
}
}
}
```
## Identifiers
Every id Easy2257 returns is an opaque 25-character cuid with no prefix, for example `cmd4k2x9p0001qz8h3v7ftg2a`. Store them as strings, do not parse them, and do not assume a prefix or a fixed length beyond sizing your column generously.
The exceptions are the values Easy2257 formats for you: webhook event ids (`evt_...`), delivery ids (`del_...`), webhook signing secrets (`whsec_...`), and API keys (`ez_live_...`).
## Webhooks
Deliveries go **only** to endpoints you register in the partner portal at [`partner.easy2257.com/webhooks`](https://partner.easy2257.com/webhooks). The `callbackUrl` field on a solo account, collab scene, or upload session is stored for your own reference and subscribes you to nothing.
See [Webhooks](/docs/webhooks) for the envelope, the signature scheme, the full event list, and the retry schedule.
## Versioning
The version is in the path. There is no version header, and no request is versioned by one.
Additive changes ship without notice: new optional fields, new endpoints, new event types, new error codes. Write your handlers to ignore fields they do not recognize and to treat an unknown `code` as its HTTP status. Anything breaking ships as a new path version, and the [changelog](/docs/changelog) records what moved.
### https://docs.easy2257.com/docs/api/productions/certificates
**Exactly one certificate is `CURRENT` for a documented production.** `SUPERSEDED` means a later certificate replaced it, either at annual renewal or when the producer reissued the sealed package after a correction, and the content stays up; `REVOKED` means the certified record was pulled and the content comes down. Certificates issued before storage began return `jwt: null`; those arrive with the next renewal or reissue.
### https://docs.easy2257.com/docs/api/productions/get
**Productions are partner-scoped.** A production is visible only to the partner it is linked to, and anything else returns `404` rather than `403`. If you received a `production.documented` webhook for it, it is linked to you and this endpoint will return it.
### https://docs.easy2257.com/docs/api/productions
## What a compliance certificate is
When a producer completes documentation for a production, Easy2257 issues a signed **RS256 JWT**: the compliance certificate. You store it next to the content. A federal inspector or a payment-network auditor can verify it offline against the public JWKS, with no call to Easy2257 and no dependency on our uptime.
## Payload
```json
{
"iss": "easy2257.com",
"sub": "cmd3p8w1k0002qz8h9j4r7v5s",
"jti": "0b4b8f4e-6a7c-4a1e-9f0e-2f1d7c3a5b9e",
"productionId": "cmd3p8w1k0002qz8h9j4r7v5s",
"performerCount": 3,
"documentedAt": "2026-04-24T18:00:00Z",
"custodian": {
"name": "",
"address": ""
},
"partnerId": "cmd2n6t0h0001qz8h1c9x4b7m",
"iat": 1714000000,
"exp": 1745536000
}
```
`sub` and `productionId` carry the same value. `custodian` is filled from the Custodian of Records identity published on the Easy2257 records statement, so a certificate and a statement can never disagree. `partnerId` is null for productions not linked to a partner.
**Algorithm:** RS256. **Issuer:** `easy2257.com`. **Lifetime:** 1 year from issuance, then rotated by annual re-attestation. A producer reissuing the sealed package after a correction rotates it sooner, so treat the newest `production.documented` as authoritative rather than the expiry.
## Offline verification
Fetch the public JWKS from `https://easy2257.com/.well-known/jwks.json` and cache it for 24 hours. Verify with the key matching the `kid` header. Any RS256-capable library works:
```javascript
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://easy2257.com/.well-known/jwks.json'));
const { payload } = await jwtVerify(certificate, JWKS, { issuer: 'easy2257.com' });
```
Offline verification proves the signature, the issuer, and the expiry. It cannot see a revocation, which is why the revocation list exists.
## Lifecycle
A certificate is `CURRENT` until one of two different things happens. A **revocation** means the certified record was pulled (a removal request was actioned): take the content down. A **supersession** means a newer certificate replaced it, either at the annual re-attestation or when a producer reissued the sealed package after correcting a closed record: swap it in and the content stays up. Sync the [Certificate Revocation List](/docs/api/productions/revoked-certificates) daily for revocations, and subscribe to `production.takedown_requested` and `production.certificate_renewed` for the real-time signals. Neither a renewal nor a producer reissue is a revocation, and neither appears in the revocation list.
## Delivery and retrieval
The certificate arrives in the `production.documented` webhook payload when documentation completes, and a fresh `production.documented` arrives again whenever a producer reissues the sealed package after a correction. Store the newest one indexed by `productionId`. If you miss or lose one, fetch it any time from [`GET /api/v1/productions/{productionId}/certificates`](/docs/api/productions/certificates), which lists every certificate for the production with its lifecycle status and the signed JWT.
### https://docs.easy2257.com/docs/api/productions/list
**This is your right-to-removal lookup.** When a performer asks to be removed, one call returns every production on your platform they appear in, so you can action the request inside the legal window instead of hunting through your own catalog.
`status` defaults to `documented`. A removal sweep that needs everything must pass the status it wants explicitly.
### https://docs.easy2257.com/docs/api/productions/revoked-certificates
**Advance your watermark from the top-level `retrievedAt`, not from a revocation's `revokedAt`.** `retrievedAt` is the server time this response was built. The per-item `revokedAt` values belong to individual certificates and are always in the past, so a sync that stores one of those as its next `since` will re-read the same rows forever or skip rows that land between them.
`nextCursor` is an opaque page cursor, not a `productionId`. Pass it back verbatim as `cursor`. A reason starting `removal_request:` means take the content down; match on the prefix, because the id after it varies. Annual renewals are not revocations and no longer enter this list; entries recorded before that split carry `type: supersession` and their content stays up.
### https://docs.easy2257.com/docs/api/productions/verify-certificate
**A `200` does not mean the certificate is valid. Read the `valid` field.** This endpoint returns `200` for good and bad certificates alike, and `reason` explains a failure. The one exception is `production_id_mismatch`, which arrives as `400`: the certificate verified, but it belongs to a different production than the one in the URL.
### https://docs.easy2257.com/docs/api/productions/webhooks
These four events cover the Verification API. The [full event reference](/docs/webhooks/events) documents every Easy2257 event, and [Webhooks](/docs/webhooks) covers the envelope, signatures, and retries.
Deliveries go only to endpoints registered at [`partner.easy2257.com/webhooks`](https://partner.easy2257.com/webhooks). The `callbackUrl` field on a resource is stored for your reference and subscribes you to nothing.
Every delivery uses the same envelope. Switch on `type` and read the event fields from `data`, and deduplicate on `id`:
```json
{
"id": "evt_m2k9x1_a7f3qz",
"type": "production.documented",
"created": 1714000000,
"data": { }
}
```
## production.documented
Documentation completed and the production moved to `documented`. The payload carries the compliance certificate that now stands, so **store it**. This event fires again when a producer reissues the sealed package after a correction: the certificate on the newer delivery replaces the one you hold, and the one it replaces is marked superseded, not revoked.
```json
{
"productionId": "cmd3p8w1k0002qz8h9j4r7v5s",
"performerCount": 3,
"documentedAt": "2026-04-24T18:00:00Z",
"certificate": "eyJhbGc..."
}
```
## production.takedown\_requested
A depicted person submitted a removal request through the Easy2257 portal. Remove the content from every public surface by `removalDeadline`.
```json
{
"productionId": "cmd3p8w1k0002qz8h9j4r7v5s",
"removalRequestId": "cmd7v3n8y0001qz8h2b6k4m1p",
"legalBasis": "NCII_TAKE_IT_DOWN",
"performerEmailHash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
"requestedAt": "2026-04-24T18:00:00Z",
"removalDeadline": "2026-04-26T18:00:00Z"
}
```
**Despite the name, `performerEmailHash` is the SHA-256 hex digest of the lowercased, trimmed address the claimant filed the removal request with.** The claimant is whoever submitted the request: often the depicted person, but possibly a parent, attorney, or other representative, and possibly the performer using a different address than the one you hold. Hashing your own records and comparing digests is therefore a correlation hint, not an identifier: a miss does not mean the request is invalid. `productionId` and `removalDeadline` are what govern the takedown. No raw address is ever sent, and there is no lookup that turns the hash back into an address.
`removalDeadline` reflects the legal basis: 48 hours for `NCII_TAKE_IT_DOWN`, 72 hours for `NCII_STATE`, 7 business days for `DMCA`, `AN_5196_APPEAL`, and `OTHER`.
The certificate is **not** revoked at this point. Revocation follows when an Easy2257 admin confirms the takedown was actioned. Start removing on this event; do not wait for the revocation list to confirm it.
## production.certificate\_renewed
Annual re-attestation superseded the old certificate and issued a new one. Replace your stored copy with `newCertificate`; the content stays up, and the superseded certificate does not enter the revocation list.
```json
{
"productionId": "cmd3p8w1k0002qz8h9j4r7v5s",
"newCertificate": "eyJhbGc...",
"previousCertificateExpiresAt": "2027-04-24T18:00:00Z"
}
```
## production.records\_inspected
A federal inspector accessed the records for that production through the Easy2257 inspection portal. No action is required from you, because Easy2257 answered as Custodian of Records. Log it for your own audit trail.
```json
{
"productionId": "cmd3p8w1k0002qz8h9j4r7v5s",
"inspectedAt": "2026-04-24T18:00:00Z",
"inspectorAgency": "FBI"
}
```
### https://docs.easy2257.com/docs/api/solo-accounts/confirm-compliance-copy
**Order of operations.** `POST .../content` with `complianceCopy` declared, `PUT` the file to the returned URL, then this call. A `409 NOT_UPLOADED` means the PUT has not landed; `SIZE_MISMATCH` or `TYPE_MISMATCH` means the object differed from the declaration and was discarded. Idempotent: confirming a stored copy returns the recorded digest.
### https://docs.easy2257.com/docs/api/solo-accounts/create
**One call handles both cases.** If the address is new to Easy2257, the creator is provisioned and emailed a link. If it already belongs to an Easy2257 user, that person is emailed and asked to approve the connection first, and their identity record stays unlinked until they do. The response is identical either way, so you write one branch, and your integration cannot be used to probe whether one of your users already has an Easy2257 account.
Until a creator on the consent path approves, `POST` and `DELETE` on that account's content return `409` with code `CONSENT_PENDING`, and `CONSENT_DECLINED` if they refuse. The `solo_account.verified` webhook fires only after approval. Re-provisioning is safe but is not a way to nag: the approval email goes out at most once per creator per day, and a creator who declines is never emailed about your platform again.
### https://docs.easy2257.com/docs/api/solo-accounts/deactivate
**Records stay. Only the billing line stops.** Deactivating deletes nothing: the creator's identity record and every depiction you logged stay in custody for the seven-year retention window. What changes is that the creator is no longer in custody on your behalf, so they are not on your next invoice, and their `solo_account.suspended` event tells you to revoke upload access.
**Funded creators only.** This verb is for creators your platform pays for. A creator who subscribes to Easy2257 directly is not on your invoice and returns `409 CREATOR_PAYS_OWN_SUBSCRIPTION`.
### https://docs.easy2257.com/docs/api/solo-accounts/delete-content
**TAKE IT DOWN Act SLA: 48 hours.** Once you receive the `content_log.deleted` webhook, whether you triggered it or Easy2257 did through the public removal portal, the file must be gone from your CDN within 48 hours. Make that path idempotent so it still works when your own DELETE already succeeded.
**The custody record outlives the takedown.** Inside the 7-year retention window the depiction is marked removed and stamped with who removed it and why, rather than erased. That is what an inspector needs to see, and it is why you can honor a removal request immediately without destroying evidence.
### https://docs.easy2257.com/docs/api/solo-accounts/get
**This response carries no identity data.** No legal name, no date of birth, no ID image, no Easy2257 user id, not even the email you provisioned with. Your entire identifier surface is `accountId` plus your own `externalUserId`. The one creator-published value it does return is `complianceStatementUrl`, the public 2257 statement page you are asked to link from your content pages; it appears once the account is `active` and the creator has published that page.
### https://docs.easy2257.com/docs/api/solo-accounts/log-content
**Send the compliance copy.** Easy2257 keeps a copy of every depiction it records (28 CFR 75.2(a)(1)(iii)). Declare it on this call as `complianceCopy: { mimeType, sizeBytes }` using your lowest-resolution rendition (images under 5 MB, video under 500 MB; quality does not matter), `PUT` the file to `complianceCopyUpload.url` with the `Content-Type` header given (the URL is valid 15 minutes and bound to the declared size and type), then call `POST /api/v1/solo-accounts/{accountId}/content/{externalContentId}/compliance-copy` to confirm. Easy2257 verifies size and type, hashes the bytes itself and fires `content_log.copy_stored`. Platform-plan partners must declare a copy; the JuicyStarz pipeline may omit it for now and the depiction shows as `complianceCopy: "missing"`. Re-posting the same `externalContentId` while the copy is pending returns a fresh upload URL.
**Always send `externalContentId`.** It is the idempotency key, and it is the only handle you can address later for a takedown. A depiction logged without one cannot be removed through the API.
### https://docs.easy2257.com/docs/api/solo-accounts/reactivate
**No new verification.** A returning creator is reactivated on the identity record Easy2257 already holds. Nothing is re-examined and nothing is charged for the return; they simply count again on your next invoice. If they never finished ID verification before you deactivated them, they come back at `subscribed_pending_id` and `GET /api/v1/solo-accounts/{accountId}` returns their continue URL.
### https://docs.easy2257.com/docs/api/talent/check
**Scoped to your own creators, by design.** Any address you did not provision returns `found: false`, including creators verified through another platform. The endpoint cannot be used to discover whether an arbitrary email has an Easy2257 account, and it returns no name, no date of birth, and no ID.
### https://docs.easy2257.com/docs/api/troubleshooting
## HTTP status decoder
| Code | What it means | Most common cause |
|------|---------------|-------------------|
| **200** | Success, nothing new created | Idempotent replay, or a successful no-op DELETE |
| **201** | Success, record created | First-time request |
| **400** | Bad request | Missing required field, malformed JSON, invalid URL or hash |
| **401** | Authentication failed | No `Authorization` header, or a key that is unknown, revoked, or expired |
| **403** | Refused | Test-mode key (`TEST_MODE_UNAVAILABLE`), partner account not active, or `extension_required`. Never cross-partner access |
| **404** | Not found | Wrong id, **or** the resource belongs to another partner. Identical responses on purpose |
| **409** | State conflict | `CONSENT_PENDING`, `CONSENT_DECLINED`, `duplicate_publication`, and `uploader_not_solo_active` on the content and upload-session routes. `POST /v1/collab-scenes` returns that last code as a `400`, so match on code together with the route you called. From a platform-funded account, `POST /v1/collab-scenes` can also return `collab_scenes_not_available_on_platform_pays` (collab scenes not switched on) or `collab_platform_billing_unavailable` (no active or past-due platform fee subscription) |
| **422** | Unprocessable | Right shape, wrong state: `not_greenlit`, `already_owner`, `invalid_status`, or an account with no linked Easy2257 user |
| **429** | Rate limited | 300 requests per 60 seconds per API key, shared across all of `/api/v1` |
| **500** | Server error | Retry with exponential backoff |
Switch on the status and on `code`, never on message text. See [Errors](/docs/api#errors) for the response shape.
## Everything returns 403
You are calling with a test-mode key. Check the response body for `"code": "TEST_MODE_UNAVAILABLE"`.
There is no sandbox, so `ez_test_` keys are rejected before any handler runs. New partner accounts are created **with a test key**, which is why this is the most common first-integration failure. Create a live key at [`partner.easy2257.com/api-keys`](https://partner.easy2257.com/api-keys) and deploy that.
If the key starts with `ez_live_` and you still get `403`, your partner account is not active. Contact `api@easy2257.com`.
## 404 on an id you know exists
The resource belongs to a different partner account, or the id is wrong. Easy2257 answers both cases identically so the API cannot be walked to discover which creators are on which platform.
Check that the key you are calling with belongs to the same partner account that created the resource. Two environments pointed at two different partner accounts is the usual cause: the account was provisioned with one key and read with another.
## Webhook not arriving
Work through in order:
1. **Have you registered an endpoint?** Deliveries go only to endpoints created at [`partner.easy2257.com/webhooks`](https://partner.easy2257.com/webhooks). The `callbackUrl` you passed to `POST /v1/solo-accounts` or `POST /v1/collab-scenes` is stored for your reference and subscribes you to nothing. A handler written without this step never fires.
2. **Does the endpoint subscribe to that event type?** An endpoint carries an event list, or `*` for everything. An event outside the list is not delivered and no delivery row is created.
3. **Is the URL reachable and public?** Easy2257 posts from AWS with no IP allowlist to configure, but a URL that resolves to a private or internal address is rejected at delivery time as an SSRF guard, and a VPN-only or strict WAF setup will block it.
4. **Did your endpoint return `2xx` within 30 seconds?** The delivery times out at 30 seconds and is treated as a failure even if your handler eventually finished. Return `2xx` immediately and process asynchronously.
5. **Check the delivery log.** Every attempt is recorded in the portal with HTTP status, attempt count, and final state (`DELIVERED`, `FAILED`, `EXHAUSTED`). Delivery is attempted immediately, then retried at 1 minute, 5 minutes, 30 minutes, and 2 hours. After 5 attempts it is abandoned as `EXHAUSTED`.
6. **Send a test ping.** The portal's test button fires a real signed `test.ping` at the endpoint, which proves the whole path end to end.
## Webhook signature verification keeps failing
This is the most common integration bug. Five things to check:
1. **Raw body, not parsed JSON.** HMAC the exact bytes received. Do not `JSON.parse()` and re-serialize. Per framework:
- Next.js App Router: `await req.text()`, not `await req.json()`
- Express: `express.raw({ type: "application/json" })` on this route only, not `express.json()`
- Flask: `request.get_data()`, not `request.get_json()`
- FastAPI: `await request.body()`, not `await request.json()`
- Laravel: `$request->getContent()`, not `$request->all()`
- ASP.NET Core: `Request.EnableBuffering()`, then read the stream
2. **The signed payload is `{t}.{rawBody}`**, not the body alone. Take `t` from the signature header and concatenate timestamp, a literal dot, then the body.
3. **The digest is lowercase hex**, not base64.
4. **Compare in constant time.** `timingSafeEqual`, `hmac.compare_digest`, `hash_equals`, or `CryptographicOperations.FixedTimeEquals`. A plain `==` is a timing-attack risk.
5. **The secret is the per-endpoint `whsec_` webhook secret**, not your API key. Reveal it under the endpoint in the portal.
Retries are re-signed with a fresh timestamp, so a stored signature never matches a later delivery. Deduplicate on `event.id`, which is stable across attempts.
Working code for Node, Express, Flask, FastAPI, Laravel, plain PHP, and C# is in [Webhook Signatures](/docs/webhooks/signatures).
## 409 CONSENT_PENDING or CONSENT_DECLINED on content
The address you provisioned already belonged to an Easy2257 user, so Easy2257 emailed that person to ask whether they approve the connection. Until they approve, their identity record is not linked to your platform and nothing can be logged against it.
**Fix:** hold the content and wait for `solo_account.verified`, which fires only after approval. `CONSENT_DECLINED` is terminal for that address: the creator refused, and they are never emailed about your platform again. Do not loop on re-provisioning, which is throttled to one approval email per creator per day and will not change the answer.
## 409 `uploader_not_solo_active` on content
A different failure wearing the same status. This is not about consent: the creator approved the link, or never needed to. Their own Easy2257 account has stopped being active, most often a lapsed subscription, so there is no verified identity record to attach the depiction to.
**Fix:** send the creator back through onboarding. This is the one `409` on this route that waiting does not clear, so a queue that holds and retries every `409` will hold this one forever. Switch on `code`, not on the status. Takedowns are exempt by design: `DELETE` keeps working on an inactive account, because a lapsed subscription is not a reason to refuse a removal request.
## 422 "Account has no linked EZ2257 user"
You called `POST /api/v1/solo-accounts/{id}/content` for an account that has no Easy2257 user behind it yet.
**Fix:** wait for the `solo_account.verified` webhook before any `/content` call. Note that `subscribed_pending_id` means "paid, ID not yet verified": it is not a green light, and unlocking there is the classic version of this bug.
## DELETE returns `{ "deleted": false }`
The depiction is addressed by `externalContentId`. If the original `POST /content` did not include one, there is no key to remove it by, and DELETE reports an honest no-op rather than removing the wrong row.
**Fix going forward:** always send `externalContentId` when logging. For rows already logged without one, email `api@easy2257.com` for a one-off backfill.
A `404` on the same call means something different: the `accountId` is not one of yours.
## 403 extension_required when registering a publication
You are not the partner that created the scene, so you must call `POST /api/v1/collab-scenes/{sceneId}/extend` first. It takes no request body: the `sceneId` itself is the authorization, and it is the id the scene's creating partner shares with you when the syndication is arranged. The extension records your platform against the scene and charges the $2 extension fee; after that, publications register normally.
Check `fee.charged` in the `/extend` response. A failed charge does not revoke the extension, and a re-POST retries the unpaid fee without charging twice. A `422` with `already_owner` means the scene is already yours and needs no extension.
## Certificate verification says the certificate is fine, but the content is gone
Offline JWKS verification proves signature, issuer, and expiry. It cannot see a revocation, and a revoked certificate still verifies cryptographically. It cannot see a supersession either: a certificate replaced at annual renewal, or when a producer reissued the sealed package after a correction, still passes an offline check and never appears on the revocation list.
**Fix:** sync [`GET /api/v1/productions/revoked-certificates`](/docs/api/productions/revoked-certificates) daily and treat a listed `productionId` as authoritative over your stored certificate. To tell whether a certificate that verifies offline is still the one that stands, read [`GET /api/v1/productions/{productionId}/certificates`](/docs/api/productions/certificates) and take the one marked `CURRENT`.
## What to include in a support ticket
- Your partner id, from the portal
- The approximate UTC timestamp of the request
- The request body with PII redacted, plus the response status and body
- For webhook issues: the event id (`evt_...`) and your endpoint URL
Email `api@easy2257.com`.
### https://docs.easy2257.com/docs/api/upload-sessions/create
**Easy2257 hosts the form.** Post the content facts, redirect the uploader to `hostedUrl`, and Easy2257 captures the producer attestation with IP, user agent, and timestamp. You build no compliance UI, and the attestation text you would otherwise have to draft and version lives on our side.
`detectedFaceCount` decides the whole flow. `1` records a solo attestation and fires `upload_session.solo_completed`. `2` or more opens a collab scene from the collaborators the uploader names, emails them, and fires `collab_scene.created` carrying `uploadSessionId` so you can match the event back to your content row. Send the count your own pipeline detected; the uploader cannot add collaborators to a session you declared solo.
**AI-generated content.** Send `contentOrigin: "synthetic"` when no real person is in the content, such as an AI persona's post, and `"mixed"` when the uploader appears with AI-generated figures. Both need `contentSha256`, the SHA-256 of the file being attested. A synthetic session never invites collaborators, whatever the face count, and fires `upload_session.synthetic_completed`, a separate event so a handler written for `solo_completed` does not publish content it was not built for. A mixed session invites only the real people the uploader names; with none named it also fires `upload_session.synthetic_completed`. Omit `contentOrigin` and nothing about the session changes.
### https://docs.easy2257.com/docs/changelog
## v2026-10-25
### Upload sessions
- **New: `contentOrigin` on `POST /api/v1/upload-sessions`.** Declare what the content is: `captured` (the default, and what every existing session is), `synthetic` (generated entirely by AI, no real person depicted) or `mixed` (the uploader with AI-generated figures). Omit it and nothing changes.
- **Synthetic sessions never invite collaborators.** The uploader attests that the content is AI-generated and depicts no real person, whatever `detectedFaceCount` says, and `detectedFaceCount` may be `0`. No collab scene is opened and no scene fee is charged.
- **Mixed sessions invite only real people.** The form starts with no collaborator rows and the uploader names any other real person in the content. Those people go through the normal collab flow and fees; with none named, the session completes on the uploader's attestation alone.
- **New fields: `contentSha256`, `personaName`, `generationTool`.** `contentSha256`, the SHA-256 of the file being attested, is required for `synthetic` and `mixed` and optional for `captured`. `personaName` is shown on the form and kept with the attestation. `generationTool` is for `synthetic` and `mixed` only. The create response now returns `contentOrigin`.
### Webhooks
- **New event: `upload_session.synthetic_completed`.** Fires when a synthetic session, or a mixed one with no other real person named, completes. It carries `contentOrigin`, `personaName`, `contentSha256`, `generationTool` and the attestation. `upload_session.solo_completed` is unchanged and fires for captured sessions only. Endpoints subscribed to `*` receive the new event; ignore it until your handler knows what to do with AI-generated content.
- **`collab_scene.created` from a hosted session now carries `contentOrigin`** (`captured` or `mixed`).
## v2026-09-05
### Solo accounts
- **New: `POST /api/v1/solo-accounts/{accountId}/deactivate` and `POST .../reactivate`.** A platform that funds its creators can now take a creator off its roster and bring them back. Deactivating suspends the account with `suspendReason: partner_deactivated`, fires `solo_account.suspended` with that reason, and takes the creator off your next invoice; their records stay in custody. Reactivating clears only that suspension, runs no new verification, and fires `solo_account.verified` again when the creator's ID is on file. Both are idempotent. Only creators your platform funds: a creator who subscribes directly returns `409 CREATOR_PAYS_OWN_SUBSCRIPTION`. The same two actions are on the portal roster at partner.easy2257.com.
- **`suspendReason` is now documented.** The values are `subscription_lapsed`, `platform_lapsed`, `partner_deactivated`, `platform_deactivated` and `manual_suspension`; the `solo_account.suspended` event reference says what each means and what clears it. The previous example value `subscription_payment_failed` was never emitted.
## v2026-08-22
### Certificates
- **`production.documented` now fires again when a producer reissues a sealed package.** A producer who corrects a closed record can rebuild the sealed package themselves. Easy2257 re-mints the certificate and sends the same `production.documented` event, with the payload shape you already handle. Treat the newest delivery for a `productionId` as the certificate that stands. The one it replaces is marked `SUPERSEDED`, not revoked: the content stays up and nothing lands in `GET /api/v1/productions/revoked-certificates`. Handlers that store the certificate keyed on `productionId` and overwrite need no change; handlers that ignore a repeat delivery will hold a superseded certificate.
### Archives
- **A production's sealed package can change after it is first sealed.** When a producer corrects a closed record and reissues, the archive is rebuilt and its `zipSha256` and `reportPdfSha256` change, along with the sealed date. A package downloaded before the reissue still exists and is still kept; it is superseded rather than invalid. Compare hashes against the package that stands today rather than against one you cached.
## v2026-08-15
### Certificates
- **Annual renewal no longer revokes the outgoing certificate — it supersedes it.** A renewal now stamps the old certificate `SUPERSEDED` instead of revoked, so revocation means exactly one thing: the certified record was pulled and the content comes down. `POST /{productionId}/verify-certificate` returns the new reason `certificate_superseded` for a renewed-away certificate; handlers treating every `valid: false` as a takedown should branch on the reason — a superseded certificate means swap in the replacement, the content stays up.
- **The revocation list carries revocations only.** New renewals no longer appear in `GET /api/v1/productions/revoked-certificates`. Each entry gains a `type` field (`revocation` or `supersession`); `supersession` appears only on entries recorded before this change and their content stays up. Handlers that skipped `annual_renewal` entries keep working unchanged.
- **Certificates are now stored and retrievable.** New endpoint `GET /api/v1/productions/{productionId}/certificates` lists every certificate for a linked production, newest first, with lifecycle status (`CURRENT`, `SUPERSEDED`, `REVOKED`) and the signed JWT. Certificates issued before this change return `jwt: null`; those arrive with the next annual renewal. Missing a `production.documented` webhook is no longer unrecoverable.
## v2026-08-14
### Collab scenes (breaking)
- **`POST /{sceneId}/extend` no longer returns performer emails.** The `performers` array is removed from the `200` body and replaced by three counts: `performerCount`, `consentedCount`, `releaseSignedCount`. The extending partner is not the scene owner, and the consent and release state those counts summarize is all the extension flow needs; the roster's email addresses belong to the scene owner's records. `sceneId`, `status`, `extendedTo`, `fee`, and `nextStep` are unchanged. Handlers reading `performers` must switch to the counts.
### Rate limits
- **`/api/v1` requests with a `Bearer` key are now limited per key, not per IP: 300 requests per 60 seconds.** Your budget is no longer drawn down by other services behind your NAT address or by other traffic from your egress IP, and it is five times larger. Requests without a key keep the previous per-IP limit of 60 per 60 seconds, and a per-address flood ceiling of 1200 per 60 seconds bounds keyed traffic during abuse. The `X-RateLimit-*` headers and the `429` body are unchanged in shape.
### Webhooks
- **`X-EZ2257-Delivery` now carries the full delivery id.** The value keeps its `del_` prefix and stays an opaque identifier; it is simply longer. The previous shortened form could repeat across two deliveries created in the same instant. If you deduplicate, keep keying on `event.id`, which is unchanged.
## v2026-07-27
Security and correctness release. Several of these changes are breaking for existing integrations. Read the two Breaking sections before your next deploy.
### Breaking
- **Test-mode keys are rejected.** There is no sandbox. Any `ez_test_` key now returns `403` with code `TEST_MODE_UNAVAILABLE` on every `/api/v1/*` endpoint, before the handler runs. Only `ez_live_` keys work. The rejection is recorded in your API usage log. Previously test keys wrote production records, provisioned real users, and sent real email, which is why they are now blocked outright.
- **The password-claim endpoint on `/api/partner-onboard/{token}` is gone.** A partner-held token that could set a password was a partner-held password. Creators set their own password through the ordinary forgot-password flow, which the provisioning email already links them to. Remove any call to it: there is nothing to replace it with, and the onboarding flow no longer needs one.
- **Cross-partner reads return `404`, not `403`.** Requesting an `accountId` that belongs to another partner now returns `404` with `Solo account not found`, identical to an id that does not exist, so the API cannot be used to enumerate accounts. Handlers that branched on `403` for this case must branch on `404`.
### Collab scenes (breaking)
- **`POST /api/v1/collab-scenes` now identifies the uploader by `uploaderAccountId` or `uploaderExternalUserId` (exactly one).** These are the two values a partner already holds: the `accountId` Easy2257 issued at provisioning, or the `externalUserId` you supplied there. Either must resolve to an active solo account under your partner account. The documented `uploaderUserId` field asked for an Easy2257 user id, a value no endpoint has ever returned to a partner; it remains accepted for Easy2257-internal callers only.
- **`POST /{sceneId}/extend` takes no request body.** Authorization is possession of the `sceneId` of a greenlit scene, the id the scene's creating partner shares with you when the syndication is arranged. The former `uploaderUserId` body field and the `403` `uploader_mismatch` rejection are gone.
- **`/extend` reports its charge and retries safely.** The response gains a `fee` object (`extensionFeeCents`, `charged`, `stripeChargeId`, `chargeError`). Extensions are recorded at most once per partner per scene: a re-POST retries an unpaid fee instead of charging twice, and a failed charge does not revoke the extension.
- **`preEnrolled` is removed from performer entries, and `pending_registration` is no longer emitted.** Both revealed whether an arbitrary email already had an Easy2257 account, which contradicted the anti-enumeration guarantee everywhere else in the API. Every invited performer now reports `pending_consent` until they sign (`consented`) or decline (`refused`). Handlers reading `preEnrolled` or matching `pending_registration` must drop those branches.
- **Scene creation responses carry `fees.chargeError`**: `null` when the immediate off-session charge succeeded, otherwise a failure code such as `no_payment_method` or `card_declined`. The scene is created and consent collection proceeds either way.
### Consent gate on pre-existing accounts
When you provision an email that already belongs to an Easy2257 user, that user is emailed and asked to approve the connection before their identity record is linked to your platform.
- `POST` and `DELETE` on `/api/v1/solo-accounts/{id}/content` return `409` with code `CONSENT_PENDING` (asked, not yet answered) or `CONSENT_DECLINED` (refused). Treat both as "creator not ready to publish".
- The provisioning response itself is unchanged and deliberately identical to a fresh signup, so no code change is required at step 1 and your integration cannot be used to probe whether an address already has an Easy2257 account.
- The approval email is throttled to at most one per creator per day, and a creator who declines is never emailed about your platform again.
### Status values a partner can observe
`GET /api/v1/solo-accounts/{id}` returns exactly four values: `pending_id_verification`, `subscribed_pending_id`, `active`, `suspended`. Internal states (`pending_consent`, `consent_declined`, `id_verified`) are collapsed into `pending_id_verification` so account existence and consent decisions are not observable. Unlock uploads on `active` only.
Note that the `solo_account.verified` webhook carries `status: "verified"`, not `"active"`. Match on `event.type`.
### Revoked certificates: cursor and reason semantics
- The response timestamp field is `retrievedAt`. Pass it as `since` on your next sync.
- `nextCursor` is an opaque token. Pass it back verbatim as `cursor`. It is not a `productionId`.
- Ordering is stable by `(revokedAt, certificate id)`, so pages never skip or repeat.
- The `reason` for an actioned takedown is `removal_request:`, a prefixed variable value. Match on the `removal_request:` prefix. The other values are `annual_renewal` and `unspecified`.
### Webhooks
- Documented the full event list. In addition to the solo-account and content-log events, Easy2257 delivers `collab_scene.created`, `.performer_consented`, `.performer_refused`, `.greenlit`, `.blocked`, `.cancelled`, `.publication_created`, `.publication_removed`, `upload_session.solo_completed`, `performer.id_verified`, `performer.forms_signed`, `verification.cancelled`, the four `production.*` events, and `test.ping`.
- Documented the `X-EZ2257-Event` and `X-EZ2257-Delivery` headers, which ship on every delivery.
- Clarified that retries are re-signed with a fresh timestamp, so deduplication must key on `event.id`.
- Clarified that endpoints are registered in the partner portal. The `callbackUrl` field on a resource never subscribed you to events.
### Partner dashboard
The partner dashboard now lives at `partner.easy2257.com`. Old `/profile/partner/*` paths permanently redirect there. API keys are at `/api-keys`, webhook endpoints and delivery history at `/webhooks`, request logs at `/api-usage`.
### Endpoints that were live but unlisted
These are not new. They were missing from this changelog, and one of them was wrongly listed as "coming soon" while it was already shipped.
- `DELETE /api/v1/solo-accounts/{id}/content/{externalContentId}`: remove a logged depiction
- `POST /api/v1/collab-scenes/{id}/publications/{pubId}/remove`: record a takedown of a syndicated publication
- `POST /api/v1/upload-sessions`: start a hosted attestation session
The previous "Coming soon" list has been removed. A content-log list endpoint, a hashed-email lookup endpoint, and language SDKs were listed there and none of them exist or are in progress. The hashed-email lookup in particular would have undone the one privacy control on the takedown webhook, so it is not planned.
## v2026-04-25
### Productions (Verification API): new
- `GET /api/v1/productions/{id}`: fetch production documentation status
- `GET /api/v1/productions?performerEmail=`: list productions a performer appears in (VIRP right-to-removal lookup)
- `POST /api/v1/productions/{id}/verify-certificate`: validate a stored compliance certificate
- `GET /api/v1/productions/revoked-certificates`: daily revocation sync
- `GET /.well-known/jwks.json`: public JWKS for offline RS256 certificate verification
### New webhook events
- `production.documented`: fires when a producer completes documentation (payload includes the issued JWT)
- `production.takedown_requested`: fires when a depicted person submits a removal request
- `production.certificate_renewed`: fires on annual certificate renewal
- `production.records_inspected`: fires when a federal inspector accesses records
## v2026-04-22: Initial public release
### Solo Accounts
- `POST /api/v1/solo-accounts`: provision solo creator account
- `GET /api/v1/solo-accounts/{id}`: get account status
- `POST /api/v1/solo-accounts/{id}/content`: log a content depiction
### Talent
- `POST /api/v1/talent/check`: look up prior verification status by email
### Collab Scenes (multi-performer)
- `POST /api/v1/collab-scenes`: create a collab scene with magic-link consent flow
- `GET /api/v1/collab-scenes/{id}`: fetch scene status and performer progress
- `POST /api/v1/collab-scenes/{id}/cancel`: cancel a pending scene
- `POST /api/v1/collab-scenes/{id}/extend`: publish to another partner platform without re-collecting signatures
- `POST /api/v1/collab-scenes/{id}/publications`: register a published content URL
### Webhooks
- `solo_account.verified`, `solo_account.suspended`
- `collab_scene.greenlit`, `collab_scene.blocked`, `collab_scene.publication_removed`
- `content_log.created`, `content_log.deleted`
### https://docs.easy2257.com/docs/guides/partners
The partner program pays you for recommending Easy2257. You share a referral link, or a discount code assigned to you, and you earn 25% of every payment made by each customer credited to you, for as long as that customer keeps paying. Joining is free, and there is nothing to build or install.
**This is the referral program, not the Partner API.** If you run a platform and want to create records for your own creators through an integration, that is the [Platform Agreement](https://easy2257.com/platform-agreement), covered in the [Platforms guide](/docs/guides/platforms). A platform earns no referral commission on its own creators. Everyone else who wants to recommend Easy2257 belongs on this page.
## Who it fits
The program is built for people who already talk to producers and creators about the business side of their work:
- Accountants and bookkeepers who file for adult performers
- Attorneys who advise on record-keeping
- Creator educators, coaches, and course sellers
- Agencies and managers who represent talent
- Creators with an audience of other creators
You do not have to be an Easy2257 customer to join. If you already are one, you can apply from your existing account (see [How to join](#how-to-join)).
## What you earn
| Term | What it means |
|------|---------------|
| Rate | 25% of every payment a referred customer makes |
| Which payments | The first payment and every renewal, on every paid plan and on per-scene fees |
| For how long | For as long as the customer keeps paying. There is no expiry date |
| Calculated on | The full amount the customer paid. Payment processing fees are not deducted first |
An example: a customer on the annual Solo Creator plan pays $120 a year, so each of their payments earns you $30. If a customer pays a discounted price, your commission is 25% of the amount they actually paid.
## How a referral is tracked
There are two ways a referral is attributed to you: your referral link, and a discount code assigned to you, used at checkout. The link needs nothing from the person you refer. The code is typed into the promo code field at checkout on the annual Solo Creator plan, or into the same field at signup, where it is saved and applied when they check out. The steps below follow the link; the code is covered under [Your audience discount](#your-audience-discount).
1. Your referral link is `https://easy2257.com?ref=YOURCODE`. The code works on any page of easy2257.com: add `?ref=YOURCODE` to any URL and the referral is tracked.
2. When someone opens a link carrying your code, a referral cookie is set in their browser for 30 days.
3. If they create an account while that cookie is present, the account is attributed to you, and every payment it makes from then on earns your commission.
Four rules follow from that:
- **First touch wins.** If the person already carries another partner's referral cookie, or their account is already credited to another partner, yours does not replace it. That holds for a code as well as a link.
- **A link attributes at signup, a code attributes at checkout.** Someone who creates an account without your cookie is still attributed to you if they later pay with a discount code assigned to you, as long as no other partner has been credited for that account in the meantime.
- **Your account has to be active at the moment the credit is recorded:** when they sign up through your link, or when they pay with a code assigned to you. Anything shared while your application is still in review attaches nothing, which is why your link is handed over only after approval.
- **You cannot refer yourself.** An account created with your own email address, or a variation of it, is not attributed to you.
## When it pays
Every commission goes through three stages before it is paid, and your dashboard shows the amount at each one.
| Stage | What happens |
|-------|--------------|
| Recorded and reviewed | A commission is recorded when the referred customer's payment clears. It is then reviewed and approved. |
| 30-day hold | An approved commission becomes payable 30 days after it was earned. That window lets a refund or chargeback on the underlying payment be reversed against the commission before any money moves. |
| Payable | Once your payable balance reaches $50.00, a payout can be sent to the payout method you set in your dashboard settings. A balance below $50.00 carries forward until it clears the minimum. |
If a payment is refunded or charged back, the matching commission is reversed. A reversal counts against your balance right away rather than waiting out the hold.
## Your audience discount
Approved partners also get a discount code for their audience: 20% off the first year of the annual Solo Creator plan, entered at checkout. The code, and ready-to-post copy that already includes it, are under Promote in your partner dashboard. Your commission on a discounted first year is 25% of what the customer paid: $24 on a $96 first year.
A code that Easy2257 has recorded against your partner account also credits you: a customer who pays with it is attributed to you at checkout, even if they never clicked your link, as long as their account is not already credited to another partner. At the plan checkout, promo codes apply to the annual Solo Creator plan, so that is the purchase a code is offered on.
## How to join
1. **Create your partner account** at [easy2257.com/partner/signup](https://easy2257.com/partner/signup). The form asks for your name, your email address, and a password of at least 12 characters; company and website are optional. You accept the [affiliate terms](https://easy2257.com/affiliate-terms) as part of the form.
2. **Verify your email.** A six-digit code is sent to the address you entered.
3. **Wait for review.** A person reviews every partner before the link goes live, usually within a business day, and you get an email when you are approved. There is no ID check to refer. Identity verification is only asked for when a partner creates a live Partner API key.
4. **Sign in to your dashboard** at [partner.easy2257.com](https://partner.easy2257.com). Your referral link, ready-to-post copy, commissions, and payouts all live there.
Already an Easy2257 customer? Sign in and apply at [easy2257.com/partner/apply](https://easy2257.com/partner/apply). The same review applies, and the partner account is attached to the account you already have.
## Questions
Email [admin@easy2257.com](mailto:admin@easy2257.com). The full agreement, including taxes, termination, and what you may and may not do when promoting Easy2257, is at [easy2257.com/affiliate-terms](https://easy2257.com/affiliate-terms).
## Reference
- [Affiliate terms](https://easy2257.com/affiliate-terms): the agreement you accept when you join
- [Partner signup](https://easy2257.com/partner/signup): create a partner account
- [Partner dashboard](https://partner.easy2257.com): your link, ready-to-post copy, commissions, and payouts
- [Platforms guide](/docs/guides/platforms): the Platform Agreement, for integrations rather than referrals
### https://docs.easy2257.com/docs/guides/platforms
This guide is for the business side of integration: who you are vs. who Easy2257 is in the eyes of 18 U.S.C. § 2257, what you owe each other, and what the integration costs.
## The deal in one sentence
Easy2257 becomes the Custodian of Records for every creator who onboards through your integration. Your platform never stores government IDs and never keeps a physical records office: our Scottsdale address goes on the 2257 statement, and we receive records requests and produce the records from our systems. A platform integration is a paid agreement rather than a revenue share: see the economic model below, and note that no referral commission accrues on your own creators (the 25% affiliate program is for link-referral affiliates, which is a different relationship: see the [Partners guide](/docs/guides/partners)).
## Your obligations as a partner platform
| Obligation | Detail |
|------------|--------|
| **Don't allow uploads from unverified accounts** | Wait for `solo_account.verified` (Solo Creator) or `collab_scene.greenlit` (Productions) before unlocking content. The intermediate `subscribed_pending_id` status is **not** "verified". |
| **Honor takedown webhooks** | Remove content from your CDN within 48 hours of `content_log.deleted` (TAKE IT DOWN Act), and by the `removalDeadline` carried on `production.takedown_requested` (48 hours, 72 hours, or 7 business days depending on the legal basis of the request). |
| **Display the 2257 statement** | Each producer has a public page at `https://easy2257.com/producer/{slug}/2257-statement`. `GET /api/v1/solo-accounts/{accountId}` returns the finished URL as `complianceStatementUrl` once the account is active and the creator has published that page, so you never construct the slug yourself. Link to it from every content page that contains the producer's content. Footer placement is conventional. |
| **Verify webhook signatures** | Every webhook is HMAC-signed. Always [verify before processing](/docs/webhooks/signatures). An unverified endpoint is an open door for spoofed events. |
| **Don't store ID documents yourself** | Easy2257 is the COR. Store the `accountId` we return and refer law enforcement to Easy2257 with that ID. |
That last row costs you nothing to comply with, because **the Partner API never returns identity data in the first place.** No endpoint returns a legal name, a date of birth, an ID image, or a linked Easy2257 user id. The whole identifier surface you hold is the opaque `accountId` plus your own `externalUserId`. The single exception is deliberate and symmetric: on a collab scene you created, the read echoes back the performer addresses you supplied when you created it. A partner that only publishes somebody else's scene receives counts, never the roster. Sensitive material that never reaches your database can never leak from it.
## What Easy2257 handles for you
| Obligation | How |
|------------|-----|
| Government ID verification | Document authenticity check plus biometric face match against the submitted ID |
| Records organisation and cross-reference (28 CFR 75.2(a)(3)) | Kept per creator inside that creator's Easy2257 record; every `POST /content` entry is logged against the creator's verified identity |
| 7-year retention | Enforced at the API layer: takedowns inside the window preserve the custody record |
| Records requests from law enforcement | Easy2257 is named Custodian of Records at a staffed business address in Scottsdale, Arizona; we receive the request, produce the records from our systems, and tell you about it where the law allows |
| Mastercard AN 5196 performer consent | Performer-signed model release stored per scene |
| Monthly platform compliance report | Auto-generated on the 2nd of every month for platforms with a published 2257 statement, nil reports included; creators on producer plans also receive the per-producer acquirer report |
| Removal portal for depicted persons | Public unauthenticated intake at `easy2257.com/report/removal` |
## Economic model
A platform deal has two parts:
- **A monthly platform fee**, set in the [Platform Agreement](https://easy2257.com/platform-agreement). It covers the platform-only goods: Easy2257 named as custodian of records on your platform's 2257 statement, roster visibility, the removal portal, coordination of records production (each creator's records are produced to that creator on request), and notice to you of an inspection request where the law allows. There is no setup fee.
- **Who pays for your creators, set in your agreement.** Either your creators subscribe to Easy2257 directly at the per-creator custody rate your deal names, with checkout on `easy2257.com` rather than your platform; or **your platform funds them** under the [Platform Agreement](https://easy2257.com/platform-agreement), in which case no creator ever sees a payment screen and the per-creator charge is added to your own monthly invoice, in arrears, for the creators in custody when the invoice period ends. Either way the per-creator rate is a custody price, not a verification fee: Easy2257 holds the record set for the seven-year window, publishes the statement, and produces each creator's records to that creator on request. The annual option on the public pricing page belongs to a different, direct-signup plan that the partner flow never touches.
- **Deactivate and reuse.** When your platform funds its creators, a creator who goes quiet can be taken off your roster with `POST /api/v1/solo-accounts/{accountId}/deactivate` or the portal roster. They come off your next invoice, their records stay in custody for the retention window, and `POST .../reactivate` brings them back on the record already held, with no new verification.
- **Commission.** The 25% affiliate program is for [link-referral affiliates](/docs/guides/partners). A platform integration is governed by the [Platform Agreement](https://easy2257.com/platform-agreement), and no referral commission accrues on your own creators' payments.
The fee and the per-creator rates are in the [Platform Agreement](https://easy2257.com/platform-agreement), and above 1,000 creators a written quote sets the price instead. The public pricing page prices direct signups, not platforms.
Affiliate terms, for link-referral affiliates: [`easy2257.com/affiliate-terms`](https://easy2257.com/affiliate-terms).
## Pre-launch checklist
Run through this before flipping the switch in production:
- [ ] **Live API key generated** at [`partner.easy2257.com/api-keys`](https://partner.easy2257.com/api-keys) and stored in your secrets manager (not committed)
- [ ] **Webhook endpoint registered** at [`partner.easy2257.com/webhooks`](https://partner.easy2257.com/webhooks) with the events you care about. The `callbackUrl` in your API calls does not register anything: an integration without a portal endpoint receives no events at all.
- [ ] **Webhook signature verification** implemented and proven with the portal's test button, which sends a real signed `test.ping`
- [ ] **Event dedupe** keyed on `event.id`, not on the signature timestamp (retries are re-signed with a fresh timestamp)
- [ ] **2257 statement link** added to your content page footer, taken from `complianceStatementUrl` on `GET /api/v1/solo-accounts/{accountId}` rather than assembled by hand
- [ ] **First end-to-end pass run against production with addresses you control.** There is no sandbox, and `ez_test_*` keys are rejected with `403` / `TEST_MODE_UNAVAILABLE`. Every call writes a real record and can send real email, so rehearse with your own mailboxes, then DELETE the content you logged.
- [ ] **Takedown flow tested end-to-end**: log a piece of test content, call DELETE, confirm the `content_log.deleted` webhook arrives
- [ ] **`externalContentId` always sent** on `POST /content`. Without it you cannot issue takedowns later.
- [ ] **`accountId` persisted** against your user record. Every later call needs it, and it is the id you hand law enforcement.
- [ ] **Idempotency verified**: replay `POST /v1/solo-accounts` for the same `externalUserId` and confirm you handle the `status: "active"` response (existing-user path)
- [ ] **`409` states handled, and told apart**: confirm your content-logging code switches on `code`, not on the status. `CONSENT_PENDING` and `CONSENT_DECLINED` mean "creator not ready", so hold and retry. `uploader_not_solo_active` means their Easy2257 account has stopped being active, so retrying never clears it and the creator has to go back through onboarding. Treating all three as one retryable state is the mistake this row exists to catch
## Reference
- [Solo Creator Guide](/docs/guides/solo-creator): single-performer integration
- [Productions Guide](/docs/guides/productions): multi-performer + JWT certificates
- [Quickstart](/docs/quickstart): 15-minute working integration
- [Partners Guide](/docs/guides/partners): the referral program, for affiliates rather than platforms
- [Affiliate Terms](https://easy2257.com/affiliate-terms): full commission terms
### https://docs.easy2257.com/docs/guides/productions
The Productions flow handles content with **more than one performer**. It collects per-scene consent from every performer before content goes live and issues a signed RS256 JWT certificate your platform stores alongside the content.
## What you get
- Per-performer consent collection via magic-link emails. Performers sign on their own device, with no account on your platform required.
- A `collab_scene.greenlit` webhook only after every performer has signed. Gate publication on this signal.
- An RS256 JWT compliance certificate per documented production, verifiable offline against `easy2257.com/.well-known/jwks.json`. Hand it to any auditor with no API call required.
- Automatic `production.takedown_requested` webhooks when a depicted person requests removal, carrying the deadline you have to meet.
- Annual re-attestation that issues a new certificate and supersedes the old one. A producer correcting a closed record and reissuing the sealed package does the same thing, so `production.documented` can arrive more than once for one production.
## When to use this vs. Solo Creator
Use **Productions** for multi-performer content (two-creator collabs, group scenes, full studio shoots). Use **[Solo Creator](/docs/guides/solo-creator)** for single-performer uploads.
You can integrate both: Solo Creator for ordinary single-performer uploads and Productions for collab content. The same partner API key works for both surfaces.
## Two integration flavors
**The two flavors use different record types with separate id spaces.** A *collab scene* is created through `POST /v1/collab-scenes`, and it is the only thing the collab-scene endpoints (`/extend`, `/cancel`, `/publications`) accept. A *production* is created in the Easy2257 producer UI and contains its own nested scenes — those scene ids look identical (25-character cuids) but belong to a different record type, and passing one to a collab-scene endpoint returns `404`. No fee is charged on that `404`.
### Collab Scenes (UGC pattern)
The uploader on your platform is the producer. Easy2257 sends magic-link emails to each tagged performer, and they consent through Easy2257 with no account on your platform needed. When all signatures land, you receive `collab_scene.greenlit` and can publish.
```
1. POST /v1/collab-scenes (uploaderAccountId OR uploaderExternalUserId,
title, shootDate, performers[],
uploaderAttestation, callbackUrl)
-> magic-link emails sent to each performer
2. each performer: visit link -> consent + register if new
-> collab_scene.performer_consented (one per signature)
-> collab_scene.performer_refused (any refusal blocks the scene)
3. collab_scene.greenlit webhook -> publish content
4. POST /v1/collab-scenes/{id}/publications for every place you publish
5. (optional) POST /v1/collab-scenes/{id}/extend to publish on another
partner platform without re-collecting signatures (collab scenes only)
```
Scenes expire **14 days** after creation. A reminder goes out at day 7, and an expired scene fires `collab_scene.blocked` with the list of performers who never signed. Size your own follow-up nudges inside that window.
The create body accepts exactly one uploader identifier, `uploaderAccountId` (the `accountId` Easy2257 issued at provisioning) or `uploaderExternalUserId` (your own user id for the uploader), plus `title`, `shootDate`, `performers`, `uploaderAttestation`, and `callbackUrl`. Anything else is dropped, and the uploader must resolve to an active solo account under your partner account. There is **no idempotency on scene creation**: every call creates a new scene, sends new invite emails, and charges a new scene fee, so guard against double submission on your side.
### Production Certificates (full-shoot pattern)
For platforms that wrap a full studio production workflow, the producer links the production to your partner account and documents it in Easy2257's UI. When they finalize, you receive `production.documented` with a JWT certificate.
```
1. producer links the production to your partner account
(production page -> Edit -> Partner platform field, pasting your
Partner ID from the partner portal's Settings page)
2. producer documents the production in Easy2257's UI
3. production.documented webhook -> store the certificate alongside content
4. daily GET /v1/productions/revoked-certificates ->
if a stored certificate's production appears, revoke / take down
5. (optional) POST /v1/productions/{id}/verify-certificate for spot checks
```
**Link before documentation completes.** The certificate is issued and delivered inside the `production.documented` webhook at the moment documentation finishes, and every certificate issued from then on is also retrievable any time from `GET /v1/productions/{id}/certificates`, with its lifecycle status. A production linked *after* it was documented becomes readable over `GET /v1/productions/{id}` and starts routing its takedown notices to you; if its certificate predates certificate storage, the list shows the record with `jwt` null and the certificate itself arrives with whichever comes first: the next annual re-attestation (`production.certificate_renewed`), or the producer reissuing the sealed package after a correction, which is delivered as `production.documented`.
## Four things to get right
1. **Do not publish before `collab_scene.greenlit`.** A `collab_scene.created` response means the scene exists, not that consent is collected. Publishing early means you have content from a performer who has not signed.
2. **Budget for the extension fee.** `POST /v1/collab-scenes/{id}/extend` charges your partner owner account **$2.00** per extension, off-session against your saved payment method, on top of the original scene fee, which the uploader's card paid or, on a platform-billed account, that platform's invoice. It is the price of not re-collecting signatures, and it is charged per platform you extend to, at most once per scene. The call takes no body: the `sceneId` the scene owner shares with you is the authorization. Check `fee.charged` in the response; a failed charge does not revoke the extension, and a re-POST retries the unpaid fee without double-charging.
3. **Store the certificate when it arrives, and fetch it when you need it.** The certificate arrives in the `production.documented` webhook; verify offline against the public JWKS. If you miss or lose one, `GET /v1/productions/{id}/certificates` lists every certificate for the production with its lifecycle status (`CURRENT`, `SUPERSEDED`, `REVOKED`) and the signed JWT.
4. **Run the revocation sync daily and match on the reason prefix.** `GET /v1/productions/revoked-certificates` returns revoked entries for your productions. Every entry is a takedown signal: neither an annual renewal nor a producer reissue is a revocation, and neither appears. Page with `nextCursor`, which is an opaque token you pass straight back as `cursor`, and advance your watermark from the response's `retrievedAt` field.
### Revocation reasons
The `reason` on each revocation is one of:
| Value | Meaning |
|-------|---------|
| `removal_request:` | A depicted person's removal request was actioned. **Match on the `removal_request:` prefix**, because the id varies per request. Take the content down. |
| `unspecified` | Revoked with no reason recorded. Treat it as a takedown until you confirm otherwise. |
| `annual_renewal` | Appears only on entries recorded before renewals were split out of this feed (they carry `type: supersession`). The certificate was replaced by the annual re-attestation; the content stays up. |
For real-time signals, also subscribe to `production.takedown_requested` and `production.certificate_renewed`. The takedown event carries a `removalDeadline` already computed for the legal basis of the request (48 hours, 72 hours, or 7 business days), so honor that field rather than hardcoding a window.
## Reference
- [Collab Scenes API](/docs/api/collab-scenes/create)
- [Productions API](/docs/api/productions)
- [Webhook Events](/docs/webhooks/events)
### https://docs.easy2257.com/docs/guides/solo-creator
The Solo Creator flow is the right integration when each piece of content has **exactly one performer** and that performer is the uploader. OnlyFans-style creator platforms, clip stores, fan sites, and personal video pages all fit this pattern.
## What you get
- A verified-identity record for every creator who uploads explicit content on your platform
- A per-depiction audit trail tying each piece of content to that record (28 CFR 75.2)
- Easy2257 named as the Custodian of Records on every 2257 statement, so your platform never stores government IDs
- The same verification covers the creator everywhere they upload: verify once, compliant on every Easy2257 partner platform
## When to use this vs. Productions
Use **Solo Creator** when content involves only the uploader. Use **[Productions](/docs/guides/productions)** when content involves additional performers and needs per-scene consent collection.
## Integration shape
Three endpoints, one status poll, and two webhooks you act on. Full code in the [Quickstart](/docs/quickstart).
```
0. register a webhook endpoint in the partner portal, copy the whsec_ secret
(callbackUrl in the API body does NOT subscribe you to anything)
1. POST /v1/solo-accounts -> status: pending_id_verification
+ onboardingUrl
-> or status: active (already verified)
redirect creator (top-level nav, NOT iframe, needs camera)
2. solo_account.verified webhook -> grant upload access
solo_account.suspended webhook -> revoke upload access
GET /v1/solo-accounts/{id} -> poll for certainty / reconcile
3. POST /v1/solo-accounts/{id}/content for every upload
(contentUrl + title required,
always send externalContentId)
4. DELETE /v1/solo-accounts/{id}/content/{externalContentId}
on every takedown -> fires content_log.deleted
```
`content_log.created` and `content_log.deleted` also arrive as echoes of your own writes, and `content_log.deleted` additionally fires for removals Easy2257 processes without you.
## Four things to get right
1. **Register a webhook endpoint before you write a handler.** Endpoints live in the partner portal at [`partner.easy2257.com/webhooks`](https://partner.easy2257.com/webhooks). The `callbackUrl` you pass to `POST /v1/solo-accounts` is stored on the record for your reference and subscribes you to nothing. This is the single most common reason a new integration "never receives webhooks".
2. **Always send `externalContentId` on POST.** It is optional in the schema but it is the only way to address the depiction later for takedowns. Without it your content is logged but not removable through the API.
3. **Wait for `solo_account.verified` before allowing uploads.** The intermediate `subscribed_pending_id` status means "paid but not yet ID verified": do not unlock content there. The webhook fires only after both payment and ID verification complete, and it carries `status: "verified"` (the REST API calls the same account `"active"`, so match on `event.type`, not on the status string).
4. **Treat `content_log.deleted` as authoritative.** When Easy2257 fires it, for a creator-initiated removal through the public portal or a request you never saw, pull the content from your CDN within 48 hours per the TAKE IT DOWN Act, even though you did not initiate the takedown.
## Creators who already have an Easy2257 account
Some of your creators will already exist on Easy2257, often from another platform. Easy2257 will not hand their identity records to you on your say-so. It emails them and asks them to approve the connection first.
You do not have to detect this case: the provisioning response is deliberately identical to a fresh signup, so your code has exactly two branches (`active` and everything else) and your integration can never be used to probe whether one of your users has an Easy2257 account.
What changes is the content-logging path. Until the creator approves, `POST` and `DELETE` on that account's content return `409`:
| Code | Meaning | What to do |
|------|---------|-----------|
| `CONSENT_PENDING` | The creator has been asked and has not answered yet. | Hold the content behind your pre-verification state. Retry after they complete onboarding. |
| `CONSENT_DECLINED` | The creator declined the connection. | Stop. Do not publish explicit content for that account through this integration. |
A third code shares that `409` status on `POST` only, and it is not a consent state: `uploader_not_solo_active` means the creator's own Easy2257 account has stopped being active, so there is no verified identity record to attach the depiction to. Retrying does not clear it, however long you wait. Send the creator back through onboarding. Takedowns are deliberately exempt, so `DELETE` keeps working on an inactive account: a lapsed subscription is not a reason to refuse a removal request.
This is a feature, not a hurdle. It means no partner can quietly attach itself to a creator's compliance record, which is precisely the guarantee that makes creators willing to verify once and carry it across platforms.
## Reference
- [Quickstart](/docs/quickstart): copy-paste cURL/Node/Python/PHP
- [Solo Accounts API](/docs/api/solo-accounts/create): endpoint reference
- [Webhook Events](/docs/webhooks/events): payload shapes
- [Webhook Signatures](/docs/webhooks/signatures): verification code
### https://docs.easy2257.com/docs
Easy2257 is the Custodian of Records for 18 U.S.C. § 2257. Our Partner API lets your platform hand off compliance (ID verification, cross-reference indexing, 7-year retention) in a single integration.
Your database never holds a government ID, a legal name, or a date of birth. The API returns none of it: the entire identifier surface you hold is an opaque account id plus your own user id. Sensitive material that never reaches you can never leak from you.
## Before you start
- Authenticate with your `ez_live_` key. There is no sandbox, and keys in test mode are rejected with `403` / `TEST_MODE_UNAVAILABLE`.
- Register a webhook endpoint in the partner portal at [`partner.easy2257.com/webhooks`](https://partner.easy2257.com/webhooks). The `callbackUrl` field on a resource does not subscribe you to events.
- Run your first end-to-end pass with email addresses you control. Every call writes a real record.
### https://docs.easy2257.com/docs/quickstart
Your platform does not need to know whether a creator already has an Easy2257 account.
Call `POST /v1/solo-accounts` with their email and your internal user ID, and the response tells you what to do next.
Use your `ez_live_` key. There is no sandbox: a key in test mode is rejected with `403` and code `TEST_MODE_UNAVAILABLE` before any handler runs. Every call below writes a real record and can send real email, so run your first pass with an address you control.
## 1. Register your webhook endpoint
Do this first. Create an endpoint at [`partner.easy2257.com/webhooks`](https://partner.easy2257.com/webhooks), subscribe it to the events you care about, and copy the `whsec_` signing secret into your secrets manager.
The `callbackUrl` field you send in step 2 is stored on the account record for your own reference. **It does not subscribe you to anything.** Events go only to endpoints registered in the portal, so a handler written without this step will never fire.
Press the portal's test button once your handler is deployed. It sends a real signed `test.ping` delivery, which proves your signature check works before live traffic depends on it.
## 2. Provision the creator
`email`, `externalUserId`, and `callbackUrl` are required. `firstName`, `lastName`, and `redirectUrl` are optional.
```bash
curl -X POST https://easy2257.com/api/v1/solo-accounts \
-H "Authorization: Bearer $EZ2257_API_KEY" \
-H "Content-Type: application/json" \
-d '{"externalUserId":"user_12345","email":"creator@example.com","callbackUrl":"https://your-site.com/webhooks/ez2257"}'
```
```javascript
const res = await fetch('https://easy2257.com/api/v1/solo-accounts', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.EZ2257_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
externalUserId: 'user_12345',
email: 'creator@example.com',
callbackUrl: 'https://your-site.com/webhooks/ez2257',
}),
});
const { accountId, status, onboardingUrl } = await res.json();
await saveAccountId(externalUserId, accountId); // you need this for every later call
if (status !== 'active') redirect(onboardingUrl);
// status === 'active' → creator is already verified; grant upload now
```
```python
import os, requests
r = requests.post(
'https://easy2257.com/api/v1/solo-accounts',
headers={'Authorization': f'Bearer {os.environ["EZ2257_API_KEY"]}'},
json={
'externalUserId': 'user_12345',
'email': 'creator@example.com',
'callbackUrl': 'https://your-site.com/webhooks/ez2257',
},
)
data = r.json()
save_account_id(external_user_id, data['accountId'])
if data['status'] != 'active':
return redirect(data['onboardingUrl'])
# status == 'active' → grant upload now
```
```php
$ch = curl_init('https://easy2257.com/api/v1/solo-accounts');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('EZ2257_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'externalUserId' => 'user_12345',
'email' => 'creator@example.com',
'callbackUrl' => 'https://your-site.com/webhooks/ez2257',
]),
]);
$data = json_decode(curl_exec($ch), true);
saveAccountId($externalUserId, $data['accountId']);
if ($data['status'] !== 'active') {
header('Location: ' . $data['onboardingUrl']); exit;
}
// status === 'active' → grant upload now
```
The response is one of exactly two shapes:
```json
{ "accountId": "...", "status": "active", "createdAt": "..." }
```
```json
{ "accountId": "...", "onboardingUrl": "https://easy2257.com/partner-onboard/...", "status": "pending_id_verification", "createdAt": "..." }
```
`active` means the creator is verified and paid: grant upload access immediately. Anything else returns the pending shape. Send the creator to `onboardingUrl` in **top-level navigation**, not an iframe, because ID verification needs camera access.
**Every not-yet-active outcome returns that identical shape and the identical status string, by design.** Whether the address was brand new, already belonged to an Easy2257 user, or belonged to someone who declined your link request, the response is the same and the URL looks the same. Your integration cannot be used to probe whether one of your users already has an Easy2257 account, or what they decided. That protects your creators, and it means you only ever write two branches.
### Idempotency
There is no idempotency header. Replay safety is built into the resource itself, on two keys:
- `(your partner account, externalUserId)`
- `(your partner account, email)`
Re-POST the same `externalUserId` and you get the same `accountId` back, with `200` instead of `201`. The email scope is what makes a creator's consent decision stick even if your own user id for them changes.
### When the creator already has an Easy2257 account
If the address already belongs to an Easy2257 user, Easy2257 cannot simply hand that person's identity records to your platform. Instead it emails them a link asking them to approve the connection. Once they approve:
- If they were already an active solo creator **and** their ID verification is on file, you receive `solo_account.verified` right away.
- Otherwise they continue through whichever of the subscribe and verify steps they still owe, and you receive `solo_account.verified` when they finish. A creator who is already paying but has not verified goes straight to the ID step.
Approval links the account. It never, on its own, means an ID was checked: `solo_account.verified` fires only once both the payment and the ID verification exist.
Until they approve, the account is not linked, and `POST` or `DELETE` on that account's content returns `409` with code `CONSENT_PENDING`. If they decline, the same routes return `409` with code `CONSENT_DECLINED`.
Handle both codes as "not yet uploadable" and keep the creator on your pre-verification state. Re-provisioning is safe but it is not a way to nag: the approval email goes out at most once per creator per day, and once someone has declined, they are never emailed about your platform again.
## 3. Receive the verified webhook
```bash
# Verify the signature before trusting the payload
# Header format: X-EZ2257-Signature: t=1714000000,v1=abc123...
# Signed payload: "{t}.{rawBody}" (HMAC-SHA256 with your whsec_ endpoint secret)
```
```javascript
// app/api/webhooks/ez2257/route.ts (Next.js App Router)
import { createHmac, timingSafeEqual } from 'crypto';
function verify(rawBody, header, secret) {
const p = Object.fromEntries(header.split(',').map(s => s.split('=')));
const expected = createHmac('sha256', secret)
.update(`${p.t}.${rawBody}`).digest('hex');
return timingSafeEqual(Buffer.from(p.v1), Buffer.from(expected));
}
export async function POST(req) {
const rawBody = await req.text(); // NEVER req.json(), raw bytes only
const sig = req.headers.get('x-ez2257-signature') ?? '';
if (!verify(rawBody, sig, process.env.EZ2257_WEBHOOK_SECRET)) {
return new Response('Unauthorized', { status: 401 });
}
const event = JSON.parse(rawBody);
if (await alreadyHandled(event.id)) return new Response('ok'); // dedupe on id
if (event.type === 'solo_account.verified') {
await grantUploadAccess(event.data.externalUserId);
}
if (event.type === 'solo_account.suspended') {
await revokeUploadAccess(event.data.externalUserId);
}
return new Response('ok');
}
```
```python
import hmac, hashlib, os, json
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['EZ2257_WEBHOOK_SECRET']
def verify(raw_body: bytes, header: str) -> bool:
parts = dict(p.split('=', 1) for p in header.split(',') if '=' in p)
payload = f"{parts['t']}.".encode() + raw_body
expected = hmac.new(SECRET.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(parts.get('v1', ''), expected)
@app.post('/webhooks/ez2257')
def webhook():
raw_body = request.get_data() # bytes, NOT request.get_json()
sig = request.headers.get('X-EZ2257-Signature', '')
if not verify(raw_body, sig):
abort(401)
event = json.loads(raw_body)
if already_handled(event['id']): # dedupe on id
return 'ok'
if event['type'] == 'solo_account.verified':
grant_upload_access(event['data']['externalUserId'])
if event['type'] == 'solo_account.suspended':
revoke_upload_access(event['data']['externalUserId'])
return 'ok'
```
```php
$rawBody = file_get_contents('php://input'); // raw, NOT json_decode first
$sig = $_SERVER['HTTP_X_EZ2257_SIGNATURE'] ?? '';
$parts = array_column(array_map(fn($p) => explode('=', $p, 2), explode(',', $sig)), 1, 0);
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, getenv('EZ2257_WEBHOOK_SECRET'));
if (!hash_equals($expected, $parts['v1'] ?? '')) {
http_response_code(401); exit('Unauthorized');
}
$event = json_decode($rawBody, true);
if (alreadyHandled($event['id'])) { echo 'ok'; exit; } // dedupe on id
if ($event['type'] === 'solo_account.verified') {
grantUploadAccess($event['data']['externalUserId']);
}
if ($event['type'] === 'solo_account.suspended') {
revokeUploadAccess($event['data']['externalUserId']);
}
echo 'ok';
```
The `solo_account.verified` payload is:
```json
{
"accountId": "cmd4k2x9p0001qz8h3v7ftg2a",
"externalUserId": "user_12345",
"status": "verified",
"verifiedAt": "2026-04-22T12:00:00Z"
}
```
Match on `event.type`, not on `data.status`. The status carried on this event is the literal string `"verified"`, while the REST API reports the same account as `"active"`. A handler comparing `data.status === 'active'` silently never grants access.
`solo_account.suspended` arrives when the subscription lapses. Revoke upload access on it.
## 4. Poll when you need certainty
Webhooks are the fast path. `GET /v1/solo-accounts/{accountId}` is the source of truth, and it is what you use to render a "finish setting up" prompt or to reconcile after an outage.
```bash
curl "https://easy2257.com/api/v1/solo-accounts/$ACCOUNT_ID" \
-H "Authorization: Bearer $EZ2257_API_KEY"
```
```json
{
"accountId": "cmd4k2x9p0001qz8h3v7ftg2a",
"externalUserId": "user_12345",
"status": "pending_id_verification",
"createdAt": "2026-04-22T11:00:00Z",
"onboardingUrl": "https://easy2257.com/partner-onboard/..."
}
```
The four statuses you can observe:
| Status | Meaning | Uploads |
|--------|---------|---------|
| `pending_id_verification` | Not finished. Covers every pre-active state. | Blocked |
| `subscribed_pending_id` | Paid, ID not yet verified. | **Still blocked** |
| `active` | Paid and ID verified. | Allowed |
| `suspended` | Subscription lapsed. | Blocked |
While an account is pending, the response includes a currently valid `onboardingUrl`, refreshed automatically if the old link expired. Re-send the creator to whatever this call returns rather than storing the link.
`verifiedAt`, `suspendedAt`, and `suspendReason` appear once they are set.
**This response carries no performer data.** No legal name, no date of birth, no ID image, no Easy2257 user id, not even the email you provisioned with. Your entire identifier surface is the `accountId` plus your own `externalUserId`. That is the point of naming Easy2257 as Custodian of Records: the sensitive material never lands in your database, so it can never leak from it.
## 5. Log every upload
Register each piece of content after a creator uploads it. `contentUrl` and `title` are required. A non-2xx here means the depiction has no record of custody: treat it as an alertable error, not a warning.
```bash
curl -X POST https://easy2257.com/api/v1/solo-accounts/ACCOUNT_ID/content \
-H "Authorization: Bearer $EZ2257_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"externalContentId": "video_abc123",
"title": "Beach Day",
"contentUrl": "https://your-cdn.com/video_abc123.mp4",
"contentType": "video",
"fileHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}'
```
```javascript
const res = await fetch(
`https://easy2257.com/api/v1/solo-accounts/${accountId}/content`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.EZ2257_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
externalContentId: 'video_abc123',
title: 'Beach Day',
contentUrl: 'https://your-cdn.com/video_abc123.mp4',
contentType: 'video',
fileHash: sha256Hex,
}),
}
);
if (res.status === 409) {
// Switch on code. Two of the three are worth retrying, one never is.
const { code } = await res.json();
if (code === 'uploader_not_solo_active') {
// Their Easy2257 account stopped being active. Waiting does not fix it.
await sendCreatorBackThroughOnboarding(accountId);
} else {
// CONSENT_PENDING or CONSENT_DECLINED: the creator has not approved the link
await holdContentPendingConsent(externalContentId, code);
}
} else if (!res.ok) {
throw new Error(`content log failed: ${res.status}`);
}
```
```python
r = requests.post(
f'https://easy2257.com/api/v1/solo-accounts/{account_id}/content',
headers={'Authorization': f'Bearer {os.environ["EZ2257_API_KEY"]}'},
json={'externalContentId': 'video_abc123',
'title': 'Beach Day',
'contentUrl': 'https://your-cdn.com/video_abc123.mp4',
'contentType': 'video',
'fileHash': sha256_hex},
)
if r.status_code == 409:
# Switch on code. Two of the three are worth retrying, one never is.
code = r.json()['code']
if code == 'uploader_not_solo_active':
# Their Easy2257 account stopped being active. Waiting does not fix it.
send_creator_back_through_onboarding(account_id)
else:
hold_content_pending_consent(external_content_id, code)
else:
r.raise_for_status()
```
```php
$ch = curl_init("https://easy2257.com/api/v1/solo-accounts/{$accountId}/content");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('EZ2257_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'externalContentId' => 'video_abc123',
'title' => 'Beach Day',
'contentUrl' => 'https://your-cdn.com/video_abc123.mp4',
'contentType' => 'video',
'fileHash' => $sha256Hex,
]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($status === 409) {
// Switch on code. Two of the three are worth retrying, one never is.
$code = json_decode($body, true)['code'];
if ($code === 'uploader_not_solo_active') {
// Their Easy2257 account stopped being active. Waiting does not fix it.
sendCreatorBackThroughOnboarding($accountId);
} else {
holdContentPendingConsent($externalContentId, $code);
}
}
```
A new record returns `201`:
```json
{ "contentLogId": "...", "depictionId": "...", "createdAt": "..." }
```
A replay returns `200` with `duplicate: true`:
```json
{ "contentLogId": "...", "depictionId": "...", "duplicate": true }
```
Idempotency comes from the body, not a header: `externalContentId` if you send one, falling back to `contentUrl` if you do not. **Always send `externalContentId`.** It is the only way to address the depiction later for a takedown.
`fileHash` (SHA-256 of the binary) is optional but recommended: it satisfies 28 CFR 75.2(f) record integrity. Omit it if you only have the URL.
## 6. Handle takedowns
When a creator removes content from your platform:
```bash
curl -X DELETE \
"https://easy2257.com/api/v1/solo-accounts/ACCOUNT_ID/content/video_abc123" \
-H "Authorization: Bearer $EZ2257_API_KEY"
```
```javascript
const res = await fetch(
`https://easy2257.com/api/v1/solo-accounts/${accountId}/content/${externalContentId}`,
{ method: 'DELETE', headers: { Authorization: `Bearer ${process.env.EZ2257_API_KEY}` } }
);
const { deleted } = await res.json(); // deleted: false means nothing matched
```
```python
r = requests.delete(
f'https://easy2257.com/api/v1/solo-accounts/{account_id}/content/{external_content_id}',
headers={'Authorization': f'Bearer {os.environ["EZ2257_API_KEY"]}'},
)
r.raise_for_status()
deleted = r.json()['deleted'] # False means nothing matched
```
```php
$ch = curl_init("https://easy2257.com/api/v1/solo-accounts/{$accountId}/content/{$externalContentId}");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('EZ2257_API_KEY')],
]);
$deleted = json_decode(curl_exec($ch), true)['deleted'];
```
DELETE is idempotent and never 404s on the content id. An unknown `externalContentId` returns `200` with `{ "deleted": false }`. A `404` means the `accountId` itself is not one of yours.
**The custody record outlives the takedown.** Inside the 7-year retention window required by 28 CFR Part 75, your takedown marks the depiction removed and stamps who removed it and why, rather than erasing the trail. Only outside that window is the record fully deleted. That is exactly what an inspector needs to see, and it is why you can honor a removal request immediately without worrying that you have destroyed evidence.
You also receive a `content_log.deleted` webhook when Easy2257 initiates the removal, for instance through the public removal portal. Under the TAKE IT DOWN Act, pull the content from your CDN within **48 hours** of that event, even when you did not initiate it.
## You're done.
| What you built | 2257 obligation satisfied |
|---|---|
| `POST /v1/solo-accounts` | Initiates the identity record for the performer |
| Onboarding redirect | Producer attestation + ID verification (§ 2257(b)(1)) |
| `solo_account.verified` webhook | Confirms ID is on file at Easy2257 (COR) |
| `POST /v1/solo-accounts/{id}/content` | Per-depiction cross-reference index (28 CFR 75.2) |
| `DELETE` + `content_log.deleted` | Takedown compliance (TAKE IT DOWN Act) |
Full endpoint reference: [API Reference](/docs/api)
### https://docs.easy2257.com/docs/webhooks/events
Every event shares the same envelope. Only `data` changes:
```json
{
"id": "evt_m2k9x1_a7f3qz",
"type": "solo_account.verified",
"created": 1714000000,
"data": { }
}
```
`id` is unique per event. Deduplicate on it: a delivery may arrive more than once. `created` is Unix seconds.
Identifiers in payloads (`accountId`, `contentLogId`, `depictionId`, scene and publication ids, `productionId`) are opaque strings with no prefix and no fixed structure. Store them as-is. Do not parse or validate a shape.
## Solo accounts
### `solo_account.verified`
The creator is fully compliant: subscription paid **and** government ID verified. Unlock uploads on this event.
```json
{
"accountId": "cmd4k2x9p0001qz8h3v7ftg2a",
"externalUserId": "user_12345",
"status": "verified",
"verifiedAt": "2026-04-22T12:00:00Z"
}
```
The status value is the literal string `"verified"`, and the account key is `accountId`. A handler that reads `data.id` or compares status to `"active"` will never match. `"active"` is the status you get back from the REST API; `"verified"` is the status carried on this event.
It fires from five situations, all of which mean the same thing to you:
- The creator finished ID verification and their subscription is paid.
- An existing Easy2257 user approved your link request and was already an active solo creator.
- A lapsed subscription renewed and the account reactivated.
- You reactivated a creator you had deactivated (`POST /api/v1/solo-accounts/{accountId}/reactivate`).
- You re-called `POST /v1/solo-accounts` for an account that was already active, so your state can resync.
### `solo_account.suspended`
The creator is no longer in custody. Revoke upload access. `reason` says why, and why decides what brings them back.
```json
{
"accountId": "cmd4k2x9p0001qz8h3v7ftg2a",
"externalUserId": "user_12345",
"reason": "subscription_lapsed"
}
```
| `reason` | What happened | What brings them back |
|---|---|---|
| `subscription_lapsed` | The creator's own subscription ended. | The creator pays again. |
| `platform_lapsed` | Your platform fee lapsed, so every creator your platform funds was suspended. | Your platform fee is paid. |
| `partner_deactivated` | You deactivated the creator, through `POST /api/v1/solo-accounts/{accountId}/deactivate` or the portal roster. | `POST .../reactivate`, from you. |
| `platform_deactivated`, `manual_suspension` | Easy2257 suspended the account. | Easy2257. |
When they come back and the creator's ID verification is still on file, the account reactivates and you receive `solo_account.verified` again. No re-verification, and no second onboarding trip for the creator.
## Content log
### `content_log.created`
A depiction was recorded against the creator's identity record.
```json
{
"accountId": "cmd4k2x9p0001qz8h3v7ftg2a",
"externalContentId": "video_abc123",
"contentLogId": "cmd4k7f2r0003qz8hb1n4pd9c",
"depictionId": "cmd4k7f2r0004qz8hs6y2wk1e",
"contentUrl": "https://your-cdn.com/video_abc123.mp4",
"contentType": "video",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"complianceCopy": "pending"
}
```
`externalContentId` and `sha256` are `null` when you did not send them. `complianceCopy` is `pending` when you declared a copy on the create call and have not confirmed it yet, `missing` when you did not declare one (JuicyStarz pipeline only; platform-plan partners must), and `stored` once confirmed.
### `content_log.copy_stored`
The compliance copy of a depiction was uploaded, verified against the declared size and type, and hashed by Easy2257 (28 CFR 75.2(a)(1)(iii)). Fires once per depiction, after your `POST .../content/{externalContentId}/compliance-copy` call.
```json
{
"accountId": "cmd4k2x9p0001qz8h3v7ftg2a",
"externalContentId": "video_abc123",
"contentLogId": "cmd4k7f2r0003qz8hb1n4pd9c",
"depictionId": "cmd4k7f2r0004qz8hs6y2wk1e",
"sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"bytes": 48213377,
"mimeType": "video/mp4",
"storedAt": "2026-08-21T18:04:11.000Z"
}
```
`sha256` here is computed by Easy2257 from the stored copy; it is not the `sha256` you sent on the create call, which describes your original.
### `content_log.deleted`
A logged depiction was removed, either by your `DELETE` call or by an Easy2257-side takedown you did not initiate.
```json
{
"accountId": "cmd4k2x9p0001qz8h3v7ftg2a",
"externalContentId": "video_abc123",
"contentLogId": "cmd4k7f2r0003qz8hb1n4pd9c",
"depictionId": "cmd4k7f2r0004qz8hs6y2wk1e",
"contentUrl": "https://your-cdn.com/video_abc123.mp4",
"contentType": "video"
}
```
There is no `sha256` on the delete event. Match on `externalContentId` or `depictionId`.
When the takedown is Easy2257-side (an administrator actioned a depicted-person removal request against this depiction), the event carries three extra fields so your receiver can tell it from your own `DELETE` and log the basis:
```json
{
"reason": "removal_request",
"removalRequestId": "cmd4m1q7h0009qz8hx2c5tb4f",
"legalBasis": "NCII_TAKE_IT_DOWN"
}
```
`legalBasis` is one of `NCII_TAKE_IT_DOWN`, `NCII_STATE`, `AN_5196_APPEAL`, `DMCA`, `OTHER`. Matching a removal request to a logged depiction is confirmed by an Easy2257 administrator before this event is sent; it is never dispatched by automatic URL matching alone.
Treat this event as authoritative even when you did not trigger it. Under the TAKE IT DOWN Act, pull the content from your CDN within 48 hours.
## Collab scenes
### `collab_scene.created`
The scene exists and invite emails have gone out. This is **not** permission to publish.
```json
{
"scene": {
"sceneId": "cmd4kb8u10007qz8h9x3mrt5v",
"status": "pending_signatures",
"performers": [
{ "email": "performer@example.com", "status": "pending_consent" },
{ "email": "newcomer@example.com", "status": "pending_consent" }
],
"fees": {
"sceneFeeCents": 500,
"onboardingFeeCents": 2500,
"totalCents": 3000,
"charged": true,
"stripeChargeId": "pi_3ABC...",
"chargeError": null,
"billedTo": "uploader_card"
},
"expiresAt": "2026-05-06T12:00:00Z"
},
"uploadSessionId": null
}
```
Every invited performer starts at `pending_consent`, whether or not the address already had an Easy2257 account. Registration and ID verification, when needed, happen inside the magic-link flow and are never visible here, so performer entries cannot be used to probe whether an email is already on Easy2257. The onboarding fee appears only as a scene-level aggregate.
If the immediate fee charge failed, `fees.charged` is `false` and `fees.chargeError` carries the failure code. The scene is created and consent collection proceeds either way.
On a platform-funded account whose collab scenes are billed to its platform invoice, `fees.billedTo` is `platform_invoice` and `fees.charged` is `false` on every scene, because the fees go on your next monthly invoice rather than a card. There, only a non-null `fees.chargeError` signals a problem.
`uploadSessionId` is the hosted attestation session that produced the scene, so you can correlate the event back to the content row that triggered it. It is `null` when you created the scene directly through `POST /v1/collab-scenes`.
A scene that came from a hosted session also carries `contentOrigin`: `captured`, or `mixed` when the session was declared as the uploader with AI-generated figures and the uploader named other real people. Only those real people are on the scene. The field is absent on scenes created directly through `POST /v1/collab-scenes`.
### `collab_scene.performer_consented`
One performer signed. Others may still be outstanding.
```json
{
"scene": { "id": "cmd4kb8u10007qz8h9x3mrt5v" },
"performer": { "email": "performer@example.com", "consentedAt": "2026-04-23T09:14:00Z" },
"uploadSessionId": null
}
```
**`uploadSessionId` is not always present on this event.** It is `null` when the scene was created directly through `POST /v1/collab-scenes`, and the key is **absent entirely** when the signer was a new performer who completed registration inside the magic-link flow. Treat it as optional and correlate on `scene.id`, which every delivery carries.
### `collab_scene.performer_refused`
A performer declined. The scene moves to `blocked` and can never be greenlit.
```json
{
"scene": { "id": "cmd4kb8u10007qz8h9x3mrt5v", "status": "blocked" },
"performer": { "email": "performer@example.com", "reason": "not_me" }
}
```
`reason` is one of `not_me`, `declining`, or `other`.
### `collab_scene.greenlit`
Every performer has consented and signed. **Publish on this event, not before.**
```json
{
"scene": {
"id": "cmd4kb8u10007qz8h9x3mrt5v",
"status": "greenlit",
"greenlitAt": "2026-04-23T09:14:00Z"
},
"uploadSessionId": null
}
```
`uploadSessionId` behaves exactly as on `collab_scene.performer_consented`: `null` for scenes created directly through the API, and absent entirely when the closing signature came from a newly registered performer. Correlate on `scene.id`.
### `collab_scene.blocked`
The scene reached its 14-day expiry with performers still unsigned. A reminder went out at day 7.
```json
{
"scene": { "id": "cmd4kb8u10007qz8h9x3mrt5v", "status": "blocked", "reason": "sla_breach" },
"unsignedPerformers": ["newcomer@example.com"]
}
```
### `collab_scene.cancelled`
The uploader cancelled the scene through `POST /v1/collab-scenes/{id}/cancel`.
```json
{
"scene": { "id": "cmd4kb8u10007qz8h9x3mrt5v", "status": "cancelled" }
}
```
### `collab_scene.publication_created`
You own the scene, and a different partner platform registered a publication of it. Fires only to the scene owner.
```json
{
"scene": { "id": "cmd4kb8u10007qz8h9x3mrt5v" },
"publication": {
"id": "cmd4kd1s7000bqz8hc4v8jq2m",
"partnerId": "cmd4jz0aa0000qz8h1t6knw3p",
"publishedAt": "2026-04-24T18:02:00Z"
}
}
```
### `collab_scene.publication_removed`
A partner recorded a takedown of their publication of your scene.
```json
{
"scene": { "id": "cmd4kb8u10007qz8h9x3mrt5v" },
"publication": {
"id": "cmd4kd1s7000bqz8hc4v8jq2m",
"partnerId": "cmd4jz0aa0000qz8h1t6knw3p",
"removedAt": "2026-05-02T10:30:00Z",
"reason": "performer_request"
}
}
```
## Upload sessions
### `upload_session.solo_completed`
A creator finished a hosted attestation session for single-performer content. The attestation block is the evidence record: IP, user agent, timestamp, and the exact wording they agreed to.
```json
{
"sessionId": "cmd4kf9x2000fqz8hp7r3zd8k",
"contentId": "video_abc123",
"contentType": "video",
"uploaderExternalUserId": "user_12345",
"attestation": {
"ip": "203.0.113.42",
"ua": "Mozilla/5.0 ...",
"attestedAt": "2026-04-24T17:55:00Z",
"text": "I confirm I am the only person depicted ...",
"shootDate": "2026-04-20T00:00:00Z"
}
}
```
Fires only for `captured` sessions, which is every session that does not send `contentOrigin`.
### `upload_session.synthetic_completed`
A creator finished a hosted attestation session for content with AI-generated figures: a `synthetic` session (no real person depicted), or a `mixed` session where the uploader named no other real person. It is a separate event from `solo_completed` on purpose, so a handler written for real-person content does not publish this without being told what it is.
```json
{
"sessionId": "cmd4kf9x2000fqz8hp7r3zd8k",
"contentId": "image_def456",
"contentType": "image",
"contentOrigin": "synthetic",
"personaName": "Lisa Mays",
"contentSha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"generationTool": "Example Gen 3",
"detectedFaceCount": 3,
"uploaderExternalUserId": "user_12345",
"attestation": {
"ip": "203.0.113.42",
"ua": "Mozilla/5.0 ...",
"attestedAt": "2026-10-25T17:55:00Z",
"text": "I attest under penalty of perjury that this content was generated entirely by AI ...",
"shootDate": "2026-10-24T00:00:00Z"
}
}
```
`attestation.text` is the exact wording the uploader agreed to, chosen by Easy2257 for the session's origin. On a `mixed` session it is the solo wording followed by a statement that every other figure is AI-generated. `shootDate` is the date the uploader gave for when the content was made. `personaName` and `generationTool` are `null` when you did not send them. `contentSha256` is always present: it is required to open a synthetic or mixed session, so compare it with the file you are about to publish.
## Productions
### `production.documented`
A producer finalized documentation, or reissued the sealed package after a correction. `certificate` is the signed RS256 JWT itself: store the string alongside your content and verify it offline against `easy2257.com/.well-known/jwks.json`. A second delivery for the same `productionId` means the record was corrected and the package rebuilt: the certificate on the newer delivery is the one that stands, and the one it replaces shows as `SUPERSEDED` rather than revoked, so it never reaches the revocation list.
```json
{
"productionId": "cmd4kh2m3000jqz8hn9w5vb7t",
"performerCount": 3,
"documentedAt": "2026-04-25T14:00:00Z",
"certificate": "eyJhbGciOiJSUzI1NiIsImtpZCI6..."
}
```
### `production.takedown_requested`
A depicted person submitted a removal request against a production linked to you. Act by `removalDeadline`.
```json
{
"productionId": "cmd4kh2m3000jqz8hn9w5vb7t",
"removalRequestId": "cmd4kj5p1000nqz8hd2f6xs4y",
"legalBasis": "NCII_TAKE_IT_DOWN",
"performerEmailHash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
"requestedAt": "2026-04-26T08:00:00Z",
"removalDeadline": "2026-04-28T08:00:00Z"
}
```
**No raw address is ever sent, and despite the name, `performerEmailHash` is the SHA-256 hex digest of the lowercased, trimmed address the claimant filed the removal request with.** The claimant is whoever submitted the request: often the depicted person, but possibly a parent, attorney, or other representative, and possibly the performer using a different address than the one you hold. Hashing your own records and comparing digests is a correlation hint, not an identifier, and a miss does not mean the request is invalid. `productionId` and `removalDeadline` are what govern the takedown.
`removalDeadline` is already computed for the legal basis of the request, so honor the field rather than hardcoding a window:
| `legalBasis` | Window |
|--------------|--------|
| `NCII_TAKE_IT_DOWN` | 48 hours |
| `NCII_STATE` | 72 hours |
| `AN_5196_APPEAL`, `DMCA`, `OTHER` | 7 business days |
### `production.certificate_renewed`
Annual re-attestation issued a fresh certificate. Replace your stored copy: the previous one is superseded (it shows as `SUPERSEDED` in `GET /v1/productions/{id}/certificates`), the content stays up, and it does not appear in the revocation list.
```json
{
"productionId": "cmd4kh2m3000jqz8hn9w5vb7t",
"newCertificate": "eyJhbGciOiJSUzI1NiIsImtpZCI6...",
"previousCertificateExpiresAt": "2027-04-25T14:00:00Z"
}
```
### `production.records_inspected`
A federal inspector accessed the records for that production. Informational: no action required from you, because Easy2257 responded as Custodian of Records.
```json
{
"productionId": "cmd4kh2m3000jqz8hn9w5vb7t",
"inspectedAt": "2026-06-01T16:20:00Z",
"inspectorAgency": "FBI"
}
```
## Scene verification jobs
These three fire for scenes created through the scene verification job flow, not the Solo Creator or Collab Scene surfaces. They share one payload shape, where `verification.id` is the job id you supplied and `verification.contentId` is your content reference.
### `performer.id_verified`
```json
{
"verification": { "id": "job_abc123", "contentId": "video_abc123" },
"performer": { "email": "performer@example.com", "status": "id_verified" }
}
```
### `performer.forms_signed`
```json
{
"verification": { "id": "job_abc123", "contentId": "video_abc123" },
"performer": { "email": "performer@example.com", "status": "forms_signed" }
}
```
### `verification.cancelled`
The scene behind the job was cancelled.
```json
{
"verification": { "id": "job_abc123", "contentId": "video_abc123", "status": "cancelled" }
}
```
This event has nothing to do with solo onboarding. **No event fires when a creator abandons solo onboarding.** Poll `GET /v1/solo-accounts/{accountId}` if you want to know whether someone is still pending, and re-send them to the `onboardingUrl` that call returns.
## Testing
### `test.ping`
Fired by the test button in the partner portal. Signed exactly like a live event, with the delivery header set to `del_test` and an id of the form `evt_test_`.
```json
{
"message": "This is a test webhook from Easy2257"
}
```
### https://docs.easy2257.com/docs/webhooks
Easy2257 pushes a signed HTTP POST to your server whenever something changes on an account, a scene, or a piece of content you manage.
## Register an endpoint
Webhook endpoints are managed in the partner portal at [`partner.easy2257.com/webhooks`](https://partner.easy2257.com/webhooks).
The `callbackUrl` you pass to `POST /v1/solo-accounts` and `POST /v1/collab-scenes` is stored on the record for your own reference. It does **not** subscribe you to anything. Deliveries go to the endpoints registered in the portal, and only to those. If you have not registered an endpoint, you receive no events.
Each endpoint has:
| Property | Rule |
|----------|------|
| URL | Must be `https://`. URLs that resolve to a private or internal address are rejected at registration and again at delivery time. |
| Signing secret | Generated per endpoint and prefixed `whsec_`. Never shared across endpoints, so you can rotate one integration without touching another. |
| Event subscription | A list of event types, or `*` for everything. |
| Limit | 5 endpoints per partner. |
The portal's test button fires a real signed `test.ping` delivery at the endpoint, so you can prove your signature check works before any live traffic depends on it.
## Envelope
Every delivery of every type has the same body:
```json
{
"id": "evt_m2k9x1_a7f3qz",
"type": "solo_account.verified",
"created": 1714000000,
"data": { }
}
```
- `id`: unique per delivery attempt series. **Deduplicate on this.**
- `created`: Unix timestamp in seconds.
- `data`: the event-specific payload. See the [event reference](/docs/webhooks/events).
## Headers
| Header | Value |
|--------|-------|
| `X-EZ2257-Signature` | `t=,v1=`. [Verify it](/docs/webhooks/signatures) before you trust the body. |
| `X-EZ2257-Event` | The event type, so you can route to a handler without parsing the body first. |
| `X-EZ2257-Delivery` | An opaque id for this delivery, prefixed `del_`. Treat it as a log correlation handle only — deduplicate on `event.id`, not on this. Portal test pings send the literal `del_test`. |
## Available events
### Solo accounts
| Event | Fires when |
|-------|-----------|
| `solo_account.verified` | The creator has both paid and passed ID verification, or an already-active account is re-provisioned, or a lapsed subscription renews. **This is the signal to unlock uploads.** |
| `solo_account.suspended` | The subscription lapsed. Revoke upload access. |
### Content log
| Event | Fires when |
|-------|-----------|
| `content_log.created` | A depiction was logged via `POST /v1/solo-accounts/{id}/content`. |
| `content_log.deleted` | A logged depiction was removed, by your `DELETE` call or by an Easy2257-side takedown. |
### Collab scenes
| Event | Fires when |
|-------|-----------|
| `collab_scene.created` | A scene was created and invite emails went out. |
| `collab_scene.performer_consented` | One performer signed. |
| `collab_scene.performer_refused` | One performer declined. The scene is now `blocked`. |
| `collab_scene.greenlit` | Every performer has signed. **This is the signal to publish.** |
| `collab_scene.blocked` | The scene expired with unsigned performers (14 days from creation). |
| `collab_scene.cancelled` | The scene was cancelled via the API. |
| `collab_scene.publication_created` | Another partner registered a publication of a scene you own. |
| `collab_scene.publication_removed` | Another partner recorded a takedown of their publication of a scene you own. |
### Upload sessions
| Event | Fires when |
|-------|-----------|
| `upload_session.solo_completed` | A creator finished a hosted single-performer attestation session. |
### Productions
| Event | Fires when |
|-------|-----------|
| `production.documented` | A producer finalized documentation, or reissued the sealed package after a correction. Carries the certificate that now stands. |
| `production.takedown_requested` | A depicted person submitted a removal request. Carries the deadline you must meet. |
| `production.certificate_renewed` | The annual re-attestation issued a fresh certificate and superseded the old one. |
| `production.records_inspected` | A federal inspector accessed the records for that production. |
### Scene verification jobs
These fire for scenes created through the legacy scene verification flow rather than the Solo Creator or Collab Scene surfaces.
| Event | Fires when |
|-------|-----------|
| `performer.id_verified` | A performer on the scene passed ID verification. |
| `performer.forms_signed` | A performer on the scene signed their compliance forms. |
| `verification.cancelled` | The scene was cancelled. |
### Testing
| Event | Fires when |
|-------|-----------|
| `test.ping` | You pressed the test button in the partner portal. Payload is `{ "message": "This is a test webhook from Easy2257" }`. |
## Next steps
- [Verify signatures](/docs/webhooks/signatures): required before processing any event
- [Event reference](/docs/webhooks/events): exact payload shapes
- [Retries and delivery](/docs/webhooks/retries): schedule, timeouts, and where to inspect deliveries
### https://docs.easy2257.com/docs/webhooks/retries
Easy2257 attempts each delivery up to **5 times** with a widening backoff, then marks it exhausted.
## Retry schedule
| Attempt | Sent |
|---------|------|
| 1 | Immediately, as the event happens |
| 2 | 1 minute after attempt 1 fails |
| 3 | 5 minutes after attempt 2 fails |
| 4 | 30 minutes after attempt 3 fails |
| 5 | 2 hours after attempt 4 fails |
| none | If attempt 5 fails the delivery is marked `EXHAUSTED` and is never retried again |
A delivery therefore survives roughly two and a half hours of downtime on your side. Beyond that you need to reconcile from the API rather than wait for a redelivery: `GET /v1/solo-accounts/{accountId}` gives you current account status, and `GET /v1/collab-scenes/{sceneId}` gives you current scene status.
## What counts as a failure
An attempt fails when your endpoint returns a non-`2xx` status, when the connection errors, when the request exceeds the 30-second timeout, or when your URL resolves to a private or internal address.
Return `2xx` as soon as you have the payload safely queued, and do the real work asynchronously. A handler that takes longer than 30 seconds will be retried even if it eventually succeeds, which is how duplicate processing happens.
## Retry sweep
Pending retries are processed by a scheduled sweep that picks up deliveries whose next retry time has elapsed. Each sweep handles up to **50 deliveries**, so a large backlog drains over several sweeps rather than all at once.
Each attempt is re-signed at send time with a fresh `t` timestamp. The body and the event `id` never change.
## Deduplication
Delivery is at-least-once. Deduplicate on `event.id`, which is stable across every attempt of the same event. Do not dedupe on the signature timestamp: it is regenerated per attempt.
## Inspecting deliveries
Delivery history per endpoint, including HTTP status, attempt count, the response body we received, and final state (`PENDING` / `DELIVERED` / `FAILED` / `EXHAUSTED`), is in the partner portal at [`partner.easy2257.com/webhooks`](https://partner.easy2257.com/webhooks).
### https://docs.easy2257.com/docs/webhooks/signatures
Every webhook request from Easy2257 includes an `X-EZ2257-Signature` header.
**Always verify signatures before processing events.** An unverified webhook endpoint is an open door for spoofed events.
Your signing secret is per endpoint, is shown in the partner portal at [`partner.easy2257.com/webhooks`](https://partner.easy2257.com/webhooks), and is prefixed `whsec_`. Two endpoints never share a secret.
## Headers on every delivery
| Header | Value |
|--------|-------|
| `X-EZ2257-Signature` | `t=1714000000,v1=abc123...` |
| `X-EZ2257-Event` | The event type, e.g. `solo_account.verified`. Route on this before parsing the body. |
| `X-EZ2257-Delivery` | An opaque id for this delivery, prefixed `del_`. Portal test pings send the literal `del_test`. |
## Signature format
```
X-EZ2257-Signature: t=1714000000,v1=abc123...
```
- `t`: Unix timestamp in seconds, generated at the moment the request is sent
- `v1`: HMAC-SHA256 of `{t}.{rawBody}`, hex-encoded, keyed with your endpoint's `whsec_` secret
## Verification algorithm
1. Extract `t` and `v1` from the header
2. Construct the signed payload: `"{t}.{rawBody}"`
3. Compute HMAC-SHA256 of that payload with your endpoint secret, hex-encoded
4. Compare your computed signature to `v1` using a **constant-time** comparison
5. Optionally: reject if `t` is more than 5 minutes ago (replay protection)
**Use the raw request body** for the HMAC: the literal bytes received over the wire. Never JSON.parse the body and re-serialize it before hashing. Re-serialization changes whitespace and key order, producing a different hash. This is the single most common integration failure.
## Verify function + webhook handler
```javascript
import { createHmac, timingSafeEqual } from 'crypto';
function verifyWebhookSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
const { t, v1 } = parts;
if (!t || !v1) return false;
if (toleranceSeconds > 0 && Math.floor(Date.now() / 1000) - parseInt(t) > toleranceSeconds)
return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
// Next.js App Router: app/api/webhooks/ez2257/route.ts
export async function POST(req) {
const rawBody = await req.text(); // NEVER req.json() before this
const sig = req.headers.get('x-ez2257-signature') ?? '';
if (!verifyWebhookSignature(rawBody, sig, process.env.EZ2257_WEBHOOK_SECRET))
return new Response('Unauthorized', { status: 401 });
const event = JSON.parse(rawBody);
// handle event...
return new Response('ok');
}
// Express: use express.raw(), not express.json(), on this route
app.post('/webhooks/ez2257', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
const sig = req.headers['x-ez2257-signature'] ?? '';
if (!verifyWebhookSignature(rawBody, sig, process.env.EZ2257_WEBHOOK_SECRET))
return res.status(401).send('Unauthorized');
const event = JSON.parse(rawBody);
// handle event...
res.send('ok');
});
```
```python
import hmac, hashlib, time
def verify_webhook_signature(raw_body, header, secret, tolerance_seconds=300):
parts = dict(p.split('=', 1) for p in header.split(',') if '=' in p)
t, v1 = parts.get('t'), parts.get('v1')
if not t or not v1:
return False
if tolerance_seconds > 0 and int(time.time()) - int(t) > tolerance_seconds:
return False
body_bytes = raw_body if isinstance(raw_body, bytes) else raw_body.encode('utf-8')
payload = f'{t}.'.encode() + body_bytes
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(v1, expected)
# Flask: use request.get_data(), not request.get_json()
from flask import Flask, request, abort
import os, json
app = Flask(__name__)
@app.post('/webhooks/ez2257')
def ez2257_webhook():
raw_body = request.get_data() # bytes
sig = request.headers.get('X-EZ2257-Signature', '')
if not verify_webhook_signature(raw_body, sig, os.environ['EZ2257_WEBHOOK_SECRET']):
abort(401)
event = json.loads(raw_body)
# handle event...
return 'ok'
# FastAPI: use await request.body(), not request.json()
from fastapi import FastAPI, Request, HTTPException
fastapi_app = FastAPI()
@fastapi_app.post('/webhooks/ez2257')
async def ez2257_webhook_fastapi(request: Request):
raw_body = await request.body()
sig = request.headers.get('x-ez2257-signature', '')
if not verify_webhook_signature(raw_body, sig, os.environ['EZ2257_WEBHOOK_SECRET']):
raise HTTPException(status_code=401)
event = json.loads(raw_body)
# handle event...
return {'ok': True}
```
```php
function verifyWebhookSignature(
string $rawBody,
string $header,
string $secret,
int $toleranceSeconds = 300
): bool {
$parts = [];
foreach (explode(',', $header) as $pair) {
$kv = explode('=', $pair, 2);
if (count($kv) === 2) $parts[$kv[0]] = $kv[1];
}
$t = $parts['t'] ?? null;
$v1 = $parts['v1'] ?? null;
if (!$t || !$v1) return false;
if ($toleranceSeconds > 0 && time() - (int) $t > $toleranceSeconds) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $v1);
}
// Plain PHP: use php://input
$rawBody = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_EZ2257_SIGNATURE'] ?? '';
if (!verifyWebhookSignature($rawBody, $sig, getenv('EZ2257_WEBHOOK_SECRET'))) {
http_response_code(401); exit('Unauthorized');
}
$event = json_decode($rawBody, true);
// handle event...
echo 'ok';
// Laravel: use $request->getContent(), exempt route from VerifyCsrfToken
Route::post('/webhooks/ez2257', function (Illuminate\Http\Request $request) {
$rawBody = $request->getContent();
$sig = $request->header('X-EZ2257-Signature', '');
if (!verifyWebhookSignature($rawBody, $sig, env('EZ2257_WEBHOOK_SECRET')))
abort(401);
$event = json_decode($rawBody, true);
// handle event...
return response('ok');
});
```
```csharp
using System.Security.Cryptography;
using System.Text;
public static bool VerifyWebhookSignature(
string rawBody, string header, string secret, int toleranceSeconds = 300)
{
var parts = header.Split(',')
.Select(p => p.Split('=', 2)).Where(p => p.Length == 2)
.ToDictionary(p => p[0], p => p[1]);
if (!parts.TryGetValue("t", out var t) || !parts.TryGetValue("v1", out var v1))
return false;
if (toleranceSeconds > 0 &&
DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(t) > toleranceSeconds)
return false;
var payload = Encoding.UTF8.GetBytes($"{t}.{rawBody}");
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToHexString(hmac.ComputeHash(payload)).ToLower();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(v1), Encoding.UTF8.GetBytes(expected));
}
// ASP.NET Core controller: call EnableBuffering() before reading body
[HttpPost("/webhooks/ez2257")]
public async Task Ez2257Webhook()
{
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var rawBody = await reader.ReadToEndAsync();
Request.Body.Position = 0;
var sig = Request.Headers["X-EZ2257-Signature"].FirstOrDefault() ?? "";
if (!WebhookVerifier.VerifyWebhookSignature(rawBody, sig, _webhookSecret))
return Unauthorized();
var evt = JsonSerializer.Deserialize(rawBody);
// handle event...
return Ok();
}
```
## Replay protection
The `t` timestamp lets you reject requests that have been sitting around. The default tolerance in the snippets above is 5 minutes. Tighten it to 60 seconds in production if your clock is reliable.
**Freshness is not deduplication.** Each retry is re-signed at send time with a new `t`, so a delivery that first failed yesterday still arrives with a current timestamp and passes any tolerance you set. The event `id` stays the same across every attempt, so that is the only safe dedupe key.
Deduplicate on `event.id`. Ids look like `evt_m2k9x1_a7f3qz` (portal test pings look like `evt_test_m2k9x1`). Treat them as opaque strings: do not parse a timestamp out of them.