Verify webhook signatures

How HMAC signing prevents webhook spoofing. Sample verification code in Node, Python, and PHP, plus what to do when signatures fail.

Last updated 2026-05-10

Anyone with your webhook URL could send fake events to it. The signature header proves it actually came from us.

What we send

Every webhook POST has these headers:

  • Sellstein-Signature. HMAC-SHA256 of the timestamp + body, base64-encoded
  • Sellstein-Timestamp. Unix seconds, signed alongside the body to prevent replay
  • Sellstein-Event-Id. Unique per event; useful for idempotent processing

The signing secret is at Settings → Developers → Webhooks → click the endpoint → Signing Secret. It starts with whsec_.

Verify in Node

```js import crypto from 'crypto'; function verify(rawBody, signature, timestamp, secret) { const ts = Number(timestamp); if (Math.abs(Date.now() / 1000 - ts) > 300) throw new Error('replay'); const expected = crypto .createHmac('sha256', secret) .update(timestamp + '.' + rawBody) .digest('base64'); if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) { throw new Error('bad signature'); } } ```

Verify in Python

```python import hmac, hashlib, base64, time def verify(raw_body, signature, timestamp, secret): if abs(time.time() - int(timestamp)) > 300: raise ValueError('replay') expected = base64.b64encode(hmac.new( secret.encode(), (timestamp + '.' + raw_body).encode(), hashlib.sha256 ).digest()).decode() if not hmac.compare_digest(expected, signature): raise ValueError('bad signature') ```

Critical rules

  • Verify BEFORE JSON.parse. If the body is tampered, you don't want to be acting on it
  • Use a constant-time compare (timingSafeEqual / hmac.compare_digest). String == leaks timing info
  • Reject events older than 5 minutes. Replay protection
  • Read the raw body, not the parsed JSON. Parsing can mutate whitespace and break the hash

When verification fails

Open Settings → Developers → Webhooks → endpoint → Logs. Each delivery shows the signature we sent and your endpoint's response. Common causes:

  • Wrong signing secret (you copied a different endpoint's secret)
  • Body was modified by a proxy (gzip middleware, JSON re-stringification)
  • Clock skew on your server (sync NTP)
  • Header name typo (Sellstein-Signature, exact case)

Idempotency

The same event can be delivered more than once (network retries). Use Sellstein-Event-Id as a dedupe key in your DB. If you've seen it before, return 200 immediately. Don't re-process.

Still need help?

Real humans, real answers. We respond fast and we never use chatbots as the front line.

Email Support