There is a classic reliability bug hiding in most webhook-emitting codebases. It looks like this:
func (s *OrderService) CreateOrder(ctx context.Context, order Order) error {
if err := s.db.InsertOrder(ctx, order); err != nil {
return err
}
// If the process crashes here, the webhook is never sent.
return s.webhooks.Dispatch(ctx, "order.created", order)
}The database write succeeds. The process restarts. The webhook is never dispatched. Your customer's fulfillment system never receives the event. You have no idea it was dropped.
This is not a bug in your code — it is a fundamental limitation of dispatching webhooks from application code. The write and the dispatch are not atomic. Any failure between them leaves the webhook unsent, with no record that it should have been.
Change Data Capture (CDC) eliminates this class of bug by making the database transaction log the source of truth for webhook dispatch. If the row was written, the webhook will eventually fire — no matter what happens to the application process.
What Change Data Capture Actually Is
Postgres (and most production databases) maintains a write-ahead log (WAL): an append-only record of every committed change. The WAL exists for crash recovery and replication, but it is also a perfect audit trail for every row-level event in your database.
CDC tooling — such as Debezium, pg_logical, or the built-in pgoutput logical replication plugin — reads the WAL and exposes each committed change as a structured event. You get a stream of:
{
"op": "c",
"table": "orders",
"before": null,
"after": {
"id": "ord_01HX...",
"customer_id": "cust_abc",
"total_cents": 4999,
"status": "pending",
"created_at": "2026-04-25T09:12:00Z"
},
"ts_ms": 1745573520000,
"lsn": "0/1A3C4F8"
}The op field tells you the operation type: c (create/insert), u (update), d (delete). The lsn (log sequence number) is the unique, ordered position in the WAL.
Critically, this event only appears in the CDC stream after the transaction has committed. There is no way to get a CDC event for a write that was rolled back. This gives you a guarantee you cannot get from application code: if you see the event, the data is in the database.
The Architecture
The pattern has three components:
| Component | Responsibility |
|---|---|
| Postgres (logical replication slot) | Publishes committed changes to a replication stream |
| CDC consumer service | Reads the WAL stream, filters relevant tables/events, transforms payloads |
| Webhook gateway | Accepts the normalized event, persists it, handles delivery, retry, and routing |
The CDC consumer sits between your database and the webhook delivery layer. It is stateless except for its position in the WAL (the replication slot LSN). If it crashes, it resumes from the last acknowledged LSN — no events are skipped.
Postgres WAL
│
pgoutput plugin
│
CDC Consumer
(filter + transform)
│
POST /ingest/{token} ◄── GetHook (or your webhook gateway)
│
Delivery Worker
│
Customer endpointsSetting Up Logical Replication in Postgres
First, enable logical replication in postgresql.conf:
wal_level = logical
max_replication_slots = 4
max_wal_senders = 4Create a publication for the tables you want to capture:
-- Publish all changes to the orders and subscriptions tables
CREATE PUBLICATION webhook_pub
FOR TABLE orders, subscriptions
WITH (publish = 'insert, update, delete');Create a replication slot using the pgoutput plugin (built into Postgres 10+, no external dependency):
SELECT pg_create_logical_replication_slot(
'webhook_cdc_slot',
'pgoutput'
);The slot is what lets Postgres know how far your consumer has read. Postgres retains WAL segments until the slot's confirmed LSN advances. Do not create a replication slot without a consumer actively reading it — unconsumed slots cause WAL to accumulate on disk indefinitely.
Writing a Minimal CDC Consumer in Go
The pglogrepl library (github.com/jackc/pglogrepl) provides a clean interface for reading Postgres logical replication streams. Here is a minimal consumer that reads changes and dispatches them to a webhook gateway:
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"bytes"
"time"
"github.com/jackc/pglogrepl"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgproto3"
)
const slotName = "webhook_cdc_slot"
const publicationName = "webhook_pub"
func main() {
conn, err := pgconn.Connect(context.Background(), "postgres://user:pass@localhost/mydb?replication=database")
if err != nil {
panic(err)
}
defer conn.Close(context.Background())
// Start replication from the current WAL position.
sysident, _ := pglogrepl.IdentifySystem(context.Background(), conn)
startLSN := sysident.XLogPos
err = pglogrepl.StartReplication(context.Background(), conn, slotName, startLSN,
pglogrepl.StartReplicationOptions{
PluginArgs: []string{
"proto_version '1'",
fmt.Sprintf("publication_names '%s'", publicationName),
},
},
)
if err != nil {
panic(err)
}
for {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(10*time.Second))
msg, err := conn.ReceiveMessage(ctx)
cancel()
if err != nil {
// Timeout is normal — no new WAL data. Continue.
continue
}
switch m := msg.(type) {
case *pgproto3.CopyData:
walMsg, err := pglogrepl.ParseWALData(m.Data)
if err != nil {
continue
}
if insert, ok := walMsg.(*pglogrepl.InsertMessage); ok {
dispatchWebhook(insert)
}
}
}
}
func dispatchWebhook(msg *pglogrepl.InsertMessage) {
payload, _ := json.Marshal(map[string]any{
"table": msg.RelationName,
"op": "insert",
"data": msg.Tuple,
})
http.Post(
"https://ingest.gethook.to/ingest/src_your_token_here",
"application/json",
bytes.NewReader(payload),
)
}This is a simplified example. A production consumer needs to:
- ›Track the confirmed LSN and advance it after successful dispatch (so the replication slot does not grow indefinitely)
- ›Handle
UpdateMessageandDeleteMessagein addition toInsertMessage - ›Decode column values using the relation message's type information
- ›Implement backpressure if the downstream webhook gateway is slow
Filtering and Transforming Events
Not every row change should become a webhook. A CDC consumer is the right place to apply business logic:
func shouldDispatch(table string, op string, row map[string]any) bool {
switch table {
case "orders":
// Only dispatch when status transitions to a terminal state.
if op == "update" {
status, _ := row["status"].(string)
return status == "fulfilled" || status == "cancelled"
}
return op == "insert"
case "subscriptions":
return op == "insert" || op == "delete"
}
return false
}Transformation is equally important. Your internal column names (total_cents, cust_uuid) should not leak directly into customer-facing webhook payloads. The CDC consumer is where you build the canonical event shape that your customers depend on.
The Outbox Pattern vs. CDC: When to Use Each
CDC is not the only way to solve the dual-write problem. The transactional outbox pattern is the more common alternative:
| Approach | How it works | Trade-offs |
|---|---|---|
| Transactional outbox | Write events to an outbox table in the same transaction as the business data; a separate poller reads and dispatches them | Simpler to implement; requires schema changes; polling adds latency |
| CDC (WAL-based) | Read changes directly from the WAL; no schema changes to business tables | No polling lag; no schema changes; more operational complexity (replication slots, consumer process) |
| Application-layer dispatch | Call the webhook endpoint directly in the service handler | Simplest code; no durability guarantee across the write/dispatch boundary |
For most teams, the transactional outbox is the right starting point. It does not require a replication slot, is easier to reason about, and works with any database that supports transactions. Move to CDC when you need sub-second event latency, want zero changes to business table schemas, or are capturing changes from a system you don't control (a third-party Postgres instance, a legacy system).
Operational Concerns
Replication slot lag. Monitor pg_replication_slots.confirmed_flush_lsn against pg_current_wal_lsn(). If the gap grows, your consumer is falling behind and Postgres is retaining WAL on disk.
SELECT
slot_name,
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)
) AS lag
FROM pg_replication_slots;Alert if this exceeds a few hundred megabytes. Left unchecked, a stalled replication slot can fill your disk.
Schema changes. Adding a column to a published table is safe. Dropping a column or changing a type that the CDC consumer reads will break deserialization. Always update the consumer before deploying schema changes that affect published tables.
Deduplication. If your CDC consumer restarts without having confirmed the LSN, it will re-read and re-dispatch events. Your webhook gateway must handle duplicate deliveries gracefully. GetHook deduplicates on a configurable idempotency key — map the LSN or the row's primary key to this field so re-dispatches are absorbed rather than re-delivered.
When CDC Is Worth It
CDC is not the default answer. It adds an operational component that requires maintenance: a replication slot, a consumer process, and a Postgres configuration change. For most teams shipping their first webhook integration, the transactional outbox is easier and nearly as reliable.
CDC earns its complexity when:
- ›You need delivery latency under one second (outbox polling typically adds 1–5 seconds)
- ›You are capturing changes from tables you cannot add columns to
- ›You want a single, consistent event pipeline that captures all data changes without modifying application code
- ›You are building on top of an existing system where adding outbox writes to every mutation path is impractical
If you are at that point, the WAL is one of the most reliable event sources you can build on. The data is already there; CDC is just a reader.
Once your CDC consumer is dispatching events, hand the delivery problem to a dedicated webhook gateway. GetHook handles the retry logic, secret management, and per-destination routing so your consumer stays focused on reading the WAL correctly.