A TypeScript library that queues webhooks in your Postgres transaction, then signs, retries and logs every delivery. No extra service, no per‑message bill.
-
+
Agent
-
Add Hookgres webhooks to this app: hookgres.dev/llms.txt
-
@@ -737,7 +1138,7 @@
Webhooks that commit with your data.
npm
$ npm install hookgres pg
-
+
@@ -750,13 +1151,15 @@
Webhooks that commit with your data.
-
+
+
+
app.tsreceiver.ts
-
+
@@ -800,6 +1203,7 @@
Webhooks that commit with your data.
}
}
+
@@ -813,19 +1217,175 @@
Webhooks that commit with your data.
+
+
+
when you need it
+
“Can you tell our system when this happens?”
+
When a customer asks that, you need webhooks. Six requests SaaS teams get most, each answered with one send() inside the transaction that saves the change.
One commit in. Every endpoint, signed and retried.
+
send() writes a row next to your data. After COMMIT, workers claim it with SKIP LOCKED, sign the request and deliver it to each endpoint. A failure goes back on the schedule.
+
+
+
+
+
+
+ send()Writes the webhook as a row in your transaction.
+ COMMITThe row commits with your data. Roll back and it’s gone.
+ SKIP LOCKEDWorkers claim rows without waiting on each other.
+ POSTEach endpoint gets a signed request. Failures go back on the schedule.
+
+
+
+
+
-
Transactional
+
BEGIN … COMMIT
Queued in your transaction. Sent only if it commits.
A webhook service is a second system. Call it after your commit, and a crash in between loses the event. Call it before, and a rollback leaves your customer with a webhook for an invoice that was never paid. Hookgres writes the webhook in the same transaction as your data, so you get the outbox and the delivery in one library.
-
Workers claim deliveries with FOR UPDATE SKIP LOCKED, so you can run as many as you like.
On an ORM, pass its transaction instead. Adapters for Prisma and Kysely ship with Hookgres; Drizzle, postgres.js, Knex, Sequelize, TypeORM and MikroORM each take a tested wrapper of a few lines.
The same payment code, run twice. First it queues the webhook with BullMQ inside the transaction: the payment rolls back, but the job is already in Redis, so acme.com marks in_124 paid while your database says open. Any queue outside Postgres does the same. Then the queue is swapped for hookgres.send(), and the webhook rolls back with the payment. Real BullMQ, Redis and Postgres, played at 2× speed.
+
ROLLBACK
+
A job queue can’t roll back with your data.
+
A queue outside Postgres keeps a job your transaction rolled back, so your customer hears about a payment that never happened. Two terminal recordings with real Postgres and the published package.
The same payment code, run twice. With BullMQ, the payment rolls back but the job is already in Redis, so acme.com marks in_124 paid. With hookgres.send(), the webhook rolls back with the payment.
+
+
+
+ Pause
+
From npm install hookgres pg to a logged retry, against a receiver that is down for two attempts. The rolled-back invoice sends nothing; the committed one is signed, retried and logged.
Not a contrived example. Other teams hit these bugs in 2026:
-
-
“…the entire batch is rolled back, but the webhook for the earlier (now rolled-back) request has already been dispatched.”
Not a contrived example. Other teams hit these bugs in 2026:
-
+
-
-
-
Retries
-
Watch it retry.
-
Pick how your customer's server behaves and send an event. This runs the default schedule, with 28 hours squeezed into a few seconds.
-
-
-
- Send event
-
-
+
retry schedule · 0s → 27.6h
+
Watch it retry.
+
One invoice.paid, three of your customers’ servers. Each endpoint retries on its own schedule for up to 28 hours, here squeezed into a few seconds.
-
invoice.paidt = 0s
-
-
-
+
+ invoice.paid
+ t = 0s
+ Pause
-
Press Send event.
+
Animation of one event sent to three endpoints. The healthy one answers 204 on the first attempt. The flaky one answers 503 twice, then 204 on the third attempt, 5 minutes in. The one that is down answers 500 to all eight attempts over 27.6 hours and the delivery fails.
+
+
A server that’s down never holds up the others. Each endpoint gets its own delivery, its own retries and its own log rows.
-
8attempts
over about 28 hours, from 5 seconds to 10 hours apart.
-
5days
of failures before an endpoint is disabled. One call turns it back on.
-
24hours
when old and new secrets both sign, so receivers rotate without dropping events.
-
0lost
and none duplicated across 20,000 webhooks and 4 competing workers in the load test.
+
8attempts
over about 28 hours, from 5 seconds to 10 hours apart.
+
5days
of failures before an endpoint is disabled. One call turns it back on.
+
24hours
when old and new secrets both sign, so receivers rotate without dropping events.
+
0lost
and none duplicated across 20,000 webhooks and 4 competing workers in the load test.
The same retry shape Svix uses, all configurable. On one laptop: about 13,000 sends and 5,500 deliveries per second. Reproduce it with pnpm bench.
-
+
-
Signatures
-
What your customers receive.
-
A JSON envelope and three signed headers. The webhook-id stays the same across retries. Delivery is at least once, so receivers deduplicate on it.
-
Verify in any language with the official Standard Webhooks libraries: JavaScript, Python, Go, Ruby, PHP, Rust, Java, C# and Elixir.
+
standard webhooks
+
Your customers verify it with code they already have.
+
Every request is signed to the open Standard Webhooks spec. Your customers check it with an official library in their own language, so you never have to invent a signing scheme and support it.
+
Official libraries for JavaScript, Python, Go, Ruby, PHP, Rust, Java, C# and Elixir. Hookgres’s tests check its signatures against the JavaScript one.
import { Webhook } from"standardwebhooks";
+
+const wh = new Webhook(secret); // "whsec_..."
+const event = wh.verify(rawBody, headers);
+// throws if the signature fails or the timestamp is too old
+
from standardwebhooks.webhooks import Webhook
+
+wh = Webhook(secret) # "whsec_..."
+event = wh.verify(raw_body, headers)
+# raises if the signature fails or the timestamp is too old
+
import standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
+
+wh, err := standardwebhooks.NewWebhook(secret)
+err = wh.Verify(body, r.Header)
+// err is set if the signature fails or the timestamp is too old
+
require"standardwebhooks"
+
+wh = StandardWebhooks::Webhook.new(secret)
+event = wh.verify(raw_body, headers)
+# raises if the signature fails or the timestamp is too old
+
$wh = new \StandardWebhooks\Webhook($secret);
+
+$wh->verify($body, $headers);
+// throws if the signature fails or the timestamp is too old
+
-
Demo secret whsec_…, generated for this page.
-
+
-
Delivery log
+
hookgres.deliveries
Every attempt is a row you can query.
-
Status, response body and timing for each request, stored next to your data. Build a customer-facing log, a support view or an alert with plain SQL.
+
Status, response body and timing for each request, stored next to your data. A customer-facing log or a support view is one SQL query away.
-
-
-
- invoice.paid
- msg_01m6yb1bw7xncca4sw5028556c
-
-
-
- Endpoint0s5s5m35m2.6h7.6h17.6h27.6hDelivery
+
+
+
+ Customer log
+ Failed today
+ Slowest
-
-
acme.com/webhooksevery event
-
-
204
-
-
-
Succeeded1 attempt
+ Copy
+
+
+
+
+
select d.id, m.event_type, d.status, d.attempt_count,
+ m.created_at::time(0) as sent_at
+from hookgres.deliveries d
+join hookgres.messages m on m.id = d.message_id
+where m.tenant_id = 'acme'
+order by m.created_at desclimit 4;
+
+
id
event_type
status
attempt_count
sent_at
Action
dlv_8c41e2…
invoice.paid
succeeded
1
14:02:11
dlv_5b07d9…
customer.updated
succeeded
3
13:58:40
dlv_9a1f63…
refund.created
failed
8
13:41:07
Replay
dlv_2e6c80…
invoice.paid
succeeded
1
13:30:52
-
-
shop.example/hooks2 event types
-
-
503
-
503
-
204
-
-
-
Succeeded3 attempts
+
(4 rows) · Try Replay on the failed delivery.
+
+
+
select e.tenant_id, e.url, d.attempt_count, d.last_error
+from hookgres.deliveries d
+join hookgres.endpoints e on e.id = d.endpoint_id
+where d.status = 'failed'
+ and d.updated_at > now() - interval'1 day';
+
+
tenant_id
url
attempt_count
last_error
acme
https://crm.example/in
8
HTTP 500
globex
https://hooks.globex.dev/hg
8
Timed out after 15000ms
initech
https://initech.example/webhooks
8
fetch failed: ECONNREFUSED
-
-
crm.example/ininvoice.paid
-
-
500
-
500
-
500
-
500
-
500
-
500
-
500
-
500
-
-
Failed8 attempts
+
(3 rows)
+
+
+
select e.url,
+ percentile_cont(0.95) within group (order by a.duration_ms) as p95_ms,
+ count(*) filter (where a.error is not null) as errors
+from hookgres.attempts a
+join hookgres.deliveries d on d.id = a.delivery_id
+join hookgres.endpoints e on e.id = d.endpoint_id
+where a.created_at > now() - interval'1 hour'
+group by e.url order by p95_ms desclimit 3;
+
+
url
p95_ms
errors
https://hooks.globex.dev/hg
15000
12
https://shop.example/hooks
2380
41
https://acme.com/webhooks
84
0
+
(3 rows)
+
-
-
+
Replay.replayDelivery() re-sends a finished delivery with the same webhook-id, so receivers can deduplicate.
Idempotency keys. Pass the same key twice and get the original message back, not a duplicate.
-
Built for multi-tenant apps. Every event goes only to one tenant's endpoints, filtered by event type.
+
Built for multi-tenant apps. Every event goes only to one tenant’s endpoints, filtered by event type.
Crash recovery. If a worker dies mid-request, its claim expires and another worker picks it up.
-
-
-
Failed in the last 24 hoursCopy
-
select e.tenant_id, e.url, d.last_error
-from hookgres.deliveries d
-join hookgres.endpoints e on e.id = d.endpoint_id
-where d.status = 'failed'
- and d.updated_at > now() - interval'1 day';
-
-
-
-
-
-
-
-
-
-
In your terminal
-
Install it, send inside a transaction, watch it retry.
-
The published package, a real Postgres and a receiver that is down for two attempts. The rolled-back invoice sends nothing; the committed one is signed, retried and logged. Played at 2× speed.
Hosted services like Svix and Hookdeck are a great fit when you want someone else to run it. Hookgres fits when you'd rather keep webhooks next to your data.
+
Hosted services like Svix and Hookdeck make sense when you want someone else to run it. Hookgres fits when you’d rather keep webhooks next to your data.
-
Question
Hookgres
Hosted service
+
Question
Hookgres
Hosted service
Where it runs
Your app, your Postgres
Their cloud, via API
Part of your transaction
Yes
No, a separate API call
1M webhooks a month
Free library, about $0.15 of storage
$9.50 on Hookdeck Outpost, $115 on Svix Basic (compare)
Let your customers add endpoints, see every delivery and replay failures, styled to match your app. Join the waitlist for early access.
+
Today, customers add endpoints through your own settings page, a few routes around createEndpoint() scoped to their tenant (example). Pro adds a portal where they add endpoints, see every delivery and replay failures, styled to match your app. Join the waitlist for early access.