---
title: "Verifying Webhooks - API Reference"
description: "How to verify the authenticity of SocialData webhook requests using the X-Signature header, HMAC-SHA256 and your webhook secret"
source: "https://docs.socialdata.tools/monitoring/verifying-webhooks/"
---

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.

> **Set a secret first, or the signature proves nothing**
> 
> Signature headers are sent on every delivery, whether or not you have configured a webhook secret. Until you [set one](https://docs.socialdata.tools/monitoring/set-webhook-secret/), the signature is computed with a fixed placeholder key — the literal string `NULL` — which anyone can reproduce, because the signing scheme on this page is public.
> 
> An unverified `X-Signature` header is not evidence of anything. [Set a secret](https://docs.socialdata.tools/monitoring/set-webhook-secret/) before you rely on it.

## 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:

1.  We build the JSON payload.
2.  We compute a signature from your webhook secret, the event ID and the timestamp.
3.  We send the payload with the signature in the request headers.
4.  Your server recomputes the signature from the raw body it received.
5.  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:

```http
X-Timestamp: 1736944496
X-Event-Id: 49cd1f14-8325-4b44-9a5b-cffe7ffa82f8
X-Signature: v1=c104d8b39bbe76b201dfd7b0ef3606c57b053f38e4e678756e9da1fbbf7404f4
```

## Signature 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.

> **Sign the raw bytes, never re-encoded JSON**
> 
> JSON serialisation is not deterministic. Whitespace, key order and Unicode escaping all change when a payload is parsed and re-encoded, and any one of those differences produces a completely different digest.
> 
> Verify against the **raw request body exactly as received**. Most frameworks parse JSON before your handler runs, so you usually have to opt out — `express.raw()` in Express, `request.get_data()` in Flask, `$request->getContent()` in Laravel.

## 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-Id` for 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.

#### JavaScript

```js
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')
    );
}
```

#### Python

```python
import hashlib
import hmac
import 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

```php
<?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](https://docs.socialdata.tools/monitoring/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.

> **Signatures cannot be re-checked against event history**
> 
> [Monitor event history](https://docs.socialdata.tools/monitoring/monitor-event-history/) stores each payload pretty-printed, and truncates very large ones. Those bytes are not the bytes we signed, so a payload replayed from history will not reproduce its original signature.
> 
> This does not weaken anything — history is read from our API over your authenticated connection, so it is already trusted. Just don’t build a backfill path that expects to re-verify.

## Related endpoints

-   [Set webhook URL](https://docs.socialdata.tools/monitoring/set-global-webhook-url/)
-   [Set webhook secret](https://docs.socialdata.tools/monitoring/set-webhook-secret/)
-   [Monitor event history](https://docs.socialdata.tools/monitoring/monitor-event-history/)

## Before you integrate

-   [Authentication](https://docs.socialdata.tools/getting-started/authentication/)
-   [Rate limits](https://docs.socialdata.tools/getting-started/rate-limits/)
-   [Errors](https://docs.socialdata.tools/getting-started/errors/)
-   [Monitoring API pricing](https://docs.socialdata.tools/monitoring/pricing/)
-   [Processing webhook events](https://docs.socialdata.tools/monitoring/processing-webhooks/)
