Back to Blog
CDCchange data capturereliabilityPostgreSQLarchitecture

Change Data Capture: Triggering Webhooks Directly from Your Database

Application-layer webhook dispatch is brittle — if the process crashes between the DB write and the HTTP call, the event is lost. Change Data Capture lets you emit webhooks from the database transaction log itself, making delivery as durable as the write.

P
Priya Nair
Developer Advocate
April 25, 2026
10 min read

There is a classic reliability bug hiding in most webhook-emitting codebases. It looks like this:

go
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:

json
{
	"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:

ComponentResponsibility
Postgres (logical replication slot)Publishes committed changes to a replication stream
CDC consumer serviceReads the WAL stream, filters relevant tables/events, transforms payloads
Webhook gatewayAccepts 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 endpoints

Setting Up Logical Replication in Postgres

First, enable logical replication in postgresql.conf:

wal_level = logical
max_replication_slots = 4
max_wal_senders = 4

Create a publication for the tables you want to capture:

sql
-- 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):

sql
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:

go
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 UpdateMessage and DeleteMessage in addition to InsertMessage
  • 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:

go
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:

ApproachHow it worksTrade-offs
Transactional outboxWrite events to an outbox table in the same transaction as the business data; a separate poller reads and dispatches themSimpler to implement; requires schema changes; polling adds latency
CDC (WAL-based)Read changes directly from the WAL; no schema changes to business tablesNo polling lag; no schema changes; more operational complexity (replication slots, consumer process)
Application-layer dispatchCall the webhook endpoint directly in the service handlerSimplest 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.

sql
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.

Start routing CDC events with GetHook →

Stop losing webhook events.

GetHook gives you reliable delivery, automatic retry, and full observability — in minutes.