Webhooks

Two webhook flavors exist in Ploxir. The generic webhook source turns your own backend into a data source — POST JSON events at a secret URL and build widgets on top of them. Provider webhooks are the plumbing behind supported platform connectors (Stripe, Shopify, Paddle, PayPal, …) so their events stream in live. This page covers the generic webhook end-to-end, with a short section on provider webhooks at the bottom.

The generic webhook source

Add a source to any project and pick Webhook ("any platform"). Ploxir generates a single secret URL:

https://ploxir.com/api/wh/<secret>

One source handles unlimited event types — you distinguish streams by the name field in each event, not by creating more sources. A GET on the URL returns a small health check — { ok, label, signed, hint } — handy for confirming the URL works and whether HMAC signing is enforced (signed: true) before you wire a sender.

The URL is the credential
Anyone who knows the URL can POST events into your dashboard. Treat it like a password: keep it in your backend's secret store, never in client-side code or public repos. In the dashboard, only Admins and Owners can reveal or copy it. For defense in depth, add HMAC signing (below).

Event shape

POST a single JSON object:

curl -X POST 'https://ploxir.com/api/wh/<secret>' \
  -H 'Content-Type: application/json' \
  -d '{"name":"signup","value":1,"meta":{"plan":"pro","country":"DE"}}'
FieldRequiredTypeNotes
nameyesstring, 1–100 charsThe event name — signup, order_paid, license_activated. Widgets filter on it, so spell it consistently.
valuenofinite numberThe event's numeric payload. Used by sum / avg / last widgets. Omit it for pure counting events.
tsnoISO-8601 datetimeWhen the event happened. Defaults to arrival time. Accepted window: up to 24h in the past, 5min future clock skew.
metanoJSON objectFree-form payload — plan, country, user_id, amount, even arrays of objects. Widgets can chart, group, and tabulate these fields (see below), so send rich meta.

ts parsing is deliberately lenient: Z-suffixed UTC, numeric offsets (+02:00 or compact +0200), and zoneless timestamps all work — a zoneless ts is treated as UTC, never shifted by the server's locale. Date-only strings (2026-07-13) are rejected; an event needs a time of day. Charts and range filters bucket by ts, so send the real event time when you have it.

Batching

POST an array of up to 100 events in one request — useful for catch-up after an outage:

curl -X POST 'https://ploxir.com/api/wh/<secret>' \
  -H 'Content-Type: application/json' \
  -d '[
    {"name":"signup","ts":"2026-07-13T10:00:00Z"},
    {"name":"signup","ts":"2026-07-13T10:05:00Z"}
  ]'

Remember the 24-hour backfill window applies per event — a batch replaying older ts values will have those events rejected individually.

Per-event tolerance & the response

Batches are tolerant per event: one malformed event doesn't reject the other 99. Valid events ingest, and the response reports exactly what happened:

{ "ok": true, "ingested": 98, "accepted": 98, "rejected": 2,
  "firstError": "ts is too far in the past (>24h)" }

A request only 400s when every event in it is invalid. Full status-code map:

StatusMeaning
200At least one event stored (also returned for an empty array). Check rejected for partial failures.
400Body isn't JSON, isn't an object/array, has more than 100 events, or every event failed validation — the response carries { accepted: 0, rejected, firstError }.
401Signing is enforced and the headers are missing, the timestamp is stale, or the signature doesn't match.
404Unknown secret — wrong URL, or the source was deleted.
413Body over 256 KB.
429Rate limit exceeded — back off and retry later.

Limits

  • 600 requests / minute per source (token bucket)
  • 256 KB body per request
  • 100 events per request
  • 24h backfill window on ts, 5min future clock skew
  • Event names capped at 100 characters; meta must be a JSON object (not an array or scalar)

Retries & duplicates

Every error response (4xx, or a 5xx from a crash mid-request) stores nothing from that request — the batch insert is atomic — so retrying the whole request after an error is always safe. A 200 means the accepted events are committed. Events carry no client-supplied ID and are not deduplicated: re-sending an acknowledged batch stores every event again and double-counts your widgets. The rule for senders: retry on errors, never re-send after a 200.

HMAC signing (optional)

The path secret already gates the endpoint. With a signing secret set on the source, every POST must additionally carry two headers, and unsigned or mis-signed requests are rejected with a 401:

X-Ploxir-Timestamp: <unix-seconds>
X-Ploxir-Signature: sha256=<hex-hmac>

The signature is HMAC-SHA256(secret, "<timestamp>.<raw-body>"), hex-encoded, computed over the exact timestamp string you send in the header joined to the exact raw body bytes you send — sign the string you POST, not a re-serialized copy. Details:

  • Send the timestamp as unix seconds (a millisecond epoch is also accepted).
  • The sha256= prefix on the signature is optional — bare hex works too.
  • Timestamps more than 5 minutes off the server clock are rejected (Stale or invalid timestamp).
Working example (Node.js)
import { createHmac } from 'crypto'

const body = JSON.stringify({ name: 'signup', value: 1 })
const ts = Math.floor(Date.now() / 1000).toString()
const sig = createHmac('sha256', SIGNING_SECRET)
  .update(`${ts}.${body}`)
  .digest('hex')

await fetch(WEBHOOK_URL, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Ploxir-Timestamp': ts,
    'X-Ploxir-Signature': `sha256=${sig}`,
  },
  body, // the exact string that was signed
})
Enabling signing
Signing is enforced automatically as soon as a signing secret is set on the source — there is no self-serve field in the dashboard for it yet. Email [email protected] to have one set on your source. You can confirm the current state at any time with a GET on the webhook URL: signed: true means every POST must be signed.

Signature failures surface as rejected deliveries on the source card (below), so a sender with a wrong secret or a drifting clock is visible immediately — not silently dropped.

Seeing what arrived

Each webhook source on the project's Sources page expands into an activity card:

  • Webhook URL with a copy button (Admin+ only).
  • Event types received — each distinct event name (up to the 50 most frequent), with its count and last-seen time. This table is the ground truth for what your sender actually POSTs; check it before wiring widgets.
  • Declared events — pre-register up to 50 event names (paste them comma- or newline-separated) so they appear as quick-picks in the widget builder before any event has fired. Useful for wiring the dashboard ahead of the sender going live.
  • Sample request — a ready-to-paste curl matching one of your real event names.
  • Recent events — a paginated history of received events with their payloads.
  • Last delivery rejected — when a delivery was refused (bad signature, stale timestamp, malformed event, rate limit), the card shows the reason and how long ago. It appears only while the rejection is newer than the last accepted event, so a healed sender clears the warning on its next success. This is what makes "my sender is misconfigured" distinguishable from "no events sent yet".

Building widgets from events

Open Add widget and look in the Custom webhook widgets category: Event count, Cumulative count, Sum of values, Average of values, Last value, Recent events (table), and Events by name (breakdown) — plus Cohort retention and Conversion funnel (under Product and Growth). Every widget filters on one event name and applies an aggregation:

AggregationWhat it computesExample
countEvents in the selected rangeSignups this week
running countAll-time cumulative total, optionally minus an offset eventActive licenses = activateddeactivated
sumSum of the numeric value across events in rangeRevenue from order_paid events
avgAverage of the numeric value across events in rangeAverage order size, average latency
lastMost recent value — a point-in-time levelCurrent MRR posted by a nightly job
distinctUnique values of a payload field in rangeUnique visitors by meta.visitor_hash

Levels vs. flows: count, sum, avg, and distinct follow the page's date range. Running count and last are levels — they read the state as of the end of the range with no lower bound, so an MRR rollup that last fired yesterday still shows its current value when the page is set to Today instead of blanking to zero.

Reading the meta payload

Everything you put in meta is usable in widgets, addressed by dotted paths (plan, order.total):

  • Value field — sum / avg / last can read a number from a meta path (e.g. amount) instead of the top-level value.
  • Text KPIlast pointed at a string field renders the text itself (e.g. the most recent signup's email address).
  • Breakdowns — group any event by a meta field (plan, country, referrer); the widget shows the top 8 groups over the selected range, aggregated with whichever agg you chose. Leave the group field blank to break down by event name instead.
  • Table columns — pick meta fields as columns on the recent-events table, or leave it blank and Ploxir auto-derives columns from the payloads (numbers align right, ISO timestamps render as relative dates). Events carrying an array of objects (e.g. a users.list event with meta.users) explode into one row per element automatically.
  • Unique countsdistinct counts unique non-empty values of a meta path (user_id → active users).
  • Money — declare whether the number is in cents or major units (a cents sender otherwise inflates 100×), and optionally point at a per-event currency field: money widgets then only aggregate events matching the widget's declared currency instead of blind-summing mixed currencies.

Two dedicated widget kinds go further, and both require a meta.user_id string on the events:

  • Cohort retention — a weekly retention grid from a "join" event (e.g. user_signed_up) and an activity event (e.g. user_active), 6 weekly cohorts by default.
  • Conversion funnel — 2–8 ordered event names; counts users who progressed through the steps in order within the selected range, with per-step conversion rates.

Webhook widgets work on both planes: a project widget reads that project's source, while a workspace portfolio widget merges every webhook source across your projects (tables gain a leading Project column). Events push to the dashboard live over SSE — a POST typically appears on open dashboards within seconds, no refresh needed.

The event profiler

Ploxir continuously profiles each source's event stream: for each event name it samples recent payloads (up to 300 per name) and infers a typed schema of every field. That schema powers the widget builder — event names appear as quick-picks, field pickers show real payload paths with their types, and picking a known event auto-suggests the right widget shape: an event carrying a list pre-selects a table, a point-in-time rollup pre-selects "latest value" on its top numeric field, and a per-hit stream pre-selects a count. Everything stays editable. Brand-new sources are live-scanned so the picker is never blind while the background profile catches up.

Common mistakes

  • Event-name mismatch — the classic. A widget filtering on page_view while the app POSTs pageview silently reads 0 forever. The Event types received table on the source card shows exactly what arrived — build widgets from those names (or declared events), never from memory.
  • Cents sent, dollars assumed — a sender using minor units without setting the cents/dollars toggle inflates money widgets 100×.
  • Backfilling more than 24h — events with ts older than 24 hours are rejected per event. The webhook is a forward-building stream, not a historical import tool.
  • Re-sending acknowledged batches — there is no dedup; a 200 means committed. Only retry error responses.
  • Signing a re-serialized body — the HMAC must cover the exact raw bytes you POST. Serializing once, signing that string, and sending that same string avoids Bad signature from key-order or whitespace differences.
  • Date-only timestamps"2026-07-13" is rejected; send a full datetime.

Provider webhooks (the other flavor)

When you connect a Stripe, Shopify, Patreon, Twitch, Paddle, Lemon Squeezy, PayPal, Beehiiv, Zendesk, or Datadog source, Ploxir auto-registers a webhook with the provider — their events stream in live with no setup on your side. The provider points at a per-source URL like:

https://ploxir.com/api/wh/stripe/<sourceId>

For these auto-registered sources, a Verify action on the source card asks the provider directly whether the registration is still alive — and when it has disappeared upstream, the next sync re-registers it automatically. Some platforms only expose a webhook setup UI, not a registration API. For those (RevenueCat, App Store Connect, Play Console, Gumroad, …) the source card shows the exact URL to paste into the provider's settings. Everything else is polled on a 2–24h cadence depending on the platform; the Refresh button force-syncs any source now. Per-platform steps live in Connect a source, and the push-vs-poll picture in Real-time.

FAQ

How fast do events show up on the dashboard?

Seconds. Each accepted POST pushes a live event over SSE to any open dashboard, which refreshes automatically — there is no polling interval in between. See Real-time for how the stream reconnects through deploys and network drops.

How long are events kept?

Webhook events are retained — they are the historical record, not a cache. Widgets query them directly over whatever date range you pick, so a chart built today can still read events from months ago.

Can one URL handle multiple event types?

Yes — that's the intended design. One source, unlimited event names, distinguished by the name field. Create separate sources only when you want separate credentials or separate projects.

Are duplicate events deduplicated?

No. Events carry no client-supplied ID, so the same event POSTed twice counts twice. Design your sender to emit each event once; retry only on error responses (which store nothing), never after a 200.

Can I backfill historical data?

Only within the 24-hour ts window — older events are rejected. For deep history, use a native connector where one exists (Stripe backfills full account history on connect), or start POSTing now and let the stream build forward.

What should I do if the URL leaks?

Anyone with the URL can inject fake events — they can't read your stored events (a GET returns only the health-check summary, never event data). There's no self-serve rotation button yet: delete the source and create a fresh one (new secret URL; the old source's widgets are archived with it, so rebuild them on the new source), or email [email protected]. Enabling HMAC signing means a leaked URL alone isn't enough to inject events.

Why does my widget show 0?

In order of likelihood: the widget's event name doesn't exactly match what your sender POSTs (check Event types received); the widget uses a range-scoped aggregation (count/sum/avg) and no events fall in the selected range; or deliveries are being rejected — the source card shows the last rejection and its reason.

Who can see and manage the webhook URL?

Revealing or copying the URL requires Admin or Owner. Editors can build widgets on the source and Viewers can see the resulting dashboards, but neither can read the URL. Declared events and source deletion are also Admin+.

What happens when I hit the rate limit?

Requests beyond 600 per minute per source get a 429 and nothing is stored from them. The bucket refills continuously, so back off briefly and retry. If you're bursty, batch up to 100 events per request — the limit counts requests, so batching multiplies your effective event throughput.