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.