Generic Webhook Firehose
Not Segment-shaped, not Amplitude-shaped, not an export from a named vendor — just your own backend's event stream, in your own JSON. POST it here with a small field-mapping config and it lands in the same analysis layer as everything else.
5-minute quickstart
The fastest path needs no HMAC setup at all — a secret key as a bearer token. Get one at drengr.dev/pro (free), then POST a plain JSON array:
curl -X POST function(){throw Error("Attempted to call FN_BASE() from the server but FN_BASE is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.")}/firehose \
-H "Authorization: Bearer drengr_sk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '[
{ "event": "order_completed", "timestamp": "2026-06-01T12:00:00Z", "user_id": "user_123", "anonymous_id": "anon_abc", "revenue": 42.5 }
]'This uses the default field mapping (event / timestamp (ISO 8601) / user_id / anonymous_id / id). If your fields are named differently, pass a mapping — see below.
Endpoint
Body shapes
Any of these three:
A bare JSON array
[
{ "event": "signup", "timestamp": "2026-06-01T12:00:00Z", "anonymous_id": "anon_1" },
{ "event": "purchase", "timestamp": "2026-06-01T12:05:00Z", "user_id": "user_1", "revenue": 9.99 }
]NDJSON — one JSON object per line
curl -X POST function(){throw Error("Attempted to call FN_BASE() from the server but FN_BASE is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.")}/firehose \
-H "Authorization: Bearer drengr_sk_YOUR_KEY" \
-H "Content-Type: application/x-ndjson" \
--data-binary $'{"event":"signup","timestamp":"2026-06-01T12:00:00Z","anonymous_id":"anon_1"}\n{"event":"purchase","timestamp":"2026-06-01T12:05:00Z","user_id":"user_1","revenue":9.99}'A malformed line is skipped and counted — it doesn't fail the rest of the stream.
A wrapper object — the only shape that can carry mapping/app_package in the body
{
"app_package": "com.example.myapp",
"mapping": {
"event_name_field": "type",
"timestamp_field": "occurred_ms",
"timestamp_unit": "ms",
"user_id_field": "uid",
"anon_id_field": "device_id"
},
"events": [
{ "type": "checkout_completed", "occurred_ms": 1750000000000, "uid": "u_9", "device_id": "d_9", "amount": 19.99 }
]
}gzip
Send Content-Encoding: gzipwith any of the body shapes above — it's decompressed server-side before parsing, with a 20MB streaming guard that rejects (413) before the 5MB post-decompression cap is even checked, bounding worst-case memory use against a zip-bomb payload.
gzip -c events.json | curl -X POST function(){throw Error("Attempted to call FN_BASE() from the server but FN_BASE is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.")}/firehose \
-H "Authorization: Bearer drengr_sk_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Content-Encoding: gzip" \
--data-binary @-Mapping config
Pass as body.mapping, or URL-encoded in a ?mapping= query param. Precedence: body wins over query param wins over the default below. Partial overrides fill in the rest from the default.
{
"event_name_field": "event", // default
"timestamp_field": "timestamp", // default
"timestamp_unit": "iso8601", // default — one of iso8601 | seconds | ms | us
"user_id_field": "user_id", // default
"anon_id_field": "anonymous_id", // default
"dedup_id_field": "id", // default
"props_field": undefined // optional — omit to use every unconsumed key as props
}app_package comes from body.app_package or ?app_package=, sanitized to [a-zA-Z0-9._-], defaulting to firehose.
Auth
Two paths, tried in this order:
1. Standard Webhooks HMAC (recommended for backend-to-backend)
Send three headers: webhook-id, webhook-timestamp (unix seconds), and webhook-signature. Once these three are present, this path is committed — a bad signature is a hard 401, never a silent fallthrough to the bearer-token path below.
| Source field | semantic_events field | Notes |
|---|---|---|
| webhook-id | (signed input) | Any string you choose to identify this delivery. |
| webhook-timestamp | (signed input + replay check) | Unix seconds. Request is rejected if |now − timestamp| > 300s. |
| webhook-signature | "v1,<base64>" (space-separated for multiple) | Any signature in the space-separated list matching authenticates — supports key rotation. |
| ?org_id=<uuid> query param | (selects whose secret to try) | Not itself a credential — a wrong org_id just fails signature verification against the wrong secret. |
Signature formula — body is the exact decompressed request text; sign the raw bytes you send, not a re-serialized copy of the parsed JSON:
signature = base64( HMAC-SHA256(secret, `${id}.${timestamp}.${body}`) )2. Bearer secret key (fallback)
Used automatically whenever the three HMAC headers above are absent: Authorization: Bearer drengr_sk_… — the same secret key as the history importer, from /pro.
Every auth failure — missing headers, bad signature, replay outside the 300s window, unknown or revoked key — returns the same uniform 401 (anti-enumeration: it never reveals which check failed).
Signing example (Node)
Computes the signature exactly as Drengr verifies it, then sends the request:
import crypto from 'node:crypto';
const secret = process.env.DRENGR_WEBHOOK_SECRET; // from Drengr — see caveat above
const orgId = process.env.DRENGR_ORG_ID;
const body = JSON.stringify([
{ event: 'order_completed', timestamp: new Date().toISOString(), user_id: 'user_123', revenue: 42.5 },
]);
const id = crypto.randomUUID(); // any string identifying this delivery
const timestamp = Math.floor(Date.now() / 1000); // unix seconds — must be within 300s of receipt
const signedContent = `${id}.${timestamp}.${body}`;
const signature = crypto.createHmac('sha256', secret).update(signedContent).digest('base64');
const res = await fetch(`function(){throw Error("Attempted to call FN_BASE() from the server but FN_BASE is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.")}/firehose?org_id=${orgId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'webhook-id': id,
'webhook-timestamp': String(timestamp),
'webhook-signature': `v1,${signature}`, // space-separate multiple "v1,<sig>" for key rotation
},
body,
});Event mapping
| Source field | semantic_events field | Notes |
|---|---|---|
| mapping.event_name_field value | event_name | Normalized (lowercased, non-[a-z0-9_] → "_", capped 64 chars). |
| mapping.timestamp_field + timestamp_unit | occurred_at | Must resolve into year-2000→now+1day; otherwise the event is skipped (not clamped, unlike the other doors). |
| mapping.anon_id_field value | install_id | Falls back to the user id field if the anon field is empty. |
| mapping.user_id_field value | external_id | |
| props_field, or every key not already consumed | dims + measures | One-level flatten; numeric → measures (≤20 keys, revenue/total/price/quantity first); rest → dims (≤40 keys, 64-char keys, 256-char values). |
| mapping.dedup_id_field value | event_id | Falls back to a hash of the webhook delivery id + event index (HMAC path), or of anon id + event name + raw timestamp (bearer path, no delivery id available). |
| email/phone/name/address/password/token/ssn/card/cvv-like keys | (dropped) | Same sensitive-key filter as every other door. |
Limits
- 1,000 events per request
- 5MB per request (post-decompression)
- 20MB streaming guard during gzip decompression (rejects before the 5MB check runs)
- A bad event is skipped and counted, never fails the batch (fail-open, per event)
Response
200 { "accepted": 1, "skipped": 0, "errors": [] }
400 { "error": "Invalid envelope: expected a JSON array, NDJSON body, or {events:[...]}" }
400 { "error": "Batch too large" }
401 { "error": "Unauthorized" }
413 { "error": "Payload too large" }Troubleshooting
Check clock skew first — the replay window is only 300 seconds, so a signature computed against a stale webhook-timestamp fails even with the right secret. Also verify the ?org_id= query param matches the org the secret was provisioned for.