Sendry Docs
Integrations

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 as sendry-webhooks-ingester on npm).
  • Built on Hono + @hono/node-server.
  • Uses the official sendry-sdk for 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

MethodPathDescription
POST/webhooks/sendryReceives a webhook delivery from Sendry.
GET/healthLiveness probe — returns { "status": "ok" }.

POST /webhooks/sendry returns:

CodeMeaning
200Event stored (or duplicate of one we already had).
400Malformed body (invalid JSON, missing type).
401Missing/invalid signature or stale timestamp.
500Database 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:

ColumnTypeNotes
idtext PKDelivery ID. Retries hit ON CONFLICT DO NOTHING.
event_typetexte.g. email.delivered, email.bounced.
email_idtext nullFrom payload.data.email_id/id.
contact_idtext nullFrom payload.data.contact_id.
domain_idtext nullFrom payload.data.domain_id.
automation_run_idtext nullFrom payload.data.automation_run_id/run_id.
occurred_attimestamptzFrom envelope created_at.
payloadjsonbThe full webhook envelope.
received_attimestamptzSet 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-Timestamp more than 5 minutes off your server clock are rejected. Tune with the maxTimestampSkewSeconds option on createApp().
  • At-least-once delivery. Sendry retries with exponential backoff. The primary-key + ON CONFLICT DO NOTHING design 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 verifyWebhookSignature from sendry-sdk and 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.

On this page