ulak
ulak is a PostgreSQL extension for the transactional outbox pattern. You call ulak.send() inside the same transaction as your business write, the message lands in ulak.queue alongside the rest of the data, and background workers inside PostgreSQL deliver it afterwards. If the transaction rolls back, the message never existed. That's the whole idea, and it gets rid of the dual-write problem without a CDC stack.
I built it because I kept writing the same outbox relay in every service, with the same retry loop, the same backoff and the same dead-letter table, and each copy came out slightly different. ulak means "messenger" in Turkish. The queue write is atomic with your data, and retries, backoff, circuit breaking, the DLQ, archive and redrive live in the extension, not in your application code.
How it runs
Workers poll ulak.queue with FOR UPDATE SKIP LOCKED, so lots of them can drain the same queue without stepping on each other, and they send straight from the database. HTTP is built in. Kafka, MQTT, Redis Streams, AMQP and NATS are optional adapters with the same lifecycle, compiled in with ENABLE_KAFKA=1, ENABLE_MQTT=1, ENABLE_REDIS=1, ENABLE_AMQP=1 or ENABLE_NATS=1, and each needs its client library at build time. Writes to the local queue happen exactly once, and delivery to whatever's on the other end is at least once.
Use it when PostgreSQL is your source of truth and you want outbox semantics next to the data, or when you're tired of rebuilding webhook retries and DLQ handling in every service. Skip it if you already run Debezium and you're happy with it, if your problem is large-scale event streaming and not outbox delivery, or if you don't want workers and delivery policy living inside your database. I mean those as real trade-offs.
Five minutes to a webhook
It's a background worker extension, so PostgreSQL has to load it when it starts:
shared_preload_libraries = 'ulak'Then, in the database:
CREATE EXTENSION ulak;
SELECT ulak.send(
'https://example.com/webhooks/orders',
jsonb_build_object('order_id', 42, 'status', 'paid')
);The row commits with your transaction. A worker picks it up and delivers it, retries with backoff if the endpoint is down, and parks it in the dead-letter queue if the endpoint never comes back. The other adapters, the retry policy and the operations views are in the README. It supports PostgreSQL 14 through 18.