A reliable serverless webhook endpoint authenticates the request, commits the event to durable storage, and only then returns success. Process the saved event in a separate, tracked job. PostgreSQL can provide that durable inbox without adding a queue to a small application.
This tutorial builds a Node.js receiver and a bounded worker using a synthetic event format. It suits integration callbacks, catalogue synchronisation and notification ingestion. It does not promise exactly-once delivery: the sender may retry, acknowledgements can disappear, and external side effects need their own idempotency strategy.
Make acknowledgement mean “saved”
The dangerous sequence is to return HTTP 200 and then start an untracked promise, thread or subprocess. A replica can stop after the response; the sender sees success while the work disappears. Keeping minimum replicas above zero does not turn process memory into a durable queue.
Instead, the request path verifies the signature and validates a small envelope, then inserts it into PostgreSQL. The successful insert is an autocommitted statement. Only after that promise resolves does the server return 202. If storage fails, it returns 503 so a sender whose delivery policy retries temporary failures can try again. Check the actual sender’s timeout and retry policy before integrating.
The event ID is inside the signed JSON body and is the table’s primary key. A repeated ID receives the same successful acknowledgement without creating another inbox row. Here one table belongs to one integration. For several independent providers or tenants, scope the uniqueness key and the authentication secret to that integration; an event ID alone is not a cross-provider namespace.
Prerequisites and runnable files
Use Node.js 22 or newer, PostgreSQL, and an empty database you own for the initial exercise. Download the complete example, tests and Dockerfile. Install its pinned dependency with npm ci. The code uses pg@8.16.3; choose upgrades through your normal dependency review.
Set DATABASE_URL and a random WEBHOOK_SECRET of at least 24 characters through your secret configuration. Do not put credentials in the Dockerfile, committed files or copied terminal transcripts. For a remote database, configure verified TLS using the provider’s actual endpoint and CA instructions. node-postgres pooling documentation explains why a small reusable pool is preferable to opening a connection for every request.
1. Create the durable inbox
CREATE TABLE webhook_inbox (
event_id text PRIMARY KEY,
payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz
);
CREATE INDEX webhook_pending ON webhook_inbox(received_at)
WHERE processed_at IS NULL;
CREATE TABLE demo_events (event_id text PRIMARY KEY, kind text NOT NULL);Apply this schema once using your migration process. For the disposable local exercise, psql "$DATABASE_URL" -f schema.sql is sufficient. The partial index keeps pending-event selection focused. The projection table deliberately has the same unique event key: processing an accepted event twice should not duplicate its local effect.
2. Run the receiver or drain the inbox
Save the following as app.mjs. The x-demo-* headers define this example’s protocol: timestamp plus a dot plus the exact body bytes are authenticated with HMAC-SHA256. They are not Stripe, GitHub or another provider’s signature format. Replace the verifier with that provider’s documented scheme before accepting real callbacks.
import http from 'node:http';
import {createHmac, timingSafeEqual} from 'node:crypto';
import {pathToFileURL} from 'node:url';
import {Pool} from 'pg';
export function createReceiver(pool, secret) {
if (!secret || secret.length<24) throw Error('Set a strong WEBHOOK_SECRET');
const server=http.createServer(async(req,res)=>{
const reply=(status)=>{res.writeHead(status);res.end();};
if(req.method==='GET' && req.url==='/healthz') return reply(200);
if(req.method!=='POST'||req.url!=='/webhook') return reply(404);
try {
const chunks=[]; let size=0;
for await(const chunk of req) {
size+=chunk.length;
if(size>262144) return reply(413);
chunks.push(chunk);
}
const raw=Buffer.concat(chunks);
const ts=req.headers['x-demo-timestamp'];
const sig=req.headers['x-demo-signature'];
if(typeof ts!=='string'||!/^\d{10}$/.test(ts)||
Math.abs(Date.now()/1000-Number(ts))>300||
typeof sig!=='string'||!/^[a-f0-9]{64}$/.test(sig)) return reply(401);
const expected=createHmac('sha256',secret).update(ts+'.').update(raw).digest();
if(!timingSafeEqual(expected,Buffer.from(sig,'hex'))) return reply(401);
let event;
try {event=JSON.parse(raw.toString('utf8'));} catch {return reply(400);}
if(!event || typeof event.id!=='string' || !/^[A-Za-z0-9_-]{1,128}$/.test(event.id) ||
event.kind!=='demo.created') return reply(400);
await pool.query(`INSERT INTO webhook_inbox(event_id,payload)
VALUES($1,$2) ON CONFLICT(event_id) DO NOTHING`,[event.id,event]);
return reply(202);
} catch {
console.error('webhook persistence failed');
return reply(503);
}
});
server.requestTimeout=10000;
return server;
}
export async function drain(pool) {
let processed=0;
// shortcut: bounded local projection, add a dead-letter policy before complex handlers.
for(let n=0;n<100;n++) {
const client=await pool.connect();
try {
await client.query('BEGIN');
await client.query("SET LOCAL statement_timeout='5s'");
const {rows}=await client.query(`SELECT event_id,payload FROM webhook_inbox
WHERE processed_at IS NULL ORDER BY received_at
LIMIT 1 FOR UPDATE SKIP LOCKED`);
if(!rows.length) {await client.query('COMMIT');break;}
const row=rows[0];
await client.query(`INSERT INTO demo_events(event_id,kind) VALUES($1,$2)
ON CONFLICT(event_id) DO NOTHING`,[row.event_id,row.payload.kind]);
await client.query('UPDATE webhook_inbox SET processed_at=now() WHERE event_id=$1',[row.event_id]);
await client.query('COMMIT');
processed++;
} catch(error) {
await client.query('ROLLBACK');
throw error;
} finally {client.release();}
}
return processed;
}
if(process.argv[1] && import.meta.url===pathToFileURL(process.argv[1]).href) {
if(!process.env.DATABASE_URL) throw Error('DATABASE_URL required');
const pool=new Pool({connectionString:process.env.DATABASE_URL,max:2,
connectionTimeoutMillis:3000,idleTimeoutMillis:10000,statement_timeout:5000});
pool.on('error',()=>console.error('idle database connection failed'));
if(process.argv[2]==='drain') {
try {console.log(JSON.stringify({processed:await drain(pool)}));}
catch {console.error('drain failed');process.exitCode=1;}
finally {await pool.end();}
} else {
const server=createReceiver(pool,process.env.WEBHOOK_SECRET);
server.listen(Number(process.env.PORT??8080),'0.0.0.0');
process.on('SIGTERM',()=>server.close(()=>pool.end()));
}
}The verifier enforces a five-minute freshness window, rejects malformed signatures, compares equal-length digests with timingSafeEqual, and limits bodies to 256 KiB. Synchronise the receiver’s clock. A legitimate provider redelivering an old event may sign the new delivery with a current timestamp; do not substitute an event creation time for its documented delivery timestamp. See Node.js cryptographic comparison guidance.
The worker handles at most 100 events and exits. A row lock is held while both the projection insert and processed marker commit in one transaction. PostgreSQL documents SKIP LOCKED for queue-like consumers; it deliberately skips work another transaction holds. All transaction statements must use the same client.
3. Send a signed event and replay it
import {createHmac} from 'node:crypto';
if(!process.env.WEBHOOK_SECRET) throw Error('WEBHOOK_SECRET required');
const body=JSON.stringify({id:'event-demo-1',kind:'demo.created'});
const ts=String(Math.floor(Date.now()/1000));
const signature=createHmac('sha256',process.env.WEBHOOK_SECRET).update(ts+'.').update(body).digest('hex');
const response=await fetch(process.env.WEBHOOK_URL??'http://127.0.0.1:8080/webhook',{
method:'POST',headers:{'content-type':'application/json','x-demo-timestamp':ts,'x-demo-signature':signature},body});
console.log(response.status);
if(!response.ok) process.exitCode=1;Start node app.mjs, then execute node send.mjs twice. Both requests should return 202. Run node app.mjs drain twice: the first invocation reports one processed event and the second zero. Inspect webhook_inbox and demo_events in your disposable database to confirm the state. Changing body bytes without recomputing the signature should return 401.
The downloadable integration test checks invalid signatures, expired timestamps, malformed JSON, oversize bodies, duplicate delivery, repeated draining and storage failure. Its database URL guard requires a dedicated loopback test database because one failure test drops the inbox table. Never point it at an application database. These local HTTP and SQL checks do not certify any real webhook provider’s behaviour.
4. Put HTTP traffic and finite work on separate paths
Build the included Dockerfile and deploy the image through DanubeData Rapids. Keep the normal command as node app.mjs and configure port 8080. Set environment values in the container’s configuration, choose a bounded replica maximum, and verify the public HTTPS endpoint before registering it with a sender. The example image explicitly selects a non-root user; that is an application setting, not a claim about every image the platform runs.
For accounts with Rapids Runs preview access, the same image can execute ["node","app.mjs","drain"] as a tracked finite run. A schedule can invoke that command periodically. Scheduled runs use the container environment; schedules do not store environment overrides. One queued or running run is allowed per container, and overlapping occurrences are skipped rather than retried. Choose an interval that drains the expected backlog.
A scheduled drain trades latency for simplicity. If events must be handled immediately, need large throughput, or require sophisticated retries, use a dedicated queue and appropriately supervised consumers. Do not silently turn the receiver into a background worker after sending 202.
Keep the failure modes visible
Monitor the oldest pending event age, pending count, worker failures and database acquisition time. A poison event rolls this simple worker back and ends the run non-zero. Investigate it, repair or quarantine it explicitly, and re-run. Add a dead-letter policy before adding handlers that commonly fail. Keep payload retention limited to the purpose of the integration, restrict database access, and avoid writing payloads or secrets into logs.
The transaction protects the two local database writes. It cannot atomically commit an email, payment or third-party API request with PostgreSQL. For those effects, persist an outbox record in the same transaction and dispatch it with a stable idempotency key accepted by the destination. A timeout after an external success is an ambiguous outcome, not proof that nothing happened.
Budget database connections across all HTTP replicas and the drain job. For the wider connection model, read database connection pooling explained. Keep a tested recovery path for the inbox using PostgreSQL backup planning; persistence and a unique key do not replace backups.
Frequently asked questions
Does 202 mean the business action finished?
No. In this design it means the validated event was accepted into the durable inbox. Completion is represented by the processed marker and the projection transaction, which you monitor separately.
Can PostgreSQL replace a message broker?
For a modest inbox, often yes. It gives you transactions, uniqueness and inspectable backlog state. A broker becomes attractive when you need routing, high fan-out, delivery controls or independent consumer scaling.
Can I acknowledge if PostgreSQL is unavailable?
Only if another durable system has accepted responsibility for the event. This example returns 503. Returning success while keeping the event only in memory loses the durable hand-off.
Do scheduled Runs automatically retry failed events?
No platform retry is implied. This worker leaves uncommitted rows pending, and a later invocation may revisit them. You own retry limits, poison-event handling and the decision to replay external effects.
Sources
- docs.danubedata.ro/rapids-runs
- docs.danubedata.ro/serverless-overview
- node-postgres.com/features/pooling
- node-postgres.com/features/transactions
- nodejs.org/api/crypto.html#cryptotimingsafeequala-b
- www.postgresql.org/docs/current/sql-select.html