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
| Header | Value |
|---|---|
X-Ssnap-Timestamp | Unix timestamp of the delivery attempt |
X-Ssnap-Signature | sha256= + 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
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
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
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 "", 204Handler checklist
- Compare in constant time. Use
timingSafeEqual,hash_equalsorcompare_digest, never==. - Verify against raw bytes, before parsing.
- Reject stale timestamps (five minutes is a reasonable window).
- Return
2xxfast. 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
urlin 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.