# Verify webhook signatures

> Check that webhook requests come from Emailit by verifying the HMAC-SHA256 signature and timestamp, with code for Node.js, Python, PHP, Ruby, Go and Laravel.

Anyone who knows your webhook URL can send it requests. Every request from Emailit is signed with your webhook's secret, so verifying the signature proves the request came from Emailit and wasn't changed on the way. Checking the timestamp also stops old requests from being replayed. Do both before you act on any event.

## How the signature works

Each webhook has its own secret, `whsec_` followed by 64 hexadecimal characters. You see it once when you create the webhook or reset the secret, and [Retrieve a webhook](/docs/api-reference/webhooks/get/) returns it.

For every request, Emailit:

1. Takes the current Unix time in seconds, for example `1790846045`, and sends it in `X-Emailit-Timestamp`.
2. Builds the string `{timestamp}.{raw body}`: the timestamp, a period, and the exact request body bytes.
3. Computes HMAC-SHA256 of that string, using the full secret, including `whsec_`, as the key.
4. Sends the result, hex-encoded in lowercase, in `X-Emailit-Signature`.

To verify, your endpoint repeats steps 2 and 3 with the raw body it received and compares its result with the header using a constant-time comparison. Then it checks that the timestamp is recent. We recommend a tolerance of 5 minutes (300 seconds). Each retry is signed again with a fresh timestamp, so a 5-minute window doesn't reject retries.

## Verify requests

Each example reads the raw body, rejects requests with a missing, stale or wrong signature with `401`, and only then parses the JSON array.

**Node.js**

```javascript title="server.js"
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.EMAILIT_WEBHOOK_SECRET; // full value, including whsec_
const TOLERANCE_SECONDS = 300;

export function verifyEmailitSignature(rawBody, signature, timestamp) {
  if (!signature || !timestamp) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!(age <= TOLERANCE_SECONDS)) return false; // also rejects non-numeric timestamps

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${timestamp}.`)
    .update(rawBody) // a Buffer with the exact bytes received
    .digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw() keeps the body as a Buffer. Don't run express.json() on this route.
app.post('/webhooks/emailit', express.raw({ type: 'application/json' }), (req, res) => {
  const valid = verifyEmailitSignature(
    req.body,
    req.get('X-Emailit-Signature'),
    req.get('X-Emailit-Timestamp'),
  );
  if (!valid) return res.status(401).send('Invalid signature');

  const events = JSON.parse(req.body.toString('utf8'));
  // Queue the events for processing, then acknowledge.
  res.sendStatus(204);
});

app.listen(3000);
```

**Python**

```python title="app.py"
import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["EMAILIT_WEBHOOK_SECRET"].encode()  # full value, including whsec_
TOLERANCE_SECONDS = 300

def verify_emailit_signature(raw_body: bytes, signature: str | None, timestamp: str | None) -> bool:
    if not signature or not timestamp:
        return False
    try:
        age = abs(time.time() - int(timestamp))
    except ValueError:
        return False
    if age > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

@app.post("/webhooks/emailit")
def emailit_webhook():
    raw_body = request.get_data()  # raw bytes; don't use request.json first
    if not verify_emailit_signature(
        raw_body,
        request.headers.get("X-Emailit-Signature"),
        request.headers.get("X-Emailit-Timestamp"),
    ):
        abort(401)

    events = json.loads(raw_body)
    # Queue the events for processing, then acknowledge.
    return "", 204
```

**PHP**

```php title="webhook.php"
<?php
const TOLERANCE_SECONDS = 300;

function verifyEmailitSignature(string $rawBody, string $signature, string $timestamp, string $secret): bool
{
    if ($signature === '' || !ctype_digit($timestamp)) {
        return false;
    }
    if (abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) {
        return false;
    }
    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    return hash_equals($expected, $signature);
}

$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_EMAILIT_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_EMAILIT_TIMESTAMP'] ?? '';
$secret = (string) getenv('EMAILIT_WEBHOOK_SECRET'); // full value, including whsec_

if (!verifyEmailitSignature($rawBody, $signature, $timestamp, $secret)) {
    http_response_code(401);
    exit('Invalid signature');
}

$events = json_decode($rawBody, true);
// Queue the events for processing, then acknowledge.
http_response_code(204);
```

**Ruby**

```ruby title="app.rb"
require 'sinatra'
require 'openssl'
require 'json'
require 'rack/utils'

SECRET = ENV.fetch('EMAILIT_WEBHOOK_SECRET') # full value, including whsec_
TOLERANCE_SECONDS = 300

def verify_emailit_signature(raw_body, signature, timestamp)
  return false if signature.to_s.empty? || timestamp.to_s !~ /\A\d+\z/
  return false if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE_SECONDS

  expected = OpenSSL::HMAC.hexdigest('SHA256', SECRET, "#{timestamp}.#{raw_body}")
  Rack::Utils.secure_compare(expected, signature)
end

post '/webhooks/emailit' do
  raw_body = request.body.read # in Rails, use request.raw_post
  valid = verify_emailit_signature(
    raw_body,
    request.env['HTTP_X_EMAILIT_SIGNATURE'],
    request.env['HTTP_X_EMAILIT_TIMESTAMP']
  )
  halt 401, 'Invalid signature' unless valid

  events = JSON.parse(raw_body)
  # Queue the events for processing, then acknowledge.
  status 204
end
```

**Go**

```go title="main.go"
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"net/http"
	"os"
	"strconv"
	"time"
)

const toleranceSeconds = 300

func verifyEmailitSignature(rawBody []byte, signature, timestamp, secret string) bool {
	ts, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil || signature == "" {
		return false
	}
	age := time.Now().Unix() - ts
	if age > toleranceSeconds || age < -toleranceSeconds {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "."))
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(signature))
}

type Event struct {
	EventID string          `json:"event_id"`
	Type    string          `json:"type"`
	Data    json.RawMessage `json:"data"`
}

func emailitWebhook(w http.ResponseWriter, r *http.Request) {
	rawBody, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "cannot read body", http.StatusBadRequest)
		return
	}
	secret := os.Getenv("EMAILIT_WEBHOOK_SECRET") // full value, including whsec_
	if !verifyEmailitSignature(rawBody, r.Header.Get("X-Emailit-Signature"), r.Header.Get("X-Emailit-Timestamp"), secret) {
		http.Error(w, "invalid signature", http.StatusUnauthorized)
		return
	}

	var events []Event
	if err := json.Unmarshal(rawBody, &events); err != nil {
		http.Error(w, "invalid JSON", http.StatusBadRequest)
		return
	}
	// Queue the events for processing, then acknowledge.
	w.WriteHeader(http.StatusNoContent)
}

func main() {
	http.HandleFunc("/webhooks/emailit", emailitWebhook)
	http.ListenAndServe(":8080", nil)
}
```

**Laravel**

```php title="app/Http/Controllers/EmailitWebhookController.php"
<?php
// config/services.php: 'emailit' => ['webhook_secret' => env('EMAILIT_WEBHOOK_SECRET')],
// routes/api.php:      Route::post('/webhooks/emailit', EmailitWebhookController::class);

namespace App\Http\Controllers;

use App\Jobs\ProcessEmailitEvent;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class EmailitWebhookController extends Controller
{
    private const TOLERANCE_SECONDS = 300;

    public function __invoke(Request $request): Response
    {
        $rawBody = $request->getContent();
        $signature = (string) $request->header('X-Emailit-Signature', '');
        $timestamp = (string) $request->header('X-Emailit-Timestamp', '');
        $secret = (string) config('services.emailit.webhook_secret'); // full value, including whsec_

        $fresh = ctype_digit($timestamp)
            && abs(time() - (int) $timestamp) <= self::TOLERANCE_SECONDS;
        $expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);

        abort_unless($fresh && hash_equals($expected, $signature), 401, 'Invalid signature');

        foreach (json_decode($rawBody, true) as $event) {
            ProcessEmailitEvent::dispatch($event); // a queued job
        }

        return response()->noContent();
    }
}
```

In Laravel, routes in `routes/api.php` aren't checked for CSRF tokens and get the `/api` prefix, so the URL above is `https://acme.com/api/webhooks/emailit`. If you register the route in `routes/web.php`, exclude the path from CSRF verification, or Laravel rejects Emailit's requests with `419`.

## Test your verification

Check your function against this known example before you deploy. Call the HMAC part directly, because the timestamp is in the past and fails the 5-minute check.

| Input | Value |
| --- | --- |
| Secret | `whsec_4f2a9c1e7b3d5a8f0e6c2b9d4a7f1e3c5b8d0a2f6e9c3b7d1a5f8e2c4b6d9a0f` |
| Timestamp | `1790846045` |
| Expected signature | `7242b580f77ec488f8ac21b57f092e3db61a431694f13074c7b4c85a4dd04be3` |

Raw body, exactly as shown, with no trailing newline:

```json
[{"event_id":"evt_2xGk7Nq1VbD5sR8tLmW3eYhC6aP","type":"email.delivered"}]
```

Then use **Send test** on the webhook to confirm your deployed endpoint accepts a real signed request. See [Set up a webhook](/docs/webhooks/set-up/#send-a-test-event).

## Common mistakes

- **Parsing before verifying.** Frameworks that parse JSON automatically (`express.json()`, `request.json` in Flask, Rails params) give you an object, not the bytes Emailit signed. Read the raw body on the webhook route.
- **Re-serializing the JSON.** `JSON.stringify(req.body)` or `json.dumps(data)` produces different whitespace, key order or escaping, so the HMAC won't match. Always hash the body exactly as received.
- **Changing the secret.** Use the whole value, including `whsec_`, as plain text. Don't strip the prefix, and don't hex- or Base64-decode it.
- **Signing the wrong string.** The input is the timestamp, a period, then the body. Signing only the body, or joining with another separator, fails.
- **Using `==` to compare.** Use a constant-time function such as `crypto.timingSafeEqual`, `hmac.compare_digest`, `hash_equals`, `Rack::Utils.secure_compare` or `hmac.Equal`.
- **A wrong server clock.** If your server's clock drifts, valid requests fall outside the tolerance. Keep it synced with NTP.
- **An old or mismatched secret.** After **Reset**, the old secret stops working immediately, including for retries. Each webhook has its own secret, so two endpoints can't share one.
- **Middleware that rewrites the body.** Proxies, API gateways or body decompression that change the bytes before your code sees them break the signature. Verify as close to the raw request as possible.

Requests that fail verification get a `401` from your endpoint and are [retried](/docs/webhooks/retries-and-failures/) like any other failure, so fixing a misconfigured secret within a few hours loses nothing.

## Related

  - [Webhook requests](/docs/webhooks/webhook-requests/)
  - [Retries and failures](/docs/webhooks/retries-and-failures/)

---
Source: https://emailit.com/docs/webhooks/request-signature/
