# Verify webhook requests

> Every webhook request is signed with HMAC-SHA256 over "<X-SendPing-Timestamp>.<raw body>". Recompute the signature with your signing secret and compare it against the X-SendPing-Signature header.

Because your webhook URL is public, you should verify that each request actually came from SendPing before trusting it. Every delivery includes an `X-SendPing-Timestamp` header (Unix seconds) and an `X-SendPing-Signature` header containing an HMAC-SHA256 signature of **that timestamp, a `.`, and the raw request body**, computed with your endpoint signing secret.

To verify: recompute the HMAC over `"<X-SendPing-Timestamp>.<raw body>"` using the exact bytes you received and compare it, using a constant-time comparison, to the header.

## How the signature is computed

- Algorithm: **HMAC-SHA256**.
- Key: your endpoint **signing secret**.
- Message: the string `"<X-SendPing-Timestamp>.<raw body>"` — the timestamp header, a literal `.`, then the **raw JSON request body** exactly as sent (verify before any re-serialization).
- Encoding: lowercase **hex**.

> **Warning:** Verify against the raw request bytes, not a re-stringified object. Parsing and re-serializing the JSON can change whitespace or key order and break the signature. Omitting the `<timestamp>.` prefix breaks it too.

## Pseudocode

```text
signature = X-SendPing-Signature header
timestamp = X-SendPing-Timestamp header
expected  = hex( HMAC_SHA256(key = signing_secret, message = timestamp + "." + raw_body) )
valid     = constant_time_equals(signature, expected)
```

## Example

**Node.js**

```js
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SIGNING_SECRET = process.env.SENDPING_WEBHOOK_SECRET;

// Capture the RAW body so the signature matches exactly.
app.post('/webhooks/sendping', express.raw({ type: 'application/json' }), (req, res) => {
  // Signed content is "<timestamp>.<raw body>" from this request's headers.
  const timestamp = req.get('X-SendPing-Timestamp') || '';
  const signature = req.get('X-SendPing-Signature') || '';
  const expected = crypto
    .createHmac('sha256', SIGNING_SECRET)
    .update(`${timestamp}.${req.body}`) // req.body is a Buffer of the raw bytes
    .digest('hex');

  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  if (!timestamp || a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(400).send('invalid signature');
  }

  const payload = JSON.parse(req.body.toString('utf8'));
  console.log(payload.type, payload.data.email_id);
  res.status(200).send('ok');
});
```

**Python**

```python
import hmac
import hashlib
from flask import Flask, request, abort

app = Flask(__name__)
SIGNING_SECRET = b"whsec_your_signing_secret"

@app.post("/webhooks/sendping")
def sendping_webhook():
    raw = request.get_data()  # raw bytes
    timestamp = request.headers.get("X-SendPing-Timestamp", "")
    signature = request.headers.get("X-SendPing-Signature", "")
    # Signed content is "<timestamp>.<raw body>".
    expected = hmac.new(SIGNING_SECRET, timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()
    if not timestamp or not hmac.compare_digest(signature, expected):
        abort(400)

    payload = request.get_json()
    print(payload["type"], payload["data"]["email_id"])
    return "ok", 200
```

> **Note:** Always use a constant-time comparison (`crypto.timingSafeEqual`, `hmac.compare_digest`) rather than `==` to avoid timing side channels.

## Svix-compatible verification

Every delivery is **also** signed with the Svix scheme, so you can verify it with the off-the-shelf [`svix`](https://www.npmjs.com/package/svix) library instead of hand-rolling HMAC. Each request carries three extra headers: `svix-id` (a unique per-message id), `svix-timestamp` (Unix seconds), and `svix-signature`.

The signed content is `{svix-id}.{svix-timestamp}.{raw_body}`; the signature is the base64 HMAC-SHA256 of that string, keyed by the base64-decoded suffix of your `whsec_` signing secret, tagged `v1,`. The `whsec_` secret is shown once when you create the endpoint, and is also returned by the create and rotate webhook API calls. The `svix` library handles all of this for you.

### Why verify

Because your endpoint URL is public, anyone could POST forged events to it. The signature proves the request came from SendPing. It also defends against **replay attacks** — an attacker re-sending a previously valid, correctly-signed request — by binding each signature to a timestamp.

### Timestamp tolerance window

The signature covers `svix-timestamp`, so a verifier should reject deliveries whose timestamp is too far from the current time. The `svix` library enforces a **±5 minute** tolerance by default; if you verify by hand, compare `svix-timestamp` against your clock and reject anything outside that window (and keep your server clock in sync via NTP). Reject mismatched or stale signatures with a non-2xx so the delivery is recorded as failed.

> **Note:** Each event is delivered **at least once**. To deduplicate retries and replays, store the `svix-id` of every processed event and skip any id you have already seen.

**Node.js (svix)**

```js
import { Webhook } from 'svix';
import express from 'express';

const app = express();
const wh = new Webhook(process.env.SENDPING_WEBHOOK_SECRET); // whsec_...

app.post('/webhooks/sendping', express.raw({ type: 'application/json' }), (req, res) => {
  try {
    const payload = wh.verify(req.body, {
      'svix-id': req.get('svix-id'),
      'svix-timestamp': req.get('svix-timestamp'),
      'svix-signature': req.get('svix-signature'),
    });
    console.log(payload.event, payload.email_id);
    res.status(200).send('ok');
  } catch {
    res.status(400).send('invalid signature');
  }
});
```

**Python (svix)**

```python
from svix.webhooks import Webhook, WebhookVerificationError
from flask import Flask, request, abort

app = Flask(__name__)
wh = Webhook("whsec_your_signing_secret")

@app.post("/webhooks/sendping")
def sendping_webhook():
    try:
        payload = wh.verify(request.get_data(), dict(request.headers))
    except WebhookVerificationError:
        abort(400)
    print(payload["event"], payload["email_id"])
    return "ok", 200
```
