On this page and tools
Partner program
Partner events
Send the events your own product records to Bynefit, in batches, and read the totals back. This is how a partner sees how people use its listings, pages or app, with totals by event and by category in the partner console.
What you need first
A manager of your Space sets these up on the Keys page and the Manage events tab of the Events page of the partner console at /dash/partner/keys. Each change works straight away.
| You need | What it is |
|---|---|
| A partner key | Starts with pbk_. It says who you are and is safe to put in a web page. It is not a secret. |
| Registered events | The event names you may send, and the details each may carry. Anything else is refused. |
| A registered website | Only for sending from a browser. The address a page is served from, like https://example.com. |
| A signing secret | Only for sending from a server or app. It is shown once when it is made. Lost it? Make a new one on the Keys page. |
| Program status | draft while you set up (test events work), live once agreed, paused to stop everything. A paused program accepts nothing. |
Three ways to send
| Way | Use it from | How it proves it is you | Endpoint |
|---|---|---|---|
| Browser | Your own web pages | The partner key plus the page's address, which must be registered. | POST /dash/api/v1/beacons/collect |
| Signed | Your server or app backend | An HMAC signature made with your signing secret. | POST /dash/api/v1/beacons/collect |
| Token | Your own servers, if you prefer an API key | A Space API key that has been given the permission to send product events. | POST /dash/api/v1/beacons |
The request body
Every way takes the same JSON body: an object with an events list of 1 to 100 events, up to 256 KB in all.
| Field | Required | What to put |
|---|---|---|
id | Yes | A unique id you make up for this event, 6 to 64 characters. Send the same id again and it is ignored, so retries never double count. |
name | Yes | One of your registered event names, or a standard event type name. Not needed when you send event_id or event_type. |
event_type | No | A standard event type, by name like listing_viewed or by its number. Use it instead of name, or with your own name to say which type it is. |
event_id | No | The number of one of your registered events, shown under Manage events. Use it instead of name. |
at | Yes | When it happened, like 2026-10-10T14:03:22Z. No more than 5 minutes ahead or 14 days behind. |
user_ref | No | Your own id for the person, like u_8f3a. Never an email address or phone number. |
session_ref | No | Your own id for the visit, like s_41c2. |
props | No | Extra details, only the properties listed for that event, with the type shown. |
{
"events": [
{
"id": "evt-20261010-0001",
"name": "listing_viewed",
"at": "2026-10-10T14:03:22Z",
"user_ref": "u_8f3a",
"session_ref": "s_41c2",
"props": { "category": "food", "count": 3 }
}
]
}
Rules every event is checked against
- Name: one you registered, or a standard event type (see below). Names are 2 to 48 characters: lowercase letters, numbers and underscores, starting with a letter. A partner can have up to 60 events registered. Up to 40 of them can be added automatically, and no more than 20 in one day. Up to 25 unknown names from web pages wait for approval at once, and a name nobody sends for 30 days drops off the list.
- Id: 6 to 64 characters, letters, numbers and
_ . : -, starting with a letter or number. It is unique per partner, so sending the same id again is counted as a duplicate and never stored twice. - Time: ISO 8601 with
Zor an offset, no more than 5 minutes in the future and no more than 14 days in the past. - Refs:
user_refandsession_refare your own ids, up to 64 characters. Never an email address or a phone number. - Details:
propsmay only hold properties registered for that event, with the registered type (string,numberorboolean). Text is at most 120 characters. You add and remove details yourself under Manage events, up to 10 per event; a new one is accepted from the next event you send. - Size: one event is at most 4 KB.
- Personal data: a property name that would carry personal data (email, phone, name, address, location, birth date, children and similar) cannot be registered, and a text value that looks like an email address or phone number is refused.
Event types: standard names that add themselves
Bynefit keeps one list of standard event types for every partner, like listing_viewed, search_performed, signup_completed or order_completed.
Each has a fixed number, a category (views, search, purchases and so on) and other spellings it also accepts, so page_view, pageView and Page View all count as page_viewed.
The full list, with numbers and the details each type can carry, is under Manage events in the partner console at /dash/partner/events.
| You send a name that is not registered yet | What happens |
|---|---|
| Signed, or with an API key | It is added to your events and accepted. A standard name is grouped under its type and gets that type's details; any other name is added as uncategorized with no details, until you or Bynefit give it a type. |
| From a web page | It is refused with pending_approval and waits under Manage events. A manager or owner of your Space approves it there. Web pages never add events on their own, because anyone can copy a partner key. |
| Past a limit | Refused with event_limit or auto_register_limit, and counted. |
Once an event is registered you can also send it by number. event_id is the number of your registered event, shown under Manage events.
event_type is a standard type, by name or number: on its own it picks your event of that type, and next to your own name it says which type that name is.
name keeps working exactly as before. Bynefit stores the number either way, so totals stay right if a label changes.
{ "id": "evt-1", "name": "listing_viewed", "at": "2026-10-10T14:03:22Z" }
{ "id": "evt-2", "event_type": "listing_viewed", "at": "2026-10-10T14:03:22Z" }
{ "id": "evt-3", "name": "viewed_shop", "event_type": "listing_viewed", "at": "2026-10-10T14:03:22Z" }
Send from a web page
Your page's address must be registered. The browser sends it for you, and Bynefit only answers pages on your list. Add a new address in the console under Your websites. It works straight away.
const response = await fetch("https://bynefit.com/dash/api/v1/beacons/collect", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Partner-Key": "pbk_YOUR_PARTNER_KEY"
},
body: JSON.stringify({
events: [{
id: crypto.randomUUID(),
name: "listing_viewed",
at: new Date().toISOString(),
props: { "category": "sample", "count": 1 }
}]
})
});
console.log(response.status, await response.json());
A browser that is closing the page can use navigator.sendBeacon. It cannot set headers, so the partner key goes in the address as ?key=,
and the body is sent as plain text so the browser does not need a preflight. The page address is checked the same way.
const body = JSON.stringify({
events: [{
id: crypto.randomUUID(),
name: "listing_viewed",
at: new Date().toISOString(),
props: { "category": "sample", "count": 1 }
}]
});
const queued = navigator.sendBeacon(
"https://bynefit.com/dash/api/v1/beacons/collect?key=pbk_YOUR_PARTNER_KEY",
new Blob([body], { type: "text/plain" })
);
console.log(queued ? "Queued" : "The browser refused it");
Some browsers print a cross-origin error in the console for a beacon that was in fact delivered. Check your totals, not the console.
Send from a server or app
Sign every request. The signature is the HMAC-SHA256 of the Unix time in seconds, a literal dot, and the exact body you send, keyed with your signing secret, written as lowercase hex.
- Take the time nowSeconds since 1970. It must be within 5 minutes of Bynefit's clock, so keep your server's clock synced.
- Write the bodySend exactly the characters you sign. Do not re-serialize between signing and sending.
- Join themThe time, a dot, then the body:
1760105000.{"events":[...]}. - Sign itHMAC-SHA256 with the signing secret, lowercase hex.
- Send three headers
X-Partner-Key,X-Beacon-TimestampandX-Beacon-Signature, withContent-Type: application/json.
URL="https://bynefit.com/dash/api/v1/beacons/collect"
KEY="pbk_YOUR_PARTNER_KEY"
SECRET="YOUR_SIGNING_SECRET"
TS=$(date +%s)
ID="evt-$TS-$RANDOM"
AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
BODY=$(cat <<EOF
{"events":[{"id":"$ID","name":"listing_viewed","at":"$AT","props":{"category":"sample","count":1}}]}
EOF
)
SIGNATURE=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -i -X POST "$URL" \
-H "Content-Type: application/json" \
-H "X-Partner-Key: $KEY" \
-H "X-Beacon-Timestamp: $TS" \
-H "X-Beacon-Signature: $SIGNATURE" \
-d "$BODY"import { createHmac, randomUUID } from "node:crypto";
const url = "https://bynefit.com/dash/api/v1/beacons/collect";
const partnerKey = "pbk_YOUR_PARTNER_KEY";
const signingSecret = "YOUR_SIGNING_SECRET";
const body = JSON.stringify({
events: [{
id: randomUUID(),
name: "listing_viewed",
at: new Date().toISOString(),
props: { "category": "sample", "count": 1 }
}]
});
const timestamp = String(Math.floor(Date.now() / 1000));
const stringToSign = `${timestamp}.${body}`;
const signature = createHmac("sha256", signingSecret).update(stringToSign).digest("hex");
const headers = {
"Content-Type": "application/json",
"X-Partner-Key": partnerKey,
"X-Beacon-Timestamp": timestamp,
"X-Beacon-Signature": signature
};
const response = await fetch(url, { method: "POST", headers, body });
console.log(response.status, await response.json());import hashlib
import hmac
import json
import time
import urllib.error
import urllib.request
import uuid
from datetime import datetime, timezone
url = "https://bynefit.com/dash/api/v1/beacons/collect"
partner_key = "pbk_YOUR_PARTNER_KEY"
signing_secret = "YOUR_SIGNING_SECRET"
body = json.dumps({
"events": [{
"id": str(uuid.uuid4()),
"name": "listing_viewed",
"at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"props": { "category": "sample", "count": 1 }
}]
}, separators=(",", ":"))
timestamp = str(int(time.time()))
string_to_sign = timestamp + "." + body
signature = hmac.new(signing_secret.encode(), string_to_sign.encode(), hashlib.sha256).hexdigest()
headers = {
"Content-Type": "application/json",
"X-Partner-Key": partner_key,
"X-Beacon-Timestamp": timestamp,
"X-Beacon-Signature": signature,
}
request = urllib.request.Request(url, data=body.encode(), headers=headers, method="POST")
try:
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
except urllib.error.HTTPError as error:
print(error.code, error.read().decode())<?php
$url = "https://bynefit.com/dash/api/v1/beacons/collect";
$partnerKey = "pbk_YOUR_PARTNER_KEY";
$signingSecret = "YOUR_SIGNING_SECRET";
$body = json_encode([
"events" => [[
"id" => bin2hex(random_bytes(16)),
"name" => "listing_viewed",
"at" => gmdate('Y-m-d\TH:i:s\Z'),
"props" => ["category" => "sample", "count" => 1]
]]
], JSON_UNESCAPED_SLASHES);
$timestamp = (string) time();
$stringToSign = $timestamp . "." . $body;
$signature = hash_hmac("sha256", $stringToSign, $signingSecret);
$headers = [
"Content-Type: application/json",
"X-Partner-Key: " . $partnerKey,
"X-Beacon-Timestamp: " . $timestamp,
"X-Beacon-Signature: " . $signature,
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($ch);
echo curl_getinfo($ch, CURLINFO_RESPONSE_CODE), " ", $response, "\n";import Foundation
import CryptoKit
let url = URL(string: "https://bynefit.com/dash/api/v1/beacons/collect")!
let partnerKey = "pbk_YOUR_PARTNER_KEY"
let signingSecret = "YOUR_SIGNING_SECRET"
let event: [String: Any] = [
"id": UUID().uuidString,
"name": "listing_viewed",
"at": ISO8601DateFormatter().string(from: Date()),
"props": ["category": "sample", "count": 1]
]
let body = try JSONSerialization.data(withJSONObject: ["events": [event]])
let timestamp = String(Int(Date().timeIntervalSince1970))
let stringToSign = Data((timestamp + ".").utf8) + body
let secretKey = SymmetricKey(data: Data(signingSecret.utf8))
let signature = HMAC<SHA256>.authenticationCode(for: stringToSign, using: secretKey)
.map { String(format: "%02x", $0) }
.joined()
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(partnerKey, forHTTPHeaderField: "X-Partner-Key")
request.setValue(timestamp, forHTTPHeaderField: "X-Beacon-Timestamp")
request.setValue(signature, forHTTPHeaderField: "X-Beacon-Signature")
let (data, response) = try await URLSession.shared.data(for: request)
print((response as! HTTPURLResponse).statusCode, String(decoding: data, as: UTF8.self))
Check your code against a known answer. With the secret YOUR_SIGNING_SECRET, the time 1760105000 and this body, the signature below is what Bynefit expects.
body {"events":[{"id":"7b1c0e5e-3f2a-4c1d-9a7e-1d2f3a4b5c6d","name":"listing_viewed","at":"2026-10-10T14:03:22Z","props":{"category":"sample","count":1}}]}
to sign 1760105000.{"events":[{"id":"7b1c0e5e-3f2a-4c1d-9a7e-1d2f3a4b5c6d","name":"listing_viewed","at":"2026-10-10T14:03:22Z","props":{"category":"sample","count":1}}]}
signature 078742092221cdbb978d50900d652de4b4b6487ffc2572983526657aba9f0444
Send with an API key
A Space API key that holds the permission to send product events can post to the token endpoint instead. The key is made on /dash/integrations/api-tokens. This permission is sensitive: wildcards never grant it and only a manager or admin of the partner Space can hold it. On a partner Space it works as soon as the key is made, and Bynefit is told; other Spaces cannot use it. See Keys and review.
| Request | |
|---|---|
| Method and path | POST /dash/api/v1/beacons |
| Needs | A Space API key from a partner Space, sent as a bearer token |
| Permission | write:beacons |
curl -X POST https://bynefit.com/dash/api/v1/beacons \
-H "Authorization: Bearer bapi_tk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"events":[{"id":"evt-20261010-0001","name":"listing_viewed","at":"2026-10-10T14:03:22Z","user_ref":"u_8f3a","props":{"category":"food","count":3}}]}'const res = await fetch("https://bynefit.com/dash/api/v1/beacons", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.BYNEFIT_TEAM_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify({
events: [{
id: crypto.randomUUID(),
name: "listing_viewed",
at: new Date().toISOString(),
user_ref: "u_8f3a",
props: { category: "food", count: 3 }
}]
})
});
console.log(res.status, await res.json());$ch = curl_init("https://bynefit.com/dash/api/v1/beacons");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("BYNEFIT_TEAM_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode(["events" => [[
"id" => bin2hex(random_bytes(16)),
"name" => "listing_viewed",
"at" => gmdate('Y-m-d\TH:i:s\Z'),
"user_ref" => "u_8f3a",
"props" => ["category" => "food", "count" => 3],
]]]),
]);
$response = curl_exec($ch);
echo curl_getinfo($ch, CURLINFO_RESPONSE_CODE), " ", $response, "\n";Token calls are not billed to your API credits and are not counted against the browser limits. They go through the same rate limit as every other API key.
What comes back
A successful request is always 202. An event that is refused is listed with its reason, and the status stays 202, so read the body.
| Status | Meaning | Body | What to do |
|---|---|---|---|
202 | Received | {"accepted":1,"duplicates":0,"rejected":[]} | Done. Look for the event in your totals. |
202 | Received, but some events were refused | {"accepted":0,"duplicates":0,"rejected":[{"index":0,"id":"evt-1","reason":"unknown_event"}]} | Read the reason on each refused event. An event name that is not registered is refused here, with a 202, not an error status. |
401 | Key or signature not accepted | {"error":"missing_or_invalid_key"} or {"error":"bad_signature"} | Check the partner key, the signing secret, the exact string you signed and that your clock is within 5 minutes. |
403 | Not allowed | {"error":"origin_not_allowed"} or {"error":"partner_paused"} | For a browser, your website address must be registered. A paused program accepts nothing. |
413 | Too large | {"error":"body_too_large"} or {"error":"batch_too_large"} | Send fewer events per request, and keep a request under 256 KB. |
422 | Body not understood | {"error":"events_required"} | Send {"events": [ ... ]} with at least one event. |
429 | Slow down | {"error":"rate_limited"} and a Retry-After header | Wait the number of seconds in Retry-After, then send the same events again. Reused ids are ignored, so nothing counts twice. |
{
"accepted": 1,
"duplicates": 0,
"rejected": [
{ "index": 1, "id": "evt-20261010-0002", "reason": "bad_property" }
]
}
Reasons an event is refused
| Reason | Meaning |
|---|---|
bad_event | Event is not an object |
bad_id | Missing or malformed id |
unknown_event | Event is not registered for this partner |
pending_approval | Event name from a web page is not registered yet and is waiting for approval in the partner console |
event_limit | This partner has reached its limit of registered events |
auto_register_limit | Too many new event names were added automatically; add the ones you want under Manage events in the console |
temporarily_unavailable | Busy adding this event name; send the same event again |
bad_time | Timestamp missing, malformed, too old or in the future |
bad_ref | user_ref or session_ref is malformed |
bad_property | Property is not registered for this event or has the wrong type |
pii_suspected | Value looks like personal data |
too_large | Event is too large |
Errors specific to the token endpoint
| Status | Body | Meaning |
|---|---|---|
401 | {"error":"missing_or_invalid_token"} | No key, a wrong key, or a revoked one. token_revoked and token_expired are returned for those two cases. |
403 | {"error":"insufficient_scope","required":"write:beacons"} | The key does not hold the permission to send product events. |
403 | {"error":"not_a_partner"} | The key's Space is not enrolled as a partner. |
405 | {"error":"method_not_allowed"} | Use POST. |
Test without saving
Add "test": true next to "events". The events are checked exactly as for a real request and the answer is the same 202 with "test": true,
but nothing is stored or counted. Rate limits still apply.
{ "test": true, "events": [ { "id": "test-0001", "name": "listing_viewed", "at": "2026-10-10T14:03:22Z" } ] }
The console's Send a test event button does the same with one made-up event. It does not exercise the signature or the website check, so send one signed or browser test as well.
Limits
| Limit | Value | Applies to |
|---|---|---|
| Events in one request | 100 | Every way of sending |
| Size of one request | 256 KB | Every way of sending |
| Events per minute | 1,200 | Everything you send, added together |
| Events per hour | 50,000 | Everything you send, added together |
| Events per minute from one visitor | 120 | Web pages only |
| Requests per minute from one visitor | 60 | Web pages and anything unsigned |
| Clock difference on a signature | 5 minutes | Signed requests |
| Age of an event | 5 minutes ahead, 14 days behind | The at field |
If you are told to slow down, wait the number of seconds in Retry-After and send the same events again. Reused ids are ignored, so nothing counts twice.
Read your totals
| Request | |
|---|---|
| Method and path | GET /dash/api/v1/beacons/summary?days=30 |
| Needs | A Space API key from a partner Space that holds the permission to read event totals |
| Permission | read:beacons |
days is 1 up to your retention period (never less than 90) and defaults to 30.
curl "https://bynefit.com/dash/api/v1/beacons/summary?days=7" \
-H "Authorization: Bearer bapi_tk_YOUR_KEY"const res = await fetch("https://bynefit.com/dash/api/v1/beacons/summary?days=7", {
headers: { Authorization: "Bearer " + process.env.BYNEFIT_TEAM_KEY }
});
const { summary, funnel, credits } = await res.json();
console.log(summary.accepted, summary.events);$ch = curl_init("https://bynefit.com/dash/api/v1/beacons/summary?days=7");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("BYNEFIT_TEAM_KEY")],
]);
$data = json_decode(curl_exec($ch), true);
echo $data["summary"]["accepted"], "\n";{
"partner": { "code": "example", "status": "live" },
"summary": {
"days": 7,
"accepted": 412,
"users": 96,
"events": [
{ "name": "listing_viewed", "count": 390, "last_seen": "2026-10-10 14:03:22",
"event_id": 12, "type": "listing_viewed", "type_label": "Listing viewed",
"category": "navigation", "category_label": "Views" }
],
"today": { "accepted": 58, "rejected": 1, "top_reject": "bad_property" },
"rejects": { "bad_property": 3 }
},
"funnel": { "spaces": 4, "bought": 1 },
"credits": { "accrued_cents": 0, "paid_cents": 0, "orders": 0 }
}
event_idis your registered event's number;typeandcategoryare the standard type and its category, ornullwhen the event is uncategorized.usersis the number of distinctuser_refvalues you sent, not a count of Bynefit accounts.funnel.spacesis how many Spaces were started with your partner code, andfunnel.boughthow many of those paid for a service.creditsare in cents. A partner credit never raises what the customer pays.- Responses are never cached.
Partner code on new Spaces
When your app creates a Space for someone, send partnerCode (and optionally partnerSource) in the create-Space request.
The first partner to touch a Space keeps it. A paused or unknown code, your own Space, or a creator who is already a member of your Space is ignored.
Bynefit staff switch this on for your program; until they do, no Space is attributed to you.
Your partner link, https://bynefit.com/via/your-code, does the same for people who sign up on the web: add ?s=poster to tell links apart. It remembers your code for 180 days. Anyone who manages the Space can see who referred it, the person who created it is emailed once, and an admin can detach it at any time, after which you stop seeing it.
Planned A QR code for your link, and a way to register a business from your own app, are planned.
Privacy and retention
- Events are kept for a retention period you choose (default 90 days), then deleted. Your console shows the period in force and the oldest event we hold, and you can change it there.
- Browser and signed requests store a keyed hash of the visitor's IP address, never the address itself, and it is cleared from your events after 30 days. Token requests store none.
- The owner of your Space can delete all of your events from the Keys page at any time. It runs 24 hours later, so any manager can cancel a mistake first. At the end of the agreement Bynefit deletes them for you.
- Separately, Bynefit keeps one-way hashed signals for up to 13 months: a keyed hash of the sending IP address and its network range, keyed hashes of the user and session references and the source, campaign, medium and referrer values you send (scoped to your partner account, never the values themselves), and daily counts per event name. We use them to prevent fraud, spam and abuse and to improve the partner program. They carry no raw property values or other contents, and they are kept even after you shorten retention or erase your events.
Security notes
- The website check stops other websites from sending as you inside a visitor's browser. It does not prove who made a request, because anything outside a browser can claim any origin. Signed requests are the only way to be sure.
- Keep the signing secret and API keys on servers. Rotate the secret from the console if it is ever exposed.
- Only the registered website is allowed through the browser's cross-origin check, so your own pages can read the answer.