Quickstart
Integrate Easy2257 in 15 minutes.
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, 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.
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"}'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 nowimport 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$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 nowThe response is one of exactly two shapes:
{ "accountId": "...", "status": "active", "createdAt": "..." }{ "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.verifiedright away. - Otherwise they continue through whichever of the subscribe and verify steps they still owe, and you receive
solo_account.verifiedwhen 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
# 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)// 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');
}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'$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:
{
"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.
curl "https://easy2257.com/api/v1/solo-accounts/$ACCOUNT_ID" \
-H "Authorization: Bearer $EZ2257_API_KEY"{
"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.
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"
}'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}`);
}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()$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:
{ "contentLogId": "...", "depictionId": "...", "createdAt": "..." }A replay returns 200 with duplicate: true:
{ "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:
curl -X DELETE \
"https://easy2257.com/api/v1/solo-accounts/ACCOUNT_ID/content/video_abc123" \
-H "Authorization: Bearer $EZ2257_API_KEY"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 matchedr = 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$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