Server-to-server (S2S) API
Send events to Reflect from your backend — verified purchases, subscription renewals, server-side game logic, web apps without the SDK.
When to use S2S vs the SDK
| Use case | SDK | S2S |
|---|---|---|
| Native installs, sessions, ad signals | ✅ | — |
| Tutorial / level / share — anything inside the game | ✅ | — |
| Verified purchases (after server-side receipt validation) | — | ✅ |
| Subscription renewals / cancellations from billing webhook | — | ✅ |
| Backfilling historical data | — | ✅ (bulk) |
| Web app with no Unity client | — | ✅ |
Both can run side-by-side. Use the same install_uuid in both calls and Reflect stitches the events to the same identity.
Mint a server key
Sign in to the admin panel → API & S2S keys → fill the form. Pick:
- App — the key is scoped to one app.
- Label — human name like
ci-purchasesorbilling-webhook. - Allowed events — comma-separated event names. Leave blank for "all events". Useful so a leaked key can only fire
purchase, notapp_install.
The plain-text key is shown once after creation. Copy it into your CI / server env var; you can't recover it later. Format: srv_<48 hex chars> (~192 bits of entropy).
Authentication
Bearer token in the Authorization header:
Authorization: Bearer srv_<your_key>No HMAC over the body — the bearer token is enough. (HMAC made sense for the SDK because client secrets ship in app binaries; server keys live in env vars and never reach a client.)
POST /s2s/event — single event
Required fields: install_uuid, event_ts_ms, event_name. Everything else is optional.
POST https://reflect.bablu147147.workers.dev/s2s/event
Authorization: Bearer srv_...
Content-Type: application/json
{
"event_id": "txn_abc123",
"event_name": "purchase",
"event_ts_ms": 1735999999000,
"install_uuid": "8f2a1c0e94d7423b8b53af7c9e21d630",
"user_id": "user_42",
"revenue": 9.99,
"currency": "USD",
"transaction_id": "txn_abc123",
"product_id": "sku_pro_pack",
"props": {
"channel": "web_checkout",
"ab_variant": "B"
}
}Response (200):
{
"accepted": 1,
"event_id": "txn_abc123",
"audit_key": "s2s/2026-04-26/txn_abc123.json"
}curl
curl -X POST https://reflect.bablu147147.workers.dev/s2s/event \
-H "Authorization: Bearer $REFLECT_S2S_KEY" \
-H "Content-Type: application/json" \
-d '{
"event_name": "purchase",
"event_ts_ms": '"$(date +%s)000"',
"install_uuid": "8f2a1c0e94d7423b8b53af7c9e21d630",
"user_id": "user_42",
"revenue": 9.99,
"currency": "USD",
"transaction_id": "txn_abc"
}'Python
import os, time, uuid, requests
def send(event_name: str, install_uuid: str, **fields):
body = {
"event_id": str(uuid.uuid4()).replace("-", ""),
"event_name": event_name,
"event_ts_ms": int(time.time() * 1000),
"install_uuid": install_uuid,
**fields,
}
r = requests.post(
"https://reflect.bablu147147.workers.dev/s2s/event",
headers={"Authorization": f"Bearer {os.environ['REFLECT_S2S_KEY']}"},
json=body, timeout=10,
)
r.raise_for_status()
return r.json()
# After verifying an iOS receipt server-side:
send("purchase",
install_uuid="8f2a1c0e94d7423b8b53af7c9e21d630",
user_id="user_42",
revenue=9.99, currency="USD",
transaction_id="apple_tx_xyz")Node.js
async function trackS2S(eventName, installUuid, fields = {}) {
const body = {
event_id: crypto.randomUUID().replace(/-/g, ""),
event_name: eventName,
event_ts_ms: Date.now(),
install_uuid: installUuid,
...fields,
};
const r = await fetch("https://reflect.bablu147147.workers.dev/s2s/event", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.REFLECT_S2S_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`Reflect S2S ${r.status}: ${await r.text()}`);
return r.json();
}PHP
function reflect_s2s(string $event, string $installUuid, array $fields = []): array {
$body = array_merge([
"event_id" => bin2hex(random_bytes(16)),
"event_name" => $event,
"event_ts_ms" => (int)(microtime(true) * 1000),
"install_uuid" => $installUuid,
], $fields);
$ch = curl_init("https://reflect.bablu147147.workers.dev/s2s/event");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("REFLECT_S2S_KEY"),
"Content-Type: application/json",
],
]);
$resp = curl_exec($ch);
if (curl_errno($ch)) throw new RuntimeException(curl_error($ch));
return json_decode($resp, true);
}POST /s2s/events/bulk — up to 1000 events
Designed for backfills and high-volume webhook fan-out. The endpoint enqueues each event to the same ingest queue used by the SDK — async, no extra D1 row writes per event.
POST https://reflect.bablu147147.workers.dev/s2s/events/bulk
Authorization: Bearer srv_...
Content-Type: application/json
{
"events": [
{ "event_name": "purchase", "event_ts_ms": ..., "install_uuid": "...", ... },
{ "event_name": "purchase", "event_ts_ms": ..., "install_uuid": "...", ... },
... up to 1000 events ...
]
}Response includes per-event accept/reject:
{
"accepted": 998,
"rejected": 2,
"results": [
{ "event_id": "...", "status": "accepted" },
{ "event_id": "...", "status": "rejected", "reason": "missing_required_fields" },
...
],
"audit_key": "s2s/bulk/2026-04-26/<uuid>.json"
}Idempotent on event_id — resubmitting the same events drops duplicates server-side. Re-send only the rejected items.
Errors
| Status | Reason |
|---|---|
| 400 | install_uuid_required, event_ts_ms_required, event_name_required, bad_json, too_many_events (bulk >1000) |
| 401 | missing_bearer_token, unknown_or_revoked_key |
| 403 | event_not_allowed_by_key (event not in the key's allow-list) |
| 413 | body_too_large |
| 429 | cap_exceeded (tenant's plan quota exhausted) |
install_uuid — where to get it
The SDK generates and persists an install_uuid on first launch. You need to send it from the client to your server when the user authenticates, then store it alongside the user's account record. After that your backend can fire S2S events tied to the same identity.
// Client (Unity)
StartCoroutine(SendInstallUuidToBackend(ReflectSDK.InstallUuid));
// Server (Python example)
@app.route("/api/auth/me")
def me(user):
user.reflect_install_uuid = request.json["install_uuid"]
user.save()
return jsonify(ok=True)Cost & rate limits
S2S events count toward the same per-tenant monthly quota as SDK events (see Plans). No separate per-key rate limit beyond Cloudflare's native protection. If you're hitting your cap, upgrade or contact us.
Security tips
- Store the key in your CI / server env var. Never commit it to source.
- Use the allowed events list to scope each key. A
billing-webhookkey only needspurchase, subscribe, subscription_renewed, subscription_cancelled, subscription_refunded. - Mint one key per role (one for CI, one for billing webhook, one for backfills) — easier to revoke when something leaks.
- Rotate keys when team members leave: revoke + mint a new one.
- Reflect stores SHA-256 hash, not the plaintext — even DB exfil can't recover live keys.