# How to store webhooks data

> Why you should persist your MailBlastr webhook events, what to store, and a complete native-HTTP handler that verifies the signature and inserts each event into your own database.

Every email you send through MailBlastr can generate real-time events — was it delivered, opened, clicked, or did it bounce? These events are delivered to your [webhook endpoint](https://www.mailblastr.com/docs/webhooks/overview), but by default they are **ephemeral**: MailBlastr does not keep them forever.

To make the data durable and queryable, store each event in your own datasource as it arrives. This guide covers **why** you should, **what** to keep, and a working handler that **verifies and inserts** each event.

## Why store webhook data?

### Meet compliance requirements

Many industries regulate retention and audit trails — GDPR (proving what was sent and when), SOC 2 (delivery verification), and financial rules (retaining transactional email for years). Owning the data gives you full control over retention periods and access controls.

### Enable long-term retention

MailBlastr retains email event data for a limited window. If you need history beyond that window, storing events in your own database ensures you never lose it.

### Power automated workflows

With events in your database you can build automations such as:

- Automatically suppress bounced addresses from future sends.
- Trigger follow-up emails based on open or click behavior.
- Alert your team when delivery failures spike.
- Re-engage contacts who haven't opened recent messages.

## What data should you store?

At minimum, store enough to deduplicate and to link an event back to the original send:

- **Event id** — the `svix-id` header, used as the unique idempotency key.
- **Event type** — what happened, e.g. `email.delivered`, `email.bounced`, `email.opened`.
- **Timestamp** — when the event occurred (`created_at` in the payload).
- **Email id** — `data.email_id`, which links the event to the original email.

For richer analytics, also keep recipient addresses, subject lines, tags, bounce details, and clicked URLs.

## A table to store events

A single events table is enough to get started. The unique constraint on `svix_id` is what makes inserts idempotent — a retried delivery hits the conflict and is skipped.

**schema.sql (PostgreSQL)**

```sql
CREATE TABLE webhook_events (
  id                  BIGSERIAL PRIMARY KEY,
  svix_id             TEXT NOT NULL UNIQUE,   -- idempotency key (svix-id header)
  event_type          TEXT NOT NULL,          -- e.g. 'email.delivered'
  email_id            TEXT,                   -- data.email_id, links to the send
  recipient           TEXT,                   -- first 'to' address, for filtering
  payload             JSONB NOT NULL,         -- the full event body
  event_created_at    TIMESTAMPTZ NOT NULL,   -- payload created_at
  webhook_received_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_webhook_events_email_id ON webhook_events (email_id);
CREATE INDEX idx_webhook_events_type     ON webhook_events (event_type);
```

## A handler that verifies and inserts

The handler below verifies the signature manually over the **raw** request body, then inserts the event, ignoring duplicates. (In Node you can also verify with the SDK — `mb.webhooks.verify(rawBody, headers, secret)` — see [Verify webhooks](https://www.mailblastr.com/docs/webhooks/verify).) MailBlastr signs each delivery with the Svix scheme: an `svix-id`, an `svix-timestamp`, and an `svix-signature` header, signed with your endpoint secret (`whsec_...`).

> **Warning:** Always verify against the exact bytes you received. If you parse the JSON before verifying, the re-serialized body will differ from what was signed and verification will fail. Read the raw body first.

**Node.js**

```js
import { createServer } from 'node:http';
import crypto from 'node:crypto';
import { Pool } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Signing secret from the dashboard, e.g. 'whsec_...'. Drop the 'whsec_' prefix
// and base64-decode the rest to get the raw signing key.
const secret = Buffer.from(process.env.MB_WEBHOOK_SECRET.split('_')[1], 'base64');

function verify(headers, rawBody) {
  const id = headers['svix-id'];
  const ts = headers['svix-timestamp'];
  const signed = `${id}.${ts}.${rawBody}`;
  const expected = crypto.createHmac('sha256', secret).update(signed).digest('base64');
  // svix-signature is a space-separated list of 'v1,<sig>' pairs.
  return headers['svix-signature']
    .split(' ')
    .some((part) => {
      const sig = part.split(',')[1];
      return sig && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
    });
}

createServer((req, res) => {
  if (req.method !== 'POST') { res.writeHead(405).end(); return; }
  let raw = '';
  req.on('data', (c) => { raw += c; });           // collect the RAW body
  req.on('end', async () => {
    if (!verify(req.headers, raw)) { res.writeHead(401).end('invalid signature'); return; }

    const event = JSON.parse(raw);
    await pool.query(
      `INSERT INTO webhook_events
         (svix_id, event_type, email_id, recipient, payload, event_created_at)
       VALUES ($1, $2, $3, $4, $5, $6)
       ON CONFLICT (svix_id) DO NOTHING`,   // idempotent: skip retries
      [
        req.headers['svix-id'],
        event.type,
        event.data?.email_id ?? null,
        event.data?.to?.[0] ?? null,
        event,
        event.created_at,
      ],
    );

    res.writeHead(200).end('ok');             // always 2xx so MailBlastr stops retrying
  });
}).listen(3000);
```

**Python**

```python
import os, json, hmac, hashlib, base64
from http.server import BaseHTTPRequestHandler, HTTPServer
import psycopg

conn = psycopg.connect(os.environ["DATABASE_URL"], autocommit=True)
# 'whsec_...' -> base64-decode the part after the underscore.
secret = base64.b64decode(os.environ["MB_WEBHOOK_SECRET"].split("_", 1)[1])

def verify(headers, raw: bytes) -> bool:
    signed = f"{headers['svix-id']}.{headers['svix-timestamp']}.{raw.decode()}".encode()
    expected = base64.b64encode(hmac.new(secret, signed, hashlib.sha256).digest()).decode()
    # svix-signature is space-separated 'v1,<sig>' pairs.
    for part in headers["svix-signature"].split(" "):
        sig = part.split(",", 1)[-1]
        if hmac.compare_digest(sig, expected):
            return True
    return False

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        raw = self.rfile.read(int(self.headers["Content-Length"]))  # RAW body
        if not verify(self.headers, raw):
            self.send_response(401); self.end_headers(); return

        event = json.loads(raw)
        conn.execute(
            """INSERT INTO webhook_events
                 (svix_id, event_type, email_id, recipient, payload, event_created_at)
               VALUES (%s, %s, %s, %s, %s, %s)
               ON CONFLICT (svix_id) DO NOTHING""",
            (
                self.headers["svix-id"],
                event["type"],
                event.get("data", {}).get("email_id"),
                (event.get("data", {}).get("to") or [None])[0],
                json.dumps(event),
                event["created_at"],
            ),
        )
        self.send_response(200); self.end_headers()  # 2xx stops retries

HTTPServer(("", 3000), Handler).serve_forever()
```

A successful delivery looks like a `POST` from MailBlastr carrying the signing headers:

```bash
POST /webhooks HTTP/1.1
Host: your-app.example.com
svix-id: msg_2abc...
svix-timestamp: 1719500000
svix-signature: v1,g0hM9SsE...
Content-Type: application/json

{ "type": "email.delivered", "created_at": "2026-06-27T10:00:00.000Z", "data": { "email_id": "8f5c2a1e-...", "to": ["user@example.com"] } }
```

> **Note:** Return a `2xx` as soon as you have stored the event. If your endpoint is slow or errors, MailBlastr retries with backoff — see [Retries and replays](https://www.mailblastr.com/docs/webhooks/retries-and-replays).

## Retention and privacy

Webhook data can contain personal information (recipient addresses, and IP/location data from opens and clicks). Decide on a retention window up front, restrict access, and have a path for deletion requests. For operational debugging, 30–90 days is often enough; compliance may require keeping records far longer.

Rather than building everything yourself, you can deploy the [Webhook ingester](https://www.mailblastr.com/docs/webhooks/ingester), which wraps the verify-and-insert pattern above behind ready-made database connectors.
