Skip to content

Database Storage for Webhooks — Complete Guide

DodaTech Updated 2026-06-28 8 min read

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

Should I use MongoDB or PostgreSQL for webhook storage?

PostgreSQL is better for relational data (subscribers, events, logs with joins). MongoDB is better for flexible, document-oriented storage. Many systems use PostgreSQL for primary storage and Redis for caching.

How do I handle database connection pooling for webhook workers?

Use PgBouncer for PostgreSQL connection pooling. Redis handles connections efficiently with single-threaded event loop. Set max pool size to handle peak webhook traffic.

Can I use S3 for webhook event storage?

Yes. Store raw webhook payloads in S3. PostgreSQL stores metadata and S3 URL. This reduces database storage costs. S3 is ideal for compliance archives and infrequently accessed data.

How do I query webhook delivery history efficiently?

Index on subscriber_id + created_at DESC. Use pagination (cursor-based). Limit result set to 30-90 days. Archive older data to keep the active table small.

What is the best database for rate limiting counters?

Redis. Use INCR with EXPIRE for per-minute counters. Atomic operations at microsecond latency. Lua scripts for complex rate limiting logic. Automatic key expiration for cleanup.

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