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, 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-idheader, used as the unique idempotency key. - Event type — what happened, e.g.
email.delivered,email.bounced,email.opened. - Timestamp — when the event occurred (
created_atin 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.
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.) 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_...).
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);A successful delivery looks like a POST from MailBlastr carrying the signing headers:
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"] } }2xx as soon as you have stored the event. If your endpoint is slow or errors, MailBlastr retries with backoff — see 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, which wraps the verify-and-insert pattern above behind ready-made database connectors.