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:

bash
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

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

Body shapes

Any of these three:

A bare JSON array

json
[
  { "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

bash
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

json
{
  "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.

bash
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.

json
{
  "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 fieldsemantic_events fieldNotes
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:

text
signature = base64( HMAC-SHA256(secret, `${id}.${timestamp}.${body}`) )
Webhook secret provisioning isn't self-serve yet. There's no console UI to generate or rotate your org's HMAC secret today. Email hey@drengr.devwith your org's account email to get one provisioned, or use the bearer-token fallback below in the meantime — it needs no extra setup.

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:

javascript
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 fieldsemantic_events fieldNotes
mapping.event_name_field valueevent_nameNormalized (lowercased, non-[a-z0-9_] → "_", capped 64 chars).
mapping.timestamp_field + timestamp_unitoccurred_atMust resolve into year-2000→now+1day; otherwise the event is skipped (not clamped, unlike the other doors).
mapping.anon_id_field valueinstall_idFalls back to the user id field if the anon field is empty.
mapping.user_id_field valueexternal_id
props_field, or every key not already consumeddims + measuresOne-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 valueevent_idFalls 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

json
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.