Documentation

Everything you need to run webhooks in production.

Axel docs are split into four lanes: get started, primitives, operate, and reference. Each page is short by design — if a page can't be skimmed in 60 seconds, it's split into smaller ones.

Get started

Quickstart — accept your first webhook

CLI or curl. Accept an event, store its payload, and send it to a destination.

1.

Create a source via the API

curl -X POST https://app.axelapp.ai/api/v1/sources \
  -H "Authorization: Bearer $AXEL_API_KEY" \
  -d '{ "name": "my-webhook" }'

The response includes a per-source ingest token (secret_token), shown only once. Store it as a secret in your producer's environment.

2.

Point the producer at the ingest URL

POST https://ingest.axelapp.ai/in/{source_id}
  x-axel-token: <token>
  content-type: application/json

  { "type": "order.created", "id": "ord_123", ... }

The endpoint returns 202 on acceptance. Your event is durable in R2 before the response.

3.

Watch it land in the dashboard

Open app.axelapp.ai: Overview shows the event count tick up, Usage shows the source-level breakdown, and Deliveries shows any failed attempts.

Every event you accept is queryable in ClickHouse for 30 days, including headers and query params. Raw payloads are kept in R2 for 30 days.

Workspace overview

Overview

Welcome back. Here's what's happening across Northwind.

Events ingested1.24M +32%
Deliveries2.41M +28%
Success rate99.997% +0.08 pts
Unresolved11 -42%
Active sources124 routes
Volume · last 14 days1,241,790 events14.2 GB received · 8.2 GB raw payloads in R2
Events
Deliveries · last 14 days2,409,664 deliveries2.39M succeeded · 18,243 retried · 11 unresolved DLQ
Success / FailureFailure
Top sources
By eventsBy bytes
stripe.webhooks
612,403
github.webhooks
318,772
shopify.webhooks
164,210
partner.webhook
92,481
internal.webhook
44,108
Activity
All deliveries →
evt_01HZQ8R7XKHTTP 503 from warehouse-webhook · retry 2/12
RETRY
evt_01HZQ7N4WTTLS handshake timeout · destination paused
DEAD
evt_01HZQ6M2VRReplay batch resolved · 11 deliveries
OK
evt_01HZQ5L1UQSchema guard rejected · depth > 24
DROPPED
What you'll see after your first event: live counts, delivery outcomes, and anything that needs attention.

Prefer the CLI? Create a source in the dashboard, then axel auth login and axel listen --source <source_id> --forward-to <url>, or point your webhook producer at the ingest URL shown in the dashboard.

Sources

Custom / generic webhook sources

Use the generic source for producers that can POST JSON, form data, or bytes.

Create a source with curl or the dashboard, then POST to https://ingest.axelapp.ai/in/{source_id} with the x-axel-token header you received. No signature required.

curl -X POST https://ingest.axelapp.ai/in/src_01H... \
  -H "x-axel-token: $AXEL_SOURCE_TOKEN" \
  -H "content-type: application/json" \
  -d '{ "type": "order.created", "id": "ord_123", ... }'

You can also bring a custom HMAC secret and Axel will verify an X-Axel-Signature: t=<unix>,v1=<hex> header (timestamped, 5-minute tolerance) using constant-time comparison.

Primitives

Webhook sources

Sources are the entry points. Each source has its own token (or signing secret), rate limit, body size cap, and nesting depth guard.

  • Per-source rate limits (events/min, operator-configured) protect downstream systems.
  • Body cap (1 MB default) and depth cap (100 levels) reject pathological payloads early.
  • An accepted event is written to R2 before the 202 response is sent.
  • Token values are stored only as SHA-256 hashes; never in logs.
Source detail

stripe.webhooks

Active
Rate limit2,500/min
Body cap256 KB
Depth cap24
Retries12×
Events / 14d612,403
Success / 14d99.998%
{
  "event_id": "evt_01HZQ8R7XK",
  "source": "stripe.webhooks",
  "event": "invoice.paid",
  "customer": "cus_PqJ8XKr",
  "amount": 14250,
  "currency": "usd",
  "routes": ["payments.live"]
}
A source's detail page: its limits, live traffic, and the payloads it receives.

Routes: declarative filters & transforms

Routes decide where events go and what they look like. Rules are data, not code.

  • Filter by event type (e.g. payments.live matches invoice.paid etc.).
  • Declarative transforms: select/rename fields by JSON path, drop fields, pass the whole payload through, or wrap it as a JSONB column.
  • The route engine is eval-free and does not execute customer JavaScript.
  • Fan-out to many destinations from one source with per-(route, destination) idempotency keys.
Routes

Live fan-out

4 routes healthy
Source
Filter
Destination
p95
stripe.webhooks
payments.live
mongo://orders
41ms
github.webhooks
push.archive
s3://raw-ledger
68ms
shopify.webhooks
orders.live
postgres://billing
37ms
partner.webhook
fanout.signed
https://hooks.example
57ms
The Routes view: each source's filter, its destination, and live p95 delivery latency.

Destinations

Deliver with retries to the systems you already run.

  • Signed webhook: POST with HMAC + deterministic idempotency header so receivers can dedupe safely.
  • Postgres: insert into a JSONB column or auto-flattened columns (column projection). MongoDB: insert into native collections.
  • S3: write JSON objects with templated keys ({date}, {event_id}). Cloudflare R2: write JSON objects under a configurable key prefix.
  • Databricks: drop JSON files into Unity Catalog volumes for Auto Loader ingest into Delta.
  • BigQuery: stream each event into a table via insertAll. New routes recursively map JSON objects to nested RECORD fields, normalize scalar leaves to STRING for schema-drift tolerance, and map compatible object arrays to REPEATED RECORD fields. Axel creates the table when needed and additively evolves nested schemas; legacy flat-column and single-STRING-column modes remain available.

Delivery guarantees & idempotency

Axel provides at-least-once delivery to external destinations. Delivery attempts use a stable key of the form workspace:event:route:destination, and retries reuse that key. A signed HTTP receiver should deduplicate on X-Axel-Event-Id because a process can fail after the destination accepts a request but before Axel records the acknowledgement. Replays use a new event ID and are intentionally delivered again.

Operate

Replays

Replay an accepted event while its original payload is retained.

From the dashboard or CLI:

# Dashboard: click any event → Replay
# CLI (exact bytes to your laptop)
axel replay evt_01HZQ8R7XK --forward-to http://localhost:3000/webhook
  • Replays pull the original payload + headers from R2 (up to 30-day retention).
  • Provider signature headers (Stripe, GitHub, Shopify, Axel) are stripped by default since their signed timestamp is stale; pass --keep-signature to forward them anyway.
  • Each replay gets a distinct, replay-tagged event id so the re-delivery is recorded separately from the original.
Event trace
evt_01HZQ8R7XK · stripe.payments
01
Edge acceptedPOST /v1/webhooks/in · 202
8ms
02
R2 persistedshard=7 · 1.4KB · sha256:c2…3f
14ms
03
Route matchedfilter payments.live · matched 1 of 3
23ms
04
MongoDB deliveredmongo://orders · POST · 200 OK
41ms
05
ClickHouse indexedevents.live · queued · partition=2026-06-16
queued
An event's trace: every hop from accept to store to route to deliver, with timings.

Failed deliveries & the Inbox

Every delivery attempt is recorded. Permanent failures (after all retries) land in the Inbox with one-click Retry and Mute. No SQL, no digging through DLQ tables.

The event detail page shows the receipt, the stored payload, and a delivery history of every HTTP attempt with its status, latency, and the failure reason (TLS error, 503, timeout, etc.).

Transform & route errors

If a declarative filter excludes an event it is dropped; if a transform or filter errors at runtime the event is dead-lettered and shows up in the Inbox. Oversized or too-deep payloads are rejected at ingest with a 413 before they are ever stored or routed.

Scaling knobs

  • Per-source rate limits + body/depth caps are your first line of defense.
  • Router and delivery workers use bounded concurrency and sharded queues (Cloudflare Queues + internal).
  • Raw payloads: 30 days by default (0–30 configurable). ClickHouse traces: 30-day TTL.
  • Dead letters: 90 days by default (1–365 configurable). Replay requests: 30 days by default (1–90 configurable).
  • Audit logs: 365 days by default (30–3,650 configurable).
  • Dashboard and API give you per-route and per-destination delivery metrics, including p95 delivery latency.
Reference

Event envelope (what you receive back)

Every stored event carries Axel metadata and a pointer to the raw body in R2 (the body is referenced by r2_key rather than inlined). Example shape:

{
  "event_id": "evt_01HZQ8R7XK",
  "workspace_id": "ws_...",
  "source_id": "src_...",
  "received_at": "2026-06-16T14:22:09.123Z",
  "r2_key": "...",
  "size_bytes": 1024,
  "headers": { "x-my-signature": "..." },
  "query": {}
}

Retry policy (defaults)

Up to 12 delivery attempts with exponential backoff. Retries are included in the inbound event price. The retry policy is fixed and not per-source configurable.

ClickHouse observability

All receipt, routing, and delivery rows are queryable in ClickHouse for 30 days (headers, query params, and failure reasons; raw payloads live in R2). The dashboard search and the axel CLI use these tables. Export or run your own queries from the usage / deliveries views.

Important status & error reasons

  • 202: accepted (written to R2, queued for routing)
  • 429: rate limited at source
  • 413 / depth errors: body or nesting exceeded caps
  • Signature / auth failures: rejected before durable store
  • Destination 5xx, timeouts, and connection errors: retried per policy, then DLQ
  • Permanent 4xx (e.g. 400/401/403/404/410): dead-lettered immediately

Need a question answered?

Docs are intentionally concise. If something is missing or unclear, email the founders — we read every message and improve the site from real questions.