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:

  1. 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.
  2. Enforce the replay window. Reject if |now - t| > 300s.
  3. Deduplicate on Clotho-Delivery-Id. Retries re-send the same delivery id with the SAME body bytes (and a fresh t/v1). Process each delivery id at most once.
  4. Compare HMACs with a constant-time comparison.
  5. Respond 2xx quickly (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.