Brand Plugin API
The Brand Plugin puts campaigns and bounties inside your own product. Use the Elnez-paid path when Elnez is responsible for the reward, or the tracking-only path when another platform pays the member. Both paths use the same Plugin, Premium plan, member session, social-account verification, and view tracking.
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 available work, and adds a clip or tracking-only post. - Your webhook endpoint receives signed earnings events for campaigns and bounties paid through Elnez.
Choose a payment mode for each campaign
The Brand Plugin supports both models without creating a second integration.
| Mode | What members see | What Elnez does | Who pays |
|---|---|---|---|
| Paid through Elnez | Campaigns enabled for your integration and open bounties created by Elnez staff | Collects the submission, measures verified views, applies fraud controls, credits your Brand account, and sends signed earnings webhooks | Elnez pays your Brand account. Your system then credits the member |
| Tracking only | Outside-paid campaigns submitted by your Brand and approved by Elnez | Measures verified views from the campaign baseline and shows performance history | The outside platform pays the member |
Access requirements
The Plugin remains available while all four requirements are met. If one is missing, new member sessions and submissions pause while existing history remains available.
| Requirement | 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 |
| Active integration | Your integration is ACTIVE, not SUSPENDED | Elnez |
| Current Premium | Your Premium subscription has not lapsed | You |
Plugin access requires both an approved application and an active Premium subscription.
Setup, end to end
- Apply from your Brand accountSign in and open /user/brands/plugin. One application may be active per Brand account.
- Wait for reviewWhen approved, you receive an email and your public key and webhook signing secret become available. If rejected, the email includes 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.
- Add the work your members should seeFor Elnez-paid campaigns, request campaign access and set the display reward. For outside-paid work, submit a tracking-only campaign from the same Plugin page and wait for approval.
- Connect your backend and webhookComplete the member-session, widget, and webhook setup described below.
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 | Your public website |
platform_type | WEB, MOBILE or BOTH | Where you will embed the Plugin |
allowed_origins | 1 to 10 HTTPS origins, no path, query or fragment | Defines where the widget may be embedded. Every member session must name one of these origins exactly |
android_package_name | optional, max 255 | Your Android application identifier |
ios_bundle_id | optional, max 255 | Your iOS application identifier |
audience_size | required integer, 1 or more | Your total audience size |
monthly_active_users | required integer, 1 or more | Your monthly active members |
use_case | required, 30 to 3,000 characters | How you plan to use the Plugin |
onboarding_mode | API, WIDGET or BOTH | Declares how you plan to integrate |
webhook_url | required, HTTPS, max 500, passes the URL guard below | Receives earnings events after approval |
Origins are matched after lowercasing and trailing-slash removal, and duplicates are ignored. https://App.YourBrand.com/ and https://app.yourbrand.com are the same entry. A pending or approved application blocks a second submission, so make any changes before applying.
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 |
The API key is shown once at creation, and the webhook secret is shown once at creation or rotation. Afterwards, the Plugin page displays only a masked value.
Never put the API key or a member token in a URL, a query string, or client-side JavaScript. Keep the API key on your server.
Revoking an API key takes effect on the next request without affecting existing members or history. Rotating the webhook secret invalidates the previous secret immediately, so deploy the new secret before rotating it. The API key scope 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
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 });
});
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.
- 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.
Social account verification
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>
The widget renders only on your approved origins. A public key alone cannot be used to embed it on another site.
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;
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. Never use '*' as the target.
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.
- Open an approved outside campaign, add the resulting social post for tracking, and see measured views without an Elnez reward.
- Track their own paid submission history, running balance, and tracking-only post history.
Webhooks: how delivery works
Elnez sends a signed earnings event when an Elnez-paid campaign or bounty changes a member's credited amount.
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. Capture the body before your framework parses it.
const crypto = require('crypto');
app.post('/webhooks/elnez',
express.raw({ type: 'application/json' }),
(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);
}
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
if (alreadyHandled(eventId)) return res.sendStatus(200);
creditMember(event.data);
markHandled(eventId);
res.sendStatus(200);
});
$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);
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. Parse them with a decimal type, not a float.
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. - Recorded credits remain unchanged. A delivery failure does not reverse a credit.
- Replay. Contact Elnez support with the event ID to resend a failed delivery after your endpoint is working again.
Verify the signature, record the event, return 200, and complete longer processing in a background job.
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, currency, linked accounts, Elnez-paid campaigns, approved tracking-only campaigns, open bounties, and both kinds of 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"
}
],
"external_tracking_enabled": true,
"external_campaigns": [
{
"id": 12,
"title": "Outside creator campaign",
"external_campaign_url": "https://campaign.example/work/launch",
"description": "Payment stays on the campaign platform.",
"accepted_platforms": ["TIKTOK", "INSTAGRAM"],
"content_requirements": "Keep the product visible.",
"starts_at": "2026-08-25T00:00:00.000000Z",
"ends_at": "2026-09-15T23:59:59.000000Z"
}
],
"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
}
],
"tracked_posts": [
{
"id": 704,
"external_campaign_id": 12,
"campaign_title": "Outside creator campaign",
"platform": "TIKTOK",
"social_url": "https://www.tiktok.com/@adaclips/video/7420...",
"status": "TRACKING",
"verified_views": 8300,
"baseline_views": 1200,
"measured_views": 7100,
"last_fetched_at": "2026-08-26T14:00:00.000000Z",
"created_at": "2026-08-25T10:03:11.000000Z"
}
],
"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" }.
Adds a member's social post to an approved tracking-only campaign for view measurement. Send JSON.
Bearer bps_ · 20 / minute
| Field | Rules |
|---|---|
external_campaign_id | required, must belong to this integration, be approved, and be within its optional start and end dates |
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, unique across Elnez-paid submissions and tracking-only posts, host must match the platform |
title | optional, max 255 |
posted_at | optional, not in the future. Defaults to now |
acknowledgement | required, must be truthy |
Response: 201 with { "tracked_post_id": 704, "status": "TRACKING" }. The first verified view count becomes the baseline when the post predates the campaign start; otherwise the baseline is zero. The bootstrap returns both the verified total and the measured views above that baseline.
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. Bounties are created and managed by Elnez staff.
Display currency and the money model
Elnez calculates verified views and USD earnings for Elnez-paid work. The widget presents those earnings in your configured display currency.
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
- Elnez-paid Plugin submissions credit your Brand account in USD.
- Use the signed earnings webhook to update the member's balance in your system.
- Tracking-only campaign payments remain on the outside platform.
Tracking-only campaigns
Use Elnez to view measured performance for tracking-only campaigns and use the outside platform for their payment records. A Brand may use Elnez-paid campaigns, tracking-only campaigns, or both.
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/tracked-posts | 20 / minute |
POST /widget/bounty-submissions | 20 / minute |
Exceeding a limit returns 429 with a Retry-After header. Contact Elnez support if you need a higher limit.
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 | This tracking-only campaign is not available. | It does not belong to this integration, has not been approved, or has been paused or rejected |
| 422 | This tracking-only campaign has not started yet. | Wait until its configured start time |
| 422 | This tracking-only campaign has ended. | The configured tracking window is closed |
| 422 | This tracking-only campaign does not accept that platform. | Choose one of the platforms approved for that campaign |
| 422 | Verify the matching social account before submitting. | The linked account is unverified, or belongs to another platform |
| 422 | Verify the matching social account before adding this post. | The tracking-only post used an unverified account, another member's account, or the wrong 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, tracked-post, and bounty submissions | Refused with 403 |
| Bio-code verification | Refused with 403 |
| The widget bootstrap | Still answers, with read_only: true |
| Existing members, paid submissions, tracked posts, and history | Kept and readable |
| USD already being earned | Continues to settle to your Brand account |
| Queued webhooks | Continue to deliver |
Restoring Premium, or Elnez lifting a suspension, resumes the same integration with the same public key, keys, members, and history.
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.
- Your system uses the outside platform for tracking-only campaign payments.
- Your members understand that an outside-paid campaign is measured by Elnez but paid by the outside platform.
- 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.
- Your integration shows members a clear unavailable state when Premium lapses.
- You have a plan for rotating the webhook secret and the API key.