Brand Plugin API
The Brand Plugin puts Elnez campaigns and bounties inside your own product. Your members see the work, submit their clips, and watch a balance grow in your currency, never in dollars. Elnez verifies the views, applies its fraud checks, and pays your Brand account in USD. How you then credit the member is your decision, made in your own system.
Overview
Three moving parts make up an integration.
- Your backend holds a permanent API key and exchanges it for a short-lived member token, once per member visit.
- An iframe on your page loads the Elnez widget and receives that token over
postMessage. The member links a social account, browses your allowlisted campaigns and the open bounties, and submits work. - Your webhook endpoint receives a signed event every time money moves for one of your members, so your own balances stay in step with Elnez.
Nothing about the direct Elnez creator experience changes. A plugin member is a separate, externally owned identity that exists only inside your integration.
The one sentence to remember. Elnez credits your Brand account in USD, the widget only ever shows your display currency, and the webhook tells you how much to credit the member on your side.
The four access gates
Every runtime call checks all four. Miss any one and new member sessions and submissions stop, with existing data left readable.
| Gate | What it means | Who controls it |
|---|---|---|
| Brand account | Your Elnez account type is BRAND, not CLIPPER | Elnez admin |
| Approved application | Your Brand Plugin application is APPROVED | Elnez owner, by hand |
| Active integration | Your integration is ACTIVE, not SUSPENDED | Elnez owner |
| Current Premium | Your Premium subscription has not lapsed | You |
Premium is never approval. Paying for Premium does not approve a plugin application, and an approved application does not survive a lapsed Premium. They are two independent switches and you need both on.
Setup, end to end
- Apply from your Brand accountSign in and open /user/brands/plugin. One application per Brand account. Submitting sends the Elnez owner an email and a bell notification.
- Wait for the owner reviewA person reads it. On approval you get an email, an integration is created, and a public key plus a webhook signing secret are generated. On rejection the email carries the reason and you may apply again.
- Configure the integrationSet your webhook URL, currency name, currency symbol, optional currency logo, and the bounty conversion value. All of it lives on the same plugin page.
- Create a server API keyName it, copy it once. The plaintext is never shown again. Store it the way you store a payment key.
- Ask the owner to allowlist campaignsCampaign access is granted one campaign at a time, by an Elnez owner, with a display reward per 1,000 views expressed in your currency.
- Wire your backend and your webhookThe rest of this page.
What the application asks for
| Field | Rules | Why it matters later |
|---|---|---|
business_name | required, max 120 | Shown to members inside the widget |
website_url | required, HTTPS, max 500 | Review evidence |
platform_type | WEB, MOBILE or BOTH | Review evidence |
allowed_origins | 1 to 10 HTTPS origins, no path, query or fragment | Load bearing. Sets the iframe frame-ancestors, and every member session must name one of these exactly |
android_package_name | optional, max 255 | Review evidence only, never a substitute for API authentication |
ios_bundle_id | optional, max 255 | Review evidence only, never a substitute for API authentication |
audience_size | required integer, 1 or more | Review evidence |
monthly_active_users | required integer, 1 or more | Review evidence |
use_case | required, 30 to 3,000 characters | The part the owner actually reads |
onboarding_mode | API, WIDGET or BOTH | Declares how you plan to integrate |
webhook_url | required, HTTPS, max 500, passes the URL guard below | Copied onto the integration at approval |
Origins are normalised before storage: lowercased, trailing slash removed, duplicates dropped. https://App.YourBrand.com/ and https://app.yourbrand.com are the same entry. A pending or already approved application blocks a second submission, so edit before you apply.
Integration settings you control after approval
| Setting | Rules | Effect |
|---|---|---|
webhook_url | required, HTTPS, max 500 | Where every earnings event is delivered |
currency_name | required, max 40 | The full name, for example Skill Coins |
currency_symbol | required, max 12 | The short label the widget prints, for example SC |
currency_logo_url | optional, HTTPS, max 500 | Your coin artwork |
bounty_display_units_per_usd | required, up to 4 decimals, minimum 0.0001 | How many of your units equal one USD of bounty reward |
Bounties stay locked until the conversion value is saved. Members see open bounties immediately, but every entry is refused with "The Brand must finish its bounty reward setting before members can enter" until bounty_display_units_per_usd is a positive number. The widget bootstrap exposes this as bounty_settings_ready.
Your three credentials
| Credential | Shape | Lives | Used for |
|---|---|---|---|
| Server API key | bpk_ + 48 chars | Your backend only | Creating member sessions |
| Member token | bps_ + 48 chars | The browser, 15 minutes | Every widget call |
| Webhook secret | bpwh_ + 48 chars | Your backend only | Verifying webhook signatures |
| Public key | bpp_ + 32 chars | Your page markup | The iframe URL. Not a secret |
Elnez stores only a SHA-256 hash of the API key and of every member token, so neither can be read back out of the database. The API key is shown once at creation and the webhook secret once at creation or rotation. Afterwards the list shows a masked form, the first 12 characters and the last 4.
Never put the API key or a member token in a URL, a query string, or client-side JavaScript. The API key can create a session for any member ID you name, so a leaked key is a leaked account.
Revoking an API key takes effect on the next request and deletes nothing: members, submissions, earnings, and queued webhooks are untouched. Rotating the webhook secret invalidates the old one immediately, so deploy the new secret before the next event fires. The only scope an API key carries today is member_sessions:create.
Create a member session from your backend
This is the call that starts everything. Your server proves who it is with the API key, names the member with your own stable identifier, and receives a token the browser can safely hold for fifteen minutes.
Creates or reuses the plugin member, then issues a fresh short-lived session token.
Authorization: Bearer bpk_... · 30 requests / minute
Request body
| Field | Type | Rules | Notes |
|---|---|---|---|
external_user_id | string | required, max 191 | Your stable member ID. The same value always resolves to the same plugin member |
origin | string | required, max 500 | Must match one of your approved origins exactly, after lowercasing and trailing-slash removal |
name | string | optional, max 160 | Display only. Never treated as a verified identity |
email | string | optional, valid email, max 255 | Lowercased and encrypted at rest. Never used for Elnez login or mail |
phone | string | optional, max 40 | Encrypted at rest |
curl -X POST https://app.elnez.com/api/v1/brand-plugin/member-sessions \
-H "Authorization: Bearer $ELNEZ_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"external_user_id": "user_88213",
"origin": "https://app.yourbrand.com",
"name": "Ada",
"email": "ada@example.com"
}'
{
"member_token": "bps_EXAMPLE_MEMBER_TOKEN",
"expires_at": "2026-08-23T14:12:44+00:00",
"embed_url": "https://app.elnez.com/brand-plugin/embed/bpp_YOUR_PUBLIC_KEY"
}
Node, in your session route
// Server side only. This route must be behind your own auth.
app.post('/elnez/session', requireLogin, async (req, res) => {
const response = await fetch('https://app.elnez.com/api/v1/brand-plugin/member-sessions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ELNEZ_API_KEY}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
external_user_id: String(req.user.id),
origin: 'https://app.yourbrand.com',
name: req.user.displayName,
email: req.user.email,
}),
});
if (!response.ok) {
const problem = await response.json().catch(() => ({}));
return res.status(502).json({ error: problem.message || 'Elnez refused the session' });
}
const { member_token, expires_at } = await response.json();
res.json({ member_token, expires_at }); // never log the token
});
PHP
$response = Http::withToken(env('ELNEZ_API_KEY'))
->acceptJson()
->post('https://app.elnez.com/api/v1/brand-plugin/member-sessions', [
'external_user_id' => (string) $user->id,
'origin' => 'https://app.yourbrand.com',
'name' => $user->display_name,
'email' => $user->email,
]);
abort_unless($response->successful(), 502, 'Elnez refused the session');
$token = $response->json('member_token');
What the token is, and is not
- It lives for 15 minutes from creation. There is no refresh endpoint: mint a new one.
- It is bound to one member of one integration. It cannot read or write anything belonging to another Brand.
- Only its SHA-256 hash is stored, so it cannot be recovered later, only replaced.
- The widget rejects any posted token that does not start with
bps_. - Once expired, every widget call answers
401with "This member session has expired." Your page should mint a fresh token and reload the iframe when a member has been idle.
The identity boundary
Behind the scenes Elnez creates an external_only internal user for each plugin member, purely so the existing linked-account, view-collection, and fraud systems work unchanged. That account cannot sign in with an Elnez one-time code, is excluded from every Elnez broadcast and marketing audience, and holds no wallet of its own. The name, email, and phone you send are display data, not a verified Elnez identity, and are encrypted at rest.
Social account ownership is still proven inside Elnez, by putting a one-time code in the public bio. The global rule that a platform and handle pair can belong to only one account anywhere on Elnez still applies, so a handle already linked elsewhere is refused.
Embed the widget
The widget is a full page served from Elnez and framed by you. It renders in your currency, your brand name, and your allowlisted campaigns, and it never displays a dollar figure.
The iframe target. Public, no authentication. An unknown key returns 404.
Content-Security-Policy: frame-ancestors 'self' <your approved https origins>
Because frame-ancestors is built from your approved origins, the page simply refuses to render on any other site. That is deliberate: it means a stolen public key is not enough to host your widget somewhere else.
The handshake
- The iframe loads and posts
{ type: 'ELNEZ_PLUGIN_READY' }to its parent. - Your page hears that, fetches a member token from your own backend, and posts
{ type: 'ELNEZ_MEMBER_SESSION', token }back into the frame. - The widget checks the message origin against your approved list and the token prefix, then bootstraps itself.
<iframe
id="elnez-plugin"
src="https://app.elnez.com/brand-plugin/embed/bpp_YOUR_PUBLIC_KEY"
title="Earn with Elnez"
style="width:100%;height:900px;border:0;border-radius:16px"></iframe>
<script>
const ELNEZ_ORIGIN = 'https://app.elnez.com';
const frame = document.getElementById('elnez-plugin');
window.addEventListener('message', async (event) => {
if (event.origin !== ELNEZ_ORIGIN) return;
if (event.data?.type !== 'ELNEZ_PLUGIN_READY') return;
// Your own endpoint, which calls Elnez with the server API key.
const res = await fetch('/elnez/session', { method: 'POST', credentials: 'same-origin' });
const { member_token } = await res.json();
frame.contentWindow.postMessage(
{ type: 'ELNEZ_MEMBER_SESSION', token: member_token },
ELNEZ_ORIGIN
);
});
</script>
Always target the exact Elnez origin in postMessage, and always check event.origin on the way in. Passing '*' as the target broadcasts the member token to whatever happens to be in the frame.
What the member can do inside the widget
- Link and verify a TikTok, Instagram, YouTube, or X account using a bio code.
- Browse the campaigns an Elnez owner has enabled for your integration, with the reward shown as your units per 1,000 views.
- Submit a clip, including the audience screenshot when the campaign requires one.
- See every open bounty, both the global ones and the ones tied to a campaign you have access to, and enter them.
- Track their own submission history and running balance, in your currency.
Webhooks: how delivery works
Every time money moves for one of your members, Elnez writes an immutable earning event and a delivery record in the same database transaction as the ledger change, then sends it to your endpoint. The money is already correct before the first delivery attempt, and a failed delivery never rolls it back.
Request shape
| Header | Value |
|---|---|
Content-Type | application/json |
X-Elnez-Event-Id | The event UUID, identical to id in the body. Use it to deduplicate |
X-Elnez-Timestamp | Unix seconds, as a string, generated per attempt |
X-Elnez-Signature | sha256= followed by the hex HMAC |
The method is always POST. Elnez waits up to 10 seconds for your response and does not follow redirects. Any 2xx marks the delivery DELIVERED; anything else is a failure and will be retried.
Rules your webhook URL must satisfy
The URL is validated when you save it and again, with a fresh DNS lookup, before every single delivery attempt. It must:
- use
https, on port 443 or no explicit port; - carry no username or password in the URL;
- not be
localhostor any.localhosthost; - resolve in DNS;
- resolve only to public addresses, never private, loopback, link-local, or reserved ranges.
The address that passed validation is then pinned for the connection, so a DNS answer that changes in between cannot redirect the delivery to an internal host.
Verify the signature
The signed string is the timestamp, a literal dot, and the raw request body, exactly as received.
signature = "sha256=" + hex( HMAC_SHA256( key = webhook_secret,
message = timestamp + "." + raw_body ) )
Sign the raw bytes, not a re-serialised object. Parsing the JSON and stringifying it again changes key order and spacing, and the signature will never match. Capture the body before your framework parses it.
const crypto = require('crypto');
app.post('/webhooks/elnez',
express.raw({ type: 'application/json' }), // keeps the raw body
(req, res) => {
const timestamp = req.get('X-Elnez-Timestamp') || '';
const received = req.get('X-Elnez-Signature') || '';
const eventId = req.get('X-Elnez-Event-Id') || '';
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.ELNEZ_WEBHOOK_SECRET)
.update(timestamp + '.' + req.body.toString('utf8'))
.digest('hex');
const a = Buffer.from(expected), b = Buffer.from(received);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
// Optional freshness check against replay of an old capture.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// Idempotent: the same event id may arrive more than once.
if (alreadyHandled(eventId)) return res.sendStatus(200);
creditMember(event.data);
markHandled(eventId);
res.sendStatus(200); // answer fast, do the slow work in a queue
});
$raw = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_ELNEZ_TIMESTAMP'] ?? '';
$received = $_SERVER['HTTP_X_ELNEZ_SIGNATURE'] ?? '';
$eventId = $_SERVER['HTTP_X_ELNEZ_EVENT_ID'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $raw, getenv('ELNEZ_WEBHOOK_SECRET'));
if (! hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// deduplicate on $eventId, then credit, then:
http_response_code(200);
import hmac, hashlib, os
from flask import request, abort
@app.post("/webhooks/elnez")
def elnez_webhook():
raw = request.get_data() # bytes, unparsed
timestamp = request.headers.get("X-Elnez-Timestamp", "")
received = request.headers.get("X-Elnez-Signature", "")
event_id = request.headers.get("X-Elnez-Event-Id", "")
expected = "sha256=" + hmac.new(
os.environ["ELNEZ_WEBHOOK_SECRET"].encode(),
timestamp.encode() + b"." + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, received):
abort(401)
event = request.get_json()
if not already_handled(event_id):
credit_member(event["data"])
mark_handled(event_id)
return "", 200
Event payloads
There is one event type today, member.earnings.updated. What separates a credit from a clawback is data.event_kind.
| event_kind | Sign | Fires when |
|---|---|---|
ACCRUAL | positive | An approved clip earns from newly verified views. Fires repeatedly as views grow |
REVERSAL | negative | An approval is withdrawn after payout and the earnings are clawed back |
DEDUCTION | negative | Accrued earnings are removed before payout, for example on a fraud finding |
BOUNTY_APPROVED | positive | A staff reviewer approves your member's bounty entry and the reward is paid |
Campaign earnings
{
"id": "0f9a5c6e-3d21-4a77-9b4c-2f6b1d8e5a30",
"type": "member.earnings.updated",
"created_at": "2026-08-23T13:41:07+00:00",
"data": {
"member_id": "user_88213",
"submission_id": 4821,
"campaign_id": 37,
"event_kind": "ACCRUAL",
"display_delta": "125.0000",
"display_total": "1875.0000",
"currency": {
"name": "Skill Coins",
"symbol": "SC"
},
"completed": false
}
}
{
"id": "b71c4d02-8e55-49af-8f1a-6c0d9e2b4713",
"type": "member.earnings.updated",
"created_at": "2026-08-23T16:02:55+00:00",
"data": {
"member_id": "user_88213",
"submission_id": 4821,
"campaign_id": 37,
"event_kind": "REVERSAL",
"display_delta": "-1875.0000",
"display_total": "0.0000",
"currency": {
"name": "Skill Coins",
"symbol": "SC"
},
"completed": false
}
}
Bounty earnings
A bounty event carries source_type, a null submission_id, and the two bounty identifiers instead.
{
"id": "5c8e1f34-96b7-4c02-a1d8-77e3b5904cf6",
"type": "member.earnings.updated",
"created_at": "2026-08-23T18:20:11+00:00",
"data": {
"member_id": "user_88213",
"source_type": "BOUNTY",
"submission_id": null,
"bounty_submission_id": 312,
"bounty_id": 44,
"campaign_id": null,
"event_kind": "BOUNTY_APPROVED",
"display_delta": "5000.0000",
"display_total": "5000.0000",
"currency": {
"name": "Skill Coins",
"symbol": "SC"
},
"completed": true
}
}
Field by field
| Field | Type | Meaning |
|---|---|---|
id | uuid | Stable event identifier. Same value as X-Elnez-Event-Id. Deduplicate on this |
type | string | Always member.earnings.updated today. Treat an unknown type as ignorable, not as an error |
created_at | ISO 8601 | When the event was recorded, not when this attempt was sent |
data.member_id | string | Your own external_user_id, unchanged |
data.source_type | string | Present as BOUNTY on bounty events. Absent on campaign events, where you should read it as CAMPAIGN |
data.submission_id | int or null | The Elnez clip submission. Null on bounty events |
data.bounty_submission_id | int | Bounty events only. The member's entry |
data.bounty_id | int | Bounty events only |
data.campaign_id | int or null | The campaign. Null for a global bounty |
data.event_kind | string | One of the four kinds above |
data.display_delta | string, 4 dp | The change to apply, in your units. Negative on REVERSAL and DEDUCTION |
data.display_total | string, 4 dp | Running total in your units for that submission, floored at zero |
data.currency | object | The name and symbol snapshotted when the member submitted |
data.completed | boolean | True when nothing more will be earned: the submission hit its cap, or the bounty was paid |
Amounts arrive as strings. They are fixed-point decimals with four places, produced by Elnez's decimal money helpers. Parse them with a decimal type, not a float, if you are going to add them up.
Retries, idempotency, and replay
- Retry schedule. Up to 5 attempts per event, spaced at 60 seconds, 5 minutes, 15 minutes, then 1 hour.
- Success. Any 2xx. The delivery is marked
DELIVEREDand never sent again on its own. - Failure. A non-2xx status, a timeout past 10 seconds, a redirect, or a URL that fails the safety check. The status becomes
FAILEDwith the reason recorded. - Duplicates are expected. A response that is slow but eventually successful can still be retried. Make your handler idempotent on
id. - Money is never at risk. Elnez cannot create two earning events for the same underlying ledger movement, and a delivery failure never reverses a credit.
- Replay. An Elnez owner can resend any failed delivery with the same event ID once your endpoint is healthy again. Ask, with the event ID, and it will be re-sent rather than recalculated.
Answer 200 first, work afterwards. Verify the signature, record the event, return 200, and do the crediting in a background job. A handler that does slow work inline is the usual cause of a retry storm.
Endpoint reference
All widget endpoints authenticate with a member token: Authorization: Bearer bps_.... Send Accept: application/json.
Server to server. Documented in full above.
Bearer bpk_ · 30 / minute
Everything the widget renders: the member, your currency, their linked accounts, your allowlisted campaigns, the open bounties, and their history. This is the one endpoint that still answers when your integration is paused, so history stays readable.
Bearer bps_ · 120 / minute
{
"read_only": false,
"member": { "external_user_id": "user_88213" },
"currency": { "name": "Skill Coins", "symbol": "SC", "logo_url": null },
"accounts": [
{
"id": 91,
"platform": "TIKTOK",
"handle": "adaclips",
"verification_code": null,
"verified_status": "VERIFIED",
"verified_at": "2026-08-22T09:14:02.000000Z"
}
],
"campaigns": [
{
"id": 37,
"title": "Summer drop, short form",
"description": "Clip the launch stream.",
"accepted_platforms": ["TIKTOK", "INSTAGRAM"],
"content_requirements": "Keep the logo visible.",
"require_demographics": true,
"display_reward_per_1k": "250.0000"
}
],
"bounty_settings_ready": true,
"bounties": [
{
"id": 44,
"title": "Best behind the scenes cut",
"description": "One minute maximum.",
"campaign_id": null,
"campaign_title": null,
"attachment_url": null,
"attachment_name": null,
"winner_slots": 3,
"remaining_slots": 2,
"display_reward_per_winner": "5000.0000"
}
],
"submissions": [
{
"id": 4821,
"campaign_id": 37,
"platform": "TIKTOK",
"social_url": "https://www.tiktok.com/@adaclips/video/7412...",
"status": "APPROVED",
"verified_views": 7500,
"payout_status": "PAID",
"created_at": "2026-08-22T10:03:11.000000Z",
"display_total": "1875.0000",
"completed": true
}
],
"bounty_submissions": [
{
"id": 312,
"bounty_id": 44,
"bounty_title": "Best behind the scenes cut",
"url": "https://www.tiktok.com/@adaclips/video/7419...",
"note": null,
"status": "APPROVED",
"created_at": "2026-08-23T08:44:50.000000Z",
"display_reward": "5000.0000",
"currency_symbol": "SC"
}
]
}
Begins social account verification. Body: platform (TIKTOK, INSTAGRAM, YOUTUBE, X) and url, either a profile URL or a bare handle. Returns the code the member must paste into their public bio.
Bearer bps_ · 10 / minute
{
"account_id": 91,
"platform": "TIKTOK",
"handle": "adaclips",
"code": "ELNEZ-4KQ2"
}
Reads the public bio and confirms the code is there. Returns { "verified": true }. A missing code answers 422 so the member can save the bio and try again.
Bearer bps_ · 10 / minute
Submits a clip. Sent as multipart/form-data because of the screenshot.
Bearer bps_ · 20 / minute
| Field | Rules |
|---|---|
campaign_id | required, must be enabled for your integration and currently live |
platform | required, one of TIKTOK, INSTAGRAM, YOUTUBE, X, FACEBOOK, and accepted by that campaign |
linked_account_id | required, must belong to this member, match the platform, and be VERIFIED |
social_url | required HTTPS URL, max 500, globally unique across all Elnez submissions, host must match the platform |
title | optional, max 255 |
posted_at | optional, not in the future. Defaults to now |
demographics_screenshot | JPEG or PNG, max 5 MB. Required when the campaign sets require_demographics |
acknowledgement | required, must be truthy |
The clip must be submitted within the campaign's posting window, thirty minutes by default, measured from posted_at. Response: 201 with { "submission_id": 4821, "status": "PENDING" }. View collection starts immediately in the background.
Enters an open bounty. JSON body: bounty_id, url, optional note up to 500 characters. Response 201 with { "bounty_submission_id": 312, "status": "PENDING" }.
Bearer bps_ · 20 / minute
A member may hold only one PENDING or APPROVED entry per bounty. Global bounties appear in every plugin automatically; a campaign-specific bounty appears only when that campaign is enabled for your integration. Your Brand cannot create, edit, open, or close bounties: Elnez staff own that surface, and your members simply see the open ones.
Brand settings, from your signed-in Elnez session
These are ordinary web routes on /user/brands/plugin, not part of the token API. They exist here so you know what the buttons on that page do.
| Route | Does |
|---|---|
POST /application | Submits the plugin application |
PATCH /settings | Saves webhook URL, currency, and bounty conversion |
POST /api-keys | Creates a key and shows the plaintext once |
POST /api-keys/{id}/revoke | Revokes a key, deleting nothing |
POST /webhook-secret/rotate | Issues a new signing secret and shows it once |
Display currency and the money model
Elnez stays the single authority on verified views and on USD. Your currency is a presentation layer that Elnez calculates for you and never treats as a second wallet.
Campaigns
When a member submits, Elnez snapshots the campaign's display rate onto that submission. Every later accrual is converted with the snapshot, so a rate change tomorrow never rewrites what was earned yesterday.
display_delta = (usd_delta / campaign_reward_per_1k_usd) * snapshotted_display_reward_per_1k
So a campaign paying $2.00 per 1,000 views, allowlisted to you at 250 SC per 1,000 views, turns a $1.00 accrual into 125.0000 SC.
Bounties
display_delta = usd_paid * snapshotted_bounty_display_units_per_usd
The conversion value is snapshotted at entry, and the webhook is calculated from the exact USD share actually paid, including the rounding remainder that falls to the final winner. The widget shows an estimated per-winner reward before entry and the real figure afterwards.
Who gets paid
- A plugin submission credits the Brand account snapshotted at submission time, in USD, into your Elnez balance. It never credits the external member.
- That payee is immutable. Changing hands, suspending the integration, or losing Premium does not move an existing submission's payout to someone else.
- A direct Elnez creator submission is untouched by any of this and still pays the creator.
- Crediting your member is entirely yours to do, off Elnez, using the webhook figures.
Approval, the 24-hour payout buffer, per-video caps, campaign budget caps, bot filtering, and the review ladder all work exactly as they do for direct creators.
Rate limits
Limits are per minute and keyed by calling IP address, since these endpoints are reached before any Elnez login. The prefix limit applies on top of each route's own limit.
| Endpoint | Limit |
|---|---|
Everything under /api/v1/brand-plugin | 120 / minute |
POST /member-sessions | 30 / minute |
GET /widget/bootstrap | 120 / minute |
POST /widget/bio-code/start and /verify | 10 / minute each |
POST /widget/submissions | 20 / minute |
POST /widget/bounty-submissions | 20 / minute |
Exceeding a limit returns 429 with a Retry-After header. If your traffic legitimately needs more headroom, ask before you engineer around it.
Errors
Errors come back as JSON with a message, and validation failures add an errors object keyed by field.
| Status | Message you will see | What to do |
|---|---|---|
| 401 | A valid Brand Plugin API key is required. | The header is missing, or the key does not start with bpk_ |
| 401 | This Brand Plugin API key is invalid or revoked. | Wrong key, revoked key, or an expiry that has passed |
| 401 | A valid member session is required. | The widget call had no bps_ token |
| 401 | This member session has expired. | Past 15 minutes, or revoked. Mint a new token |
| 403 | This Brand Plugin is paused. | Approval, active status, or Premium is missing. Check all four gates |
| 403 | This origin is not approved. | The origin you sent is not in the approved list. Compare it character by character |
| 403 | This member is blocked. | The member's status is not ACTIVE |
| 404 | Not found | Unknown public key on the embed URL |
| 422 | This campaign is not available in the Brand Plugin. | Not allowlisted for you, or the entry is disabled |
| 422 | This campaign is not accepting submissions. | The campaign is not live, or it requires an application |
| 422 | Verify the matching social account before submitting. | The linked account is unverified, or belongs to another platform |
| 422 | Submit within N minutes of posting. | The posting window has closed for that clip |
| 422 | That social account is already linked to another member. | Handles are globally unique across Elnez |
| 422 | The Brand must finish its bounty reward setting before members can enter. | Save bounty_display_units_per_usd |
| 422 | You already have a pending or approved entry for this bounty. | One live entry per bounty per member |
| 429 | Too Many Requests | Back off and honour Retry-After |
Suspension and Premium lapse
Both states behave the same way, and neither destroys anything.
| Behaviour | While paused |
|---|---|
| New member sessions | Refused with 403 |
| New clip and bounty submissions | Refused with 403 |
| Bio-code verification | Refused with 403 |
| The widget bootstrap | Still answers, with read_only: true |
| Existing members, submissions, and history | Kept and readable |
| USD already being earned | Continues to settle to your Brand account |
| Queued webhooks | Continue to deliver |
Restoring Premium, or an owner lifting a suspension, resumes the same integration with the same public key, keys, members, and history. Nothing needs rebuilding.
Go-live checklist
- The API key lives in your server environment, never in a repository, a build artefact, or browser code.
- Your session route is behind your own authentication, so a visitor cannot mint a token for someone else's member ID.
- The
originyou send matches an approved origin exactly. - Your
postMessagecall names the Elnez origin explicitly, and your listener checksevent.origin. - Your webhook handler verifies the signature over the raw body with a constant-time comparison, and rejects anything that fails.
- Your handler is idempotent on the event ID and returns 200 before doing slow work.
- You handle negative
display_deltavalues, so a clawback does not leave a member over-credited. - Your webhook host is public HTTPS on port 443 and resolves to a public address.
- You have decided what happens on your side when Premium lapses.
- You have a plan for rotating the webhook secret and the API key.