Verifying Webhooks - API Reference
Your webhook URL is a public endpoint. Anyone who discovers it can POST to it, and a payload that merely looks like ours is trivial to construct. Every webhook we send is therefore signed, so you can confirm that a request came from us and was not modified in transit.
Verification is optional, but recommended for anything that acts on the payload — placing a trade, sending a notification, writing to a database. Verify before you process.
How it works
Verification is based on four things:
- A shared secret, known only to you and SocialData
- A timestamp, so old requests can be rejected
- A unique event ID for each request
- An HMAC-SHA256 signature over the raw request body
The flow:
- We build the JSON payload.
- We compute a signature from your webhook secret, the event ID and the timestamp.
- We send the payload with the signature in the request headers.
- Your server recomputes the signature from the raw body it received.
- If the two match, the request is authentic.
Request headers
Every webhook request carries these three headers:
| Header | Description |
|---|---|
X-Timestamp | Unix timestamp (seconds) at which the webhook was generated |
X-Event-Id | A unique UUID for this request |
X-Signature | HMAC signature, prefixed with the scheme version |
Example:
X-Timestamp: 1736944496X-Event-Id: 49cd1f14-8325-4b44-9a5b-cffe7ffa82f8X-Signature: v1=c104d8b39bbe76b201dfd7b0ef3606c57b053f38e4e678756e9da1fbbf7404f4Signature scheme
- Algorithm — HMAC-SHA256
- Key — your webhook secret
- Signing string —
{X-Event-Id}.{X-Timestamp}.{raw request body}
The digest is hex-encoded and prefixed with v1=. The prefix is part of the header value, not part of the digest — strip it before comparing, or include it on both sides.
Rejecting replays
A valid signature proves a request came from us. It does not prove it is new — a captured request stays valid forever unless you check. Two additional steps:
- Check the timestamp. Reject anything outside a small tolerance of your current clock. Five minutes is a reasonable default.
- Track event IDs. Record
X-Event-Idfor each request you accept, and reject one you have already processed.
Tracking event IDs also gives you idempotency for free, which is worth having regardless of security.
Code examples
Each example returns true only for a request that is authentic, recent and correctly signed. All three use a constant-time comparison — ===, == and strcmp leak timing information that can be used to recover a valid signature byte by byte.
import crypto from 'crypto';
// Express: mount with express.raw({ type: 'application/json' }) so that// req.body is a Buffer of the original bytes rather than a parsed object.function verifyWebhook(req, secret, toleranceSec = 300) { const timestamp = req.headers['x-timestamp']; const eventId = req.headers['x-event-id']; const provided = req.headers['x-signature'];
if (!timestamp || !eventId || !provided) return false;
const match = /^v1=([0-9a-f]{64})$/.exec(provided); if (!match) return false;
// Reject stale requests before doing any crypto work const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)); if (!Number.isFinite(age) || age > toleranceSec) return false;
const rawBody = req.body.toString('utf8'); const signingString = `${eventId}.${timestamp}.${rawBody}`;
const expected = crypto .createHmac('sha256', secret) .update(signingString, 'utf8') .digest('hex');
// Both are 64 hex chars by construction, so the lengths always match return crypto.timingSafeEqual( Buffer.from(expected, 'hex'), Buffer.from(match[1], 'hex') );}import hashlibimport hmacimport time
def verify_webhook(headers, raw_body: bytes, secret: str, tolerance_sec: int = 300) -> bool: timestamp = headers.get("X-Timestamp") event_id = headers.get("X-Event-Id") provided = headers.get("X-Signature", "")
if not timestamp or not event_id or not provided.startswith("v1="): return False
# Reject stale requests before doing any crypto work try: if abs(time.time() - int(timestamp)) > tolerance_sec: return False except ValueError: return False
# raw_body stays as bytes; only the prefix is encoded signing_string = f"{event_id}.{timestamp}.".encode("utf-8") + raw_body
expected = hmac.new( secret.encode("utf-8"), signing_string, hashlib.sha256, ).hexdigest()
return hmac.compare_digest(expected, provided[3:])<?php
// Laravel: $rawBody = $request->getContent();// Plain PHP: $rawBody = file_get_contents('php://input');function verifyWebhook(string $rawBody, array $headers, string $secret, int $toleranceSec = 300): bool{ $timestamp = $headers['X-Timestamp'] ?? ''; $eventId = $headers['X-Event-Id'] ?? ''; $provided = $headers['X-Signature'] ?? '';
if ($timestamp === '' || $eventId === '' || ! str_starts_with($provided, 'v1=')) { return false; }
// Reject stale requests before doing any crypto work if (abs(time() - (int) $timestamp) > $toleranceSec) { return false; }
$expected = 'v1=' . hash_hmac( 'sha256', $eventId . '.' . $timestamp . '.' . $rawBody, $secret );
return hash_equals($expected, $provided);}Setting your secret
You choose your own secret and send it to us — see Set webhook secret. Generate it with a CSPRNG rather than typing one, for example openssl rand -hex 32.
Deliveries are signed with the new value as soon as the change takes effect. Because the secret is used for signing rather than exchanged per request, rotating it briefly invalidates in-flight deliveries — rotate at a quiet moment, or accept both the old and new secret for a few minutes.