sendry-webhooks-ingester
v0.1.0
Published
Self-hosted ingester that receives Sendry webhook deliveries, verifies signatures, and writes events to your own Postgres.
Maintainers
Readme
sendry-webhooks-ingester
Self-hosted HTTP server that receives Sendry webhook deliveries, verifies their HMAC signatures, and writes the events to your own Postgres database. Skip the handler code — point Sendry at this and query events with SQL.
- Built on Hono +
@hono/node-server. - Verifies signatures using the official
sendry-sdkhelper. - Idempotent inserts (
ON CONFLICT DO NOTHING) — safe through retries. - Bootstraps its own table on startup. No migrations to run.
- Apache-2.0 licensed.
Quick start
1. Install
npm i -g sendry-webhooks-ingesterOr run the Docker image (see Docker below).
2. Configure
Create a .env (see .env.example) or pass flags:
DATABASE_URL=postgres://user:pass@host:5432/sendry_events
SENDRY_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
PORT=4000Find the webhook secret in the Sendry dashboard:
Webhooks → your endpoint → Secret (always starts with whsec_).
3. Run
sendry-ingester
# or
sendry-ingester --port 4000 \
--database-url postgres://... \
--webhook-secret whsec_...The first boot runs CREATE TABLE IF NOT EXISTS sendry_events (...) against
your database. From then on it is a simple HTTP receiver.
4. Point Sendry at it
In the Sendry dashboard, open Webhooks → New endpoint and set the URL to:
https://your-ingester.example.com/webhooks/sendrySubscribe to whichever events you care about (or * for everything). The
ingester accepts any event Sendry sends — it doesn't care about the schema
beyond a few well-known top-level fields.
5. Query Postgres
-- 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 over 7 days
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
Required headers (sent by Sendry):
X-Sendry-Signature—HMAC_SHA256(secret, "{timestamp}.{body}")as hex.X-Sendry-Timestamp— Unix seconds when the request was signed.Content-Type: application/json
Response codes:
| Code | Meaning |
| ---- | ------------------------------------------------------ |
| 200 | Event accepted (newly 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. |
Successful responses include { "received": true, "duplicate": false } so you
can tell new events apart from idempotent retries in logs.
Table schema
The ingester writes to a single table, sendry_events:
| Column | Type | Notes |
| ------------------- | ------------- | --------------------------------------------------------------------- |
| id | text PK | The delivery ID. ON CONFLICT DO NOTHING makes retries idempotent. |
| event_type | text | e.g. email.delivered, email.bounced, contact.created. |
| email_id | text null | Promoted from payload.data.email_id/id when present. |
| contact_id | text null | Promoted from payload.data.contact_id. |
| domain_id | text null | Promoted from payload.data.domain_id. |
| automation_run_id | text null | Promoted from payload.data.automation_run_id/run_id. |
| occurred_at | timestamptz | From envelope created_at. |
| payload | jsonb | The full webhook envelope, including type, created_at, data. |
| received_at | timestamptz | Set by Postgres on insert. |
Indexes:
(event_type, occurred_at DESC)— typical "last N of event X" queries.(email_id)— per-message timelines.
Run src/schema.sql by hand if you'd rather manage the DDL yourself (the
ingester runs the same file at startup).
Docker
# From the packages/ directory of the Sendry monorepo:
docker compose -f sendry-webhooks-ingester/docker-compose.yml upThe compose file ships a Postgres 16 service on port 5433 and the ingester on
port 4000. Set SENDRY_WEBHOOK_SECRET in your shell or a .env file.
For a production deploy, build the image directly:
docker build -f sendry-webhooks-ingester/Dockerfile -t sendry-ingester ./packages
docker run -p 4000:4000 \
-e DATABASE_URL=postgres://... \
-e SENDRY_WEBHOOK_SECRET=whsec_... \
sendry-ingesterProgrammatic use
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 });You can also mount the app inside an existing Hono server, replace the pool with your own pg pool, or wrap it with auth middleware (e.g. mTLS) if you expose the ingester on the public internet.
Operational notes
- Run behind TLS. Sendry will only deliver to HTTPS URLs in production.
- Replay protection. Requests older than 5 minutes (per
X-Sendry-Timestamp) are rejected. Tune viamaxTimestampSkewSecondswhen callingcreateApp(). - At-least-once delivery. Sendry retries failed deliveries with
exponential backoff. The unique primary key +
ON CONFLICT DO NOTHINGguarantees you only ever see one row per delivery. - Backpressure. Database errors return 500, which causes Sendry to retry. Don't disable retries unless you're prepared to lose events.
