Skip to main content
Ssnap Docs
Guides

Verify webhook signatures

Check the signature on an async callback before trusting it.

Async deliveries are signed. Verify before acting: your callback endpoint is public, so anyone who learns its address can post to it.

The scheme

HeaderValue
X-Ssnap-TimestampUnix timestamp of the delivery attempt
X-Ssnap-Signaturesha256= + HMAC-SHA256("<timestamp>.<raw body>", secret)

The signed string is the timestamp, a literal dot, then the raw request body exactly as received. Re-serialising parsed JSON will change the bytes and break the signature.

Signature headers are present only when a webhook secret is configured for the platform. Treat a missing signature as a failed verification rather than a pass.

Node.js / Express

webhook.js
import crypto from 'node:crypto';
import express from 'express';

const app = express();

// Capture the raw body; parsed JSON cannot be verified.
app.post(
  '/hooks/ssnap',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const timestamp = req.get('X-Ssnap-Timestamp');
    const signature = req.get('X-Ssnap-Signature');

    if (!timestamp || !signature) return res.sendStatus(400);

    // Reject deliveries older than five minutes to blunt replays.
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
      return res.sendStatus(400);
    }

    const expected =
      'sha256=' +
      crypto
        .createHmac('sha256', process.env.SSNAP_WEBHOOK_SECRET)
        .update(`${timestamp}.${req.body.toString('utf8')}`)
        .digest('hex');

    const valid =
      signature.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));

    if (!valid) return res.sendStatus(401);

    const payload = JSON.parse(req.body.toString('utf8'));

    // Acknowledge first, work afterwards: deliveries time out after 10 seconds.
    res.sendStatus(200);
    void handle(payload);
  },
);

PHP / Laravel

Controller
public function handle(Request $request): Response
{
    $timestamp = $request->header('X-Ssnap-Timestamp');
    $signature = $request->header('X-Ssnap-Signature');

    abort_if($timestamp === null || $signature === null, 400);
    abort_if(abs(time() - (int) $timestamp) > 300, 400);

    $expected = 'sha256=' . hash_hmac(
        'sha256',
        $timestamp . '.' . $request->getContent(),
        config('services.ssnap.webhook_secret'),
    );

    abort_unless(hash_equals($expected, $signature), 401);

    $payload = $request->json()->all();

    if ($payload['status'] === 'success') {
        ProcessScreenshot::dispatch($payload['url']);
    }

    return response()->noContent();
}

Python / Flask

app.py
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["SSNAP_WEBHOOK_SECRET"].encode()

@app.post("/hooks/ssnap")
def ssnap_webhook():
    timestamp = request.headers.get("X-Ssnap-Timestamp")
    signature = request.headers.get("X-Ssnap-Signature")

    if not timestamp or not signature:
        abort(400)
    if abs(time.time() - int(timestamp)) > 300:
        abort(400)

    expected = "sha256=" + hmac.new(
        SECRET,
        f"{timestamp}.".encode() + request.get_data(),
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected, signature):
        abort(401)

    payload = request.get_json()
    return "", 204

Handler checklist

  • Compare in constant time. Use timingSafeEqual, hash_equals or compare_digest, never ==.
  • Verify against raw bytes, before parsing.
  • Reject stale timestamps (five minutes is a reasonable window).
  • Return 2xx fast. Delivery times out after 10 seconds and is retried up to three times, 500 ms apart.
  • Be idempotent. Retries mean the same payload can arrive more than once.
  • Download promptly. The url in a success payload is signed for 24 hours.
  • Handle failures. A payload may be {"status": "error", "error": …, "code": …}, and the error codes are the same as the synchronous ones.

Next