Self-Hosted Webhooks Ingester
Run a small open-source server that receives Sendry webhooks, verifies signatures, and writes events to your own Postgres.
sendry-webhooks-ingester is an Apache-2.0 companion to Sendry. You run it on
your own infrastructure; Sendry POSTs webhook deliveries to it; the server
verifies HMAC signatures and writes one row per event into a Postgres table
you can query directly with SQL.
It's the right choice when you want webhook data in your warehouse but don't want to write the handler, the dedupe logic, or the schema.
- Source:
packages/sendry-webhooks-ingester(also published assendry-webhooks-ingesteron npm). - Built on Hono +
@hono/node-server. - Uses the official
sendry-sdkfor signature verification. - License: Apache-2.0.
Quick start
1. Install
npm i -g sendry-webhooks-ingester
Or use the Docker image (see Docker below).
2. Configure
export DATABASE_URL=postgres://user:pass@host:5432/sendry_events
export SENDRY_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
export PORT=4000
The webhook secret is in the Sendry dashboard under
Webhooks → your endpoint → Secret. It always starts with whsec_.
3. Run
sendry-ingester
On first boot the ingester runs CREATE TABLE IF NOT EXISTS sendry_events (...)
against your database. No migrations to manage.
4. Point Sendry at it
In the Sendry dashboard, create a new webhook endpoint pointing at:
https://your-ingester.example.com/webhooks/sendry
Subscribe to whichever events you want (or * for all of them). The ingester
accepts any event Sendry sends.
5. Query
-- Last 100 bounces
SELECT id, email_id, occurred_at, payload->'data'->>'reason' AS reason
FROM sendry_events
WHERE event_type = 'email.bounced'
ORDER BY occurred_at DESC
LIMIT 100;
-- Click activity for one recipient
SELECT occurred_at, payload->'data'->>'url' AS url
FROM sendry_events
WHERE event_type = 'email.clicked'
AND email_id = 'email_abc123'
AND occurred_at > now() - interval '7 days'
ORDER BY occurred_at;
Endpoints
| Method | Path | Description |
|---|---|---|
| POST | /webhooks/sendry | Receives a webhook delivery from Sendry. |
| GET | /health | Liveness probe — returns { "status": "ok" }. |
POST /webhooks/sendry returns:
| Code | Meaning |
|---|---|
| 200 | Event stored (or duplicate of one we already had). |
| 400 | Malformed body (invalid JSON, missing type). |
| 401 | Missing/invalid signature or stale timestamp. |
| 500 | Database error — Sendry will retry. |
Successful responses return { "received": true, "duplicate": false } so you
can tell new events from idempotent retries in logs.
Table schema
The ingester writes to a single table, sendry_events:
| Column | Type | Notes |
|---|---|---|
id | text PK | Delivery ID. Retries hit ON CONFLICT DO NOTHING. |
event_type | text | e.g. email.delivered, email.bounced. |
email_id | text null | From payload.data.email_id/id. |
contact_id | text null | From payload.data.contact_id. |
domain_id | text null | From payload.data.domain_id. |
automation_run_id | text null | From payload.data.automation_run_id/run_id. |
occurred_at | timestamptz | From envelope created_at. |
payload | jsonb | The full webhook envelope. |
received_at | timestamptz | Set by Postgres on insert. |
Indexes: (event_type, occurred_at DESC) and (email_id).
Docker
docker run -p 4000:4000 \
-e DATABASE_URL=postgres://... \
-e SENDRY_WEBHOOK_SECRET=whsec_... \
ghcr.io/sendry-dev/sendry-webhooks-ingester:latest
The repo also ships a docker-compose.yml that pairs the ingester with a
local Postgres 16 instance — handy for local testing.
Programmatic use
If you'd rather mount the ingester inside an existing Hono app or run it as a
library, the package exports createApp, createPool, and ensureSchema:
import { serve } from "@hono/node-server";
import { createApp, createPool, ensureSchema } from "sendry-webhooks-ingester";
const pool = createPool({ databaseUrl: process.env.DATABASE_URL! });
await ensureSchema(pool);
const app = createApp({
pool,
webhookSecret: process.env.SENDRY_WEBHOOK_SECRET!,
});
serve({ fetch: app.fetch, port: 4000 });
Operational notes
- TLS required. Sendry only delivers to HTTPS endpoints in production.
- Replay protection. Requests with an
X-Sendry-Timestampmore than 5 minutes off your server clock are rejected. Tune with themaxTimestampSkewSecondsoption oncreateApp(). - At-least-once delivery. Sendry retries with exponential backoff. The
primary-key +
ON CONFLICT DO NOTHINGdesign guarantees you only ever see one row per delivery. - Backpressure. A 5xx response triggers Sendry's retry pipeline. Don't swallow database errors unless you're prepared to lose events.
Alternatives
- Roll your own handler. Verify with
verifyWebhookSignaturefromsendry-sdkand write to whatever store you prefer. - Use a queue. Forward events to SQS/Kafka/etc. before writing to your primary store — useful when you want to fan out to many consumers.
The ingester intentionally only does the boring part: receive, verify, store. Anything fancier belongs downstream.