Clotho Webhooks — Verifying Signatures
Every webhook POST carries two headers:
X-Clotho-Signature: t=<unix_seconds>,v1=<hex_hmac>,id=<delivery_uuid>
Clotho-Delivery-Id: <delivery_uuid>
The signature scheme (v1):
signed_payload = "<t>.<delivery_id>.<sha256_hex(raw_body)>"
v1 = hex( HMAC-SHA256(endpoint_secret, signed_payload) )
Rules that matter:
- Verify against the RAW request bytes. Never parse and re-serialize the JSON before hashing — key order and whitespace differences will break the signature. Read the body as bytes, hash those bytes.
- Enforce the replay window. Reject if
|now - t| > 300s. - Deduplicate on
Clotho-Delivery-Id. Retries re-send the same delivery id with the SAME body bytes (and a fresht/v1). Process each delivery id at most once. - Compare HMACs with a constant-time comparison.
- Respond
2xxquickly (do the heavy work async). Anything else — or a timeout — schedules a retry: 16 attempts over ~3 days, then the delivery dead-letters and can be replayed from the dashboard.
The endpoint secret (whsec_...) is shown once at creation and can be
re-revealed in the dashboard (Webhooks → endpoint → Reveal secret).
Go
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
func handler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
var ts, v1, id string
for _, p := range strings.Split(r.Header.Get("X-Clotho-Signature"), ",") {
k, v, _ := strings.Cut(strings.TrimSpace(p), "=")
switch k {
case "t": ts = v
case "v1": v1 = v
case "id": id = v
}
}
tsUnix, err := strconv.ParseInt(ts, 10, 64)
if err != nil || time.Since(time.Unix(tsUnix, 0)).Abs() > 5*time.Minute {
http.Error(w, "stale", http.StatusUnauthorized)
return
}
bodyHash := sha256.Sum256(body)
payload := fmt.Sprintf("%s.%s.%s", ts, id, hex.EncodeToString(bodyHash[:]))
mac := hmac.New(sha256.New, []byte(secret)) // your whsec_... value
mac.Write([]byte(payload))
if subtle.ConstantTimeCompare([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(v1)) != 1 {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
// dedupe on r.Header.Get("Clotho-Delivery-Id"), then process body
w.WriteHeader(http.StatusOK)
}
Node (Express)
const crypto = require('node:crypto')
// IMPORTANT: capture the raw body — app.use(express.raw({type: '*/*'}))
app.post('/hooks', express.raw({ type: '*/*' }), (req, res) => {
const parts = Object.fromEntries(
(req.get('x-clotho-signature') ?? '').split(',').map((p) => p.trim().split('=')),
)
const { t, v1, id } = parts
if (!t || !v1 || !id) return res.status(401).end()
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.status(401).end()
const bodyHash = crypto.createHash('sha256').update(req.body).digest('hex')
const expected = crypto
.createHmac('sha256', process.env.CLOTHO_WEBHOOK_SECRET)
.update(`${t}.${id}.${bodyHash}`)
.digest('hex')
const a = Buffer.from(expected); const b = Buffer.from(v1)
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(401).end()
// dedupe on req.get('clotho-delivery-id'), then JSON.parse(req.body)
res.status(200).end()
})
Python (Flask)
import hashlib, hmac, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = b"whsec_..." # from the dashboard
@app.post("/hooks")
def hooks():
raw = request.get_data() # RAW bytes, before any JSON parsing
parts = dict(p.strip().split("=", 1)
for p in request.headers.get("X-Clotho-Signature", "").split(","))
t, v1, did = parts.get("t"), parts.get("v1"), parts.get("id")
if not (t and v1 and did):
abort(401)
if abs(time.time() - int(t)) > 300:
abort(401)
body_hash = hashlib.sha256(raw).hexdigest()
expected = hmac.new(SECRET, f"{t}.{did}.{body_hash}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1):
abort(401)
# dedupe on request.headers["Clotho-Delivery-Id"], then parse raw
return "", 200
Event envelope
{
"id": "<outbox event uuid>",
"type": "order.paid",
"version": 1,
"created_at": "2026-06-10T08:00:00Z",
"data": { "...event-specific fields, including order metadata..." }
}
Event types: payment.confirmed, payment.reverted, order.paid,
order.partially_paid, order.expired. An endpoint with an empty
subscription list receives all of them.
payment.reverted fires when a previously confirmed payment is
reorged out of the chain — treat it as a retraction of the matching
payment.confirmed (same payment_id). If the transaction re-includes
later, a fresh payment.confirmed follows.