# How to store webhooks data

> Why you should persist your SendPing 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 SendPing can generate real-time events — was it delivered, opened, clicked, or did it bounce? These events are delivered to your [webhook endpoint](https://www.sendping.co/docs/webhooks/overview), but by default they are **ephemeral**: SendPing 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

SendPing 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, 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.sendping.co/docs/webhooks/verify).) SendPing 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_...'. Derive the key the way the
// server does: strip only the 'whsec_' PREFIX -- splitting on '_' truncates any
// secret whose body contains one, and '_' is base64url's '/' -- then base64-decode,
// falling back to the whole secret when that decodes to nothing.
function secretToKey(secret) {
  if (!secret.startsWith('whsec_')) return Buffer.from(secret, 'utf8');
  const decoded = Buffer.from(secret.slice('whsec_'.length), 'base64');
  return decoded.length ? decoded : Buffer.from(secret, 'utf8');
}
const secret = secretToKey(process.env.SENDPING_WEBHOOK_SECRET);

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 SendPing 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)
_B64 = set(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/")

def secret_to_key(secret: str) -> bytes:
    # Match the server byte for byte. It decodes with Node's Buffer.from(s, 'base64'),
    # which is NOT what base64.b64decode does -- b64decode RAISES on an unpadded secret
    # and DISCARDS '-'/'_' instead of translating them, silently yielding a different
    # key than the signer so every genuine delivery is rejected as forged.
    if not secret.startswith("whsec_"):
        return secret.encode()
    # Node reads the LOW BYTE of each UTF-16 code unit, so a non-ASCII character can
    # stand in for a base64 one: 'L' with stroke (U+0141) counts as 'A', and 'L' with
    # caron (U+013D) counts as '='. Mask first, then apply the rest.
    units = secret[len("whsec_"):].encode("utf-16-le", "surrogatepass")[0::2]
    head = units.split(b"=", 1)[0]                        # '=' TERMINATES; it is not padding
    head = head.replace(b"-", b"+").replace(b"_", b"/")   # URL-safe spellings
    chars = bytes(c for c in head if c in _B64)           # anything else is skipped
    if len(chars) % 4 == 1:                               # a lone trailing char encodes nothing
        chars = chars[:-1]
    decoded = base64.b64decode(chars + b"=" * (-len(chars) % 4)) if chars else b""
    return decoded or secret.encode()                     # empty decode -> the whole secret

secret = secret_to_key(os.environ["SENDPING_WEBHOOK_SECRET"])

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 SendPing 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, SendPing retries with backoff — see [Retries](https://www.sendping.co/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.sendping.co/docs/webhooks/ingester), which wraps the verify-and-insert pattern above behind ready-made database connectors.
