Database Storage for Webhooks — Complete Guide
In this tutorial, you will learn about Database Storage for Webhooks. We cover key concepts, practical examples, and best practices to help you master this topic.
Learn database storage for webhooks: design schemas for webhook events, delivery logs, and subscriber management. Compare PostgreSQL, MongoDB, and Redis for webhook data persistence.
What You Learn
You will learn how to design database schemas for webhook systems, store incoming webhook events, track delivery logs, manage subscribers, implement data retention policies, and choose the right database for different webhook storage needs.
Why It Matters
Webhook data must be stored for auditing, debugging, retry, and compliance. Without proper storage, you cannot investigate delivery failures, prove delivery for legal requirements, or retry failed webhooks. Database choice and schema design directly impact query performance and storage costs.
Real-World Use
DodaTech's webhook system stores 500K events daily across PostgreSQL and Redis. PostgreSQL stores permanent audit logs with 90-day retention. Redis stores idempotency keys with 24-hour TTL. This hybrid approach balances durability with performance.
PostgreSQL Schema
-- Subscribers table
CREATE TABLE webhook_subscribers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
url TEXT NOT NULL,
secret TEXT NOT NULL,
events TEXT[] NOT NULL DEFAULT '{}',
is_active BOOLEAN DEFAULT true,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_subscribers_events
ON webhook_subscribers USING GIN (events);
CREATE INDEX idx_subscribers_active
ON webhook_subscribers (is_active) WHERE is_active = true;
-- Webhook events table
CREATE TABLE webhook_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type TEXT NOT NULL,
source TEXT NOT NULL,
payload JSONB NOT NULL,
headers JSONB,
idempotency_key TEXT UNIQUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_events_type
ON webhook_events (event_type);
CREATE INDEX idx_events_created
ON webhook_events (created_at DESC);
CREATE INDEX idx_events_idempotency
ON webhook_events (idempotency_key);
-- Delivery logs table
CREATE TABLE webhook_delivery_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID REFERENCES webhook_events(id),
subscriber_id UUID REFERENCES webhook_subscribers(id),
status TEXT NOT NULL CHECK (status IN (
'pending', 'delivered', 'failed', 'retrying'
)),
attempt INTEGER DEFAULT 1,
max_attempts INTEGER DEFAULT 5,
response_status INTEGER,
response_body TEXT,
error_message TEXT,
duration_ms INTEGER,
next_retry_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE INDEX idx_delivery_status
ON webhook_delivery_logs (status, next_retry_at)
WHERE status IN ('pending', 'retrying');
CREATE INDEX idx_delivery_event
ON webhook_delivery_logs (event_id);
CREATE INDEX idx_delivery_subscriber
ON webhook_delivery_logs (subscriber_id, created_at DESC);
Expected output: Normalized schema with foreign keys between events and delivery logs. Indexes support fast queries for pending retries, event lookup, and subscriber history. JSONB stores flexible payloads.
PostgreSQL Queries
-- Find webhooks needing retry
SELECT dl.*, s.url, s.secret, e.payload
FROM webhook_delivery_logs dl
JOIN webhook_subscribers s ON dl.subscriber_id = s.id
JOIN webhook_events e ON dl.event_id = e.id
WHERE dl.status = 'retrying'
AND dl.next_retry_at <= NOW()
AND dl.attempt < dl.max_attempts
ORDER BY dl.next_retry_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
-- Delivery success rate by subscriber
SELECT
s.id,
s.url,
COUNT(dl.id) AS total_deliveries,
COUNT(dl.id) FILTER (WHERE dl.status = 'delivered') AS successful,
ROUND(
COUNT(dl.id) FILTER (WHERE dl.status = 'delivered')::numeric /
NULLIF(COUNT(dl.id), 0) * 100, 2
) AS success_rate
FROM webhook_subscribers s
LEFT JOIN webhook_delivery_logs dl ON s.id = dl.subscriber_id
GROUP BY s.id, s.url
ORDER BY success_rate ASC NULLS LAST;
-- Recent events with delivery status
SELECT
e.id AS event_id,
e.event_type,
e.created_at,
COUNT(dl.id) AS delivery_attempts,
BOOL_AND(dl.status = 'delivered') AS all_delivered
FROM webhook_events e
LEFT JOIN webhook_delivery_logs dl ON e.id = dl.event_id
WHERE e.created_at >= NOW() - INTERVAL '1 hour'
GROUP BY e.id
ORDER BY e.created_at DESC;
Expected output: Queries efficiently find pending retries with row locking, calculate delivery success rates, and summarize recent event delivery status. Indexes ensure fast execution on large tables.
MongoDB Schema
// MongoDB collections for webhook storage
const subscriberSchema = {
url: String,
secret: String,
events: [String],
isActive: Boolean,
description: String,
createdAt: Date,
updatedAt: Date,
};
subscriberSchema.index({ events: 1 });
subscriberSchema.index({ isActive: 1 });
const eventSchema = {
eventType: String,
source: String,
payload: Object,
headers: Object,
idempotencyKey: { type: String, unique: true, sparse: true },
createdAt: Date,
};
eventSchema.index({ eventType: 1, createdAt: -1 });
eventSchema.index({ createdAt: -1 });
const deliveryLogSchema = {
eventId: { type: ObjectId, ref: 'Event' },
subscriberId: { type: ObjectId, ref: 'Subscriber' },
status: { type: String, enum: ['pending', 'delivered', 'failed', 'retrying'] },
attempt: Number,
maxAttempts: Number,
responseStatus: Number,
responseBody: String,
errorMessage: String,
durationMs: Number,
nextRetryAt: Date,
createdAt: Date,
completedAt: Date,
};
deliveryLogSchema.index({ status: 1, nextRetryAt: 1 });
deliveryLogSchema.index({ eventId: 1 });
deliveryLogSchema.index({ subscriberId: 1, createdAt: -1 });
Expected output: MongoDB schema uses embedded documents and references. Sparse unique index on idempotencyKey allows null values for events without keys. Compound indexes support common query patterns.
Redis Storage
const redis = require('redis');
class WebhookRedisStore {
constructor(client) {
this.client = client;
}
// Idempotency - 24 hour TTL
async checkIdempotency(key) {
const result = await this.client.set(
`wh:idemp:${key}`,
'1',
{ EX: 86400, NX: true }
);
return result !== null;
}
// Pending retry queue - sorted by retry time
async addToRetryQueue(eventId, subscriberId, retryAt) {
const score = new Date(retryAt).getTime();
await this.client.zadd('wh:retry:queue', score, `${eventId}:${subscriberId}`);
}
async getPendingRetries(limit = 100) {
const now = Date.now();
const entries = await this.client.zrangebyscore(
'wh:retry:queue',
0,
now,
{ LIMIT: [0, limit] }
);
if (entries.length > 0) {
await this.client.zremrangebyscore('wh:retry:queue', 0, now);
}
return entries.map(e => {
const [eventId, subscriberId] = e.split(':');
return { eventId, subscriberId };
});
}
// Recent events - capped list
async addRecentEvent(event) {
await this.client.lpush('wh:recent:events', JSON.stringify(event));
await this.client.ltrim('wh:recent:events', 0, 999); // Keep 1000
}
async getRecentEvents(count = 50) {
const events = await this.client.lrange('wh:recent:events', 0, count - 1);
return events.map(e => JSON.parse(e));
}
// Rate limiting
async checkRateLimit(subscriberId, maxPerMinute = 60) {
const key = `wh:ratelimit:${subscriberId}`;
const current = await this.client.incr(key);
if (current === 1) {
await this.client.expire(key, 60);
}
return current <= maxPerMinute;
}
}
Expected output: Redis stores time-sensitive data with TTL: idempotency keys expire after 24 hours, retry queue uses sorted sets by timestamp, recent events use capped lists, rate limits use counter with expiry.
Data Retention
// Retention policy manager
class WebhookRetentionManager {
constructor(db) {
this.db = db;
}
async applyRetentionPolicy() {
const now = new Date();
const policies = [
{ table: 'webhook_events', retentionDays: 90 },
{ table: 'webhook_delivery_logs', retentionDays: 90 },
{ table: 'webhook_idempotency', retentionDays: 30 },
];
for (const policy of policies) {
const cutoff = new Date(
now.getTime() - policy.retentionDays * 86400000
);
const deleted = await this.db.query(
`DELETE FROM ${policy.table}
WHERE created_at < $1`,
[cutoff]
);
console.log(
`Cleaned ${deleted.rowCount} rows from ${policy.table}`
);
}
}
// Archive before delete
async archiveEvents(before) {
const events = await this.db.query(
`SELECT * FROM webhook_events WHERE created_at < $1`,
[before]
);
// Write to archive storage (S3, cold storage)
await this.writeToArchive('webhooks-archive', events.rows);
// Then delete from primary
await this.db.query(
`DELETE FROM webhook_events WHERE created_at < $1`,
[before]
);
}
}
Expected output: Retention policies delete old data after configured periods. Critical data is archived to cold storage before deletion. Retention periods vary by data type: events 90 days, idempotency 30 days.
Common Mistakes
1. No Index on Status and Retry Time
The retry worker queries for pending webhooks ordered by retry time. Without a composite index, this query scans the entire table. Create an index on (status, next_retry_at) for efficient retry polling.
2. Storing Full Payloads in Delivery Logs
Duplicating the event payload in every delivery log entry wastes storage. Store the payload once in the events table. Reference it via event_id in delivery logs. Join when payload is needed.
3. Not Partitioning Large Tables
Webhook tables grow by millions of rows per month. Partition by date range for efficient deletion and query performance. Old partitions can be archived or detached without blocking writes.
4. Single Database for All Workloads
Idempotency checks need fast reads (Redis). Audit logs need durability (PostgreSQL). Real-time dashboards need fast aggregations (Redis or Elasticsearch). Use multiple databases optimized for each workload.
5. No Data Archive Strategy
Deleting old data is permanent. Archive to cold storage (S3, Glacier) before deletion. Include the schema version so archived data can be restored and queried later.
Practice Questions
1. Why use PostgreSQL JSONB for webhook payloads?
JSONB stores flexible, schema-less payloads from different providers. It supports indexing and querying within the JSON structure. Schema changes do not require migrations.
2. What is the purpose of FOR UPDATE SKIP LOCKED in retry queries?
It locks selected rows so multiple retry workers do not Process the same webhook. SKIP LOCKED skips rows locked by other workers, preventing contention.
3. Why store idempotency keys in Redis instead of PostgreSQL?
Redis provides automatic TTL expiration, microsecond read/write latency, and atomic SET NX operations. PostgreSQL can work but adds latency and requires manual cleanup.
4. How do you handle data retention for webhook storage?
Set retention periods per table (events: 90 days, logs: 90 days, idempotency: 30 days). Archive before deletion. Use table partitioning by date for efficient cleanup.
Challenge
Design a webhook storage system with: PostgreSQL for events and delivery logs (partitioned by month, 90-day retention, archived to S3), Redis for idempotency keys (24-hour TTL) and retry queue, retention cleanup job running daily, and archive/restore functionality for compliance auditing.
FAQ
Mini Project: Webhook Storage System
Build a storage system with PostgreSQL schema for subscribers, events, and delivery logs. Implement Redis-backed idempotency and retry queue. Create a daily retention job that archives events older than 90 days to S3 and deletes from PostgreSQL. Build a query API for delivery history and statistics.
What's Next
Now that you understand storage, learn about Dead Letter Queue for handling permanently failed webhook deliveries.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro