Webhooks
Verify signatures

Verify webhook signatures

Every webhook delivery carries a Cobuntu-Signature header. You must verify it before trusting the payload — otherwise anyone who knows your URL can fake events.

The signature format

Cobuntu-Signature: t=1716700000,v1=2b3c4d5e6f7g8h9i...
  • t — Unix timestamp (seconds). The age of the signed body.
  • v1 — HMAC-SHA256 hex digest of {t}.{rawBody}, keyed by your webhook's signing secret.

Verify in Node / Express

import crypto from "crypto";
import express from "express";
 
const app = express();
 
// IMPORTANT: use raw body, NOT a parsed JSON body. The signature
// is over the exact bytes Cobuntu sent.
app.post(
  "/webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.get("Cobuntu-Signature") || "";
    if (!verifySignature(sig, req.body, process.env.COBUNTU_WEBHOOK_SECRET)) {
      return res.status(401).end();
    }
 
    const event = JSON.parse(req.body.toString("utf8"));
    // … handle the event
    res.status(200).end();
  },
);
 
function verifySignature(header, rawBody, secret) {
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("=", 2)),
  );
  const t = parts.t;
  const v1 = parts.v1;
  if (!t || !v1) return false;
 
  // Reject ancient signatures (replay protection — 5 min window).
  const age = Math.floor(Date.now() / 1000) - Number(t);
  if (Number.isNaN(age) || age > 300) return false;
 
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody.toString("utf8")}`)
    .digest("hex");
 
  // Constant-time compare to avoid timing attacks.
  const a = Buffer.from(v1, "hex");
  const b = Buffer.from(expected, "hex");
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

Verify in Python

import hashlib
import hmac
import time
from flask import Flask, request, abort
 
app = Flask(__name__)
SECRET = os.environ["COBUNTU_WEBHOOK_SECRET"].encode("utf-8")
 
@app.route("/webhook", methods=["POST"])
def webhook():
    sig = request.headers.get("Cobuntu-Signature", "")
    if not verify_signature(sig, request.get_data()):
        abort(401)
    event = request.get_json()
    # … handle
    return "", 200
 
def verify_signature(header: str, raw_body: bytes) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1:
        return False
    if abs(int(time.time()) - int(t)) > 300:
        return False
    expected = hmac.new(SECRET, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

Don't forget

  • Use the raw bytes of the body — not a re-stringified JSON. Parsing then re-serializing produces different bytes and signature verification fails.
  • Constant-time compare — string === leaks timing.
  • Reject old signatures — 5 minutes is a reasonable window.
  • Rotate the secret if it leaks. cobuntu-admin → Integrations → Webhooks → Rotate.