Webhook integration & verification

When a request reaches its approval threshold, TeamSigner sends a signed POST to the webhook URL you configured. This page explains the cryptography and shows how to verify it in your own service before acting.

Using an AI coding assistant? Point it at /llms.txt — the complete contract in one plain-text document.

The cryptography

The request we send

POST https://your-organisation.com/hooks/lock-db
X-Signature:        3b1e…              (HMAC-SHA256 of the body)
X-Timestamp:        1737673200
X-Idempotency-Key:  11111111-1111-1111-1111-111111111111
Content-Type:       application/json

{
  "canonical_string": "teamsigner-v1|{\"action_id\":\"…\",\"action_name\":\"LOCK DATABASE\",…}",
  "canonical_message": {
    "request_id": "…", "team_id": "…", "action_id": "…",
    "action_name": "LOCK DATABASE", "threshold": 2,
    "webhook_url": "https://your-organisation.com/hooks/lock-db",
    "initiator": "0x02…", "message": "prod db compromised",
    "issued_at": 1737673200, "expires_at": 1737676800
  },
  "signatures": [
    { "pubkey": "0x0279be…", "signature": "0xb3d6…" },
    { "pubkey": "0x02c604…", "signature": "0x5700…" }
  ]
}

Verify signatures against SHA-256(canonical_string) — you don't need to re-serialize the message yourself. Do sanity-check the fields inside canonical_message (action, threshold, expiry) match what you expect.

Where to get the secret and the keys

Verifying needs two things from your team's account. Both are visible to team admins only, on the web app and in the mobile app, with a copy button:

Non-admin members never see the HMAC secret, on either surface. If you can't see it on the action page, you're not an admin of that team.

What your endpoint must check

  1. Recompute the HMAC over X-Timestamp + "." + rawBody and constant-time compare to X-Signature. Reject a stale X-Timestamp (recommended: within ~5 minutes of your clock).
  2. Confirm expires_at has not passed.
  3. Verify each signature over SHA-256(canonical_string) using its pubkey.
  4. Confirm each pubkey is a current, whitelisted member of your team, and count only distinct valid ones — require at least threshold.
  5. If the body carries "test": true, verify and log but do not act — it came from a test action.
  6. Dedupe on X-Idempotency-Key, then act.

Verify it in your language

Each snippet is the same routine, doing all of the checks above in the same order. The Go version is compiled and tested against a signed reference delivery on every build, so it cannot drift from the contract. Prefer a runnable project? github.com/teamsigner/examples has a complete Node receiver with tests and a signed delivery you can replay offline.

package webhook // rename to suit your service

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"strconv"
	"strings"
	"time"

	"github.com/decred/dcrd/dcrec/secp256k1/v4"
	"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
)

// ErrTestDelivery means the delivery is authentic but came from a test action.
// Log it, never act on it.
var ErrTestDelivery = errors.New("test delivery: verified, not actioned")

type payload struct {
	Test             bool   `json:"test"`
	CanonicalString  string `json:"canonical_string"`
	CanonicalMessage struct {
		RequestID  string `json:"request_id"`
		ActionName string `json:"action_name"`
		Threshold  int    `json:"threshold"`
		ExpiresAt  int64  `json:"expires_at"`
	} `json:"canonical_message"`
	Signatures []struct {
		Pubkey    string `json:"pubkey"`
		Signature string `json:"signature"`
	} `json:"signatures"`
}

// Verify returns nil if the webhook is authentic, fresh, and carries enough
// valid approvals to act on.
//
//	rawBody    the request body exactly as received, unparsed
//	xSig/xTs   the X-Signature and X-Timestamp headers
//	secret     the action's HMAC secret, from its page in TeamSigner
//	whitelist  your team's current member public keys, lowercase 0x-hex
func Verify(rawBody []byte, xSig, xTs, secret string, whitelist map[string]bool) error {
	// 1. Envelope: HMAC-SHA256(secret, timestamp + "." + rawBody), over the raw
	//    bytes and before parsing — never act on a body you haven't authenticated.
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(xTs + "." + string(rawBody)))
	want := hex.EncodeToString(mac.Sum(nil))
	if !hmac.Equal([]byte(want), []byte(xSig)) {
		return errors.New("bad HMAC signature")
	}

	// 2. Freshness: a captured delivery must not be replayable tomorrow.
	ts, err := strconv.ParseInt(xTs, 10, 64)
	if err != nil {
		return errors.New("malformed X-Timestamp")
	}
	if skew := time.Since(time.Unix(ts, 0)); skew > 5*time.Minute || skew < -5*time.Minute {
		return fmt.Errorf("stale X-Timestamp (off by %s)", skew.Round(time.Second))
	}

	var p payload
	if err := json.Unmarshal(rawBody, &p); err != nil {
		return err
	}

	// 3. A test action's delivery is real, but must never trigger the real thing.
	if p.Test {
		return ErrTestDelivery
	}
	if time.Now().Unix() > p.CanonicalMessage.ExpiresAt {
		return errors.New("request expired")
	}

	// 4. The signed digest is SHA-256 of canonical_string exactly as delivered.
	//    Do not rebuild it from canonical_message: any serialization difference
	//    turns every signature into a false rejection.
	digest := sha256.Sum256([]byte(p.CanonicalString))

	// 5. Count DISTINCT whitelisted keys with a valid signature. Two copies of
	//    one member's approval is still one approval.
	seen := map[string]bool{}
	for _, s := range p.Signatures {
		k := strings.ToLower(s.Pubkey)
		if !whitelist[k] || seen[k] {
			continue
		}
		if verifySig(k, s.Signature, digest[:]) {
			seen[k] = true
		}
	}

	// 6. Threshold comes from the signed message, not from your own config:
	//    it is the number the members actually approved under.
	if len(seen) < p.CanonicalMessage.Threshold {
		return fmt.Errorf("only %d of %d required approvals", len(seen), p.CanonicalMessage.Threshold)
	}
	return nil // authentic, fresh, approved — now dedupe on X-Idempotency-Key and act.
}

func verifySig(pubHex, sigHex string, digest []byte) bool {
	pb, err := hex.DecodeString(strings.TrimPrefix(pubHex, "0x"))
	if err != nil {
		return false
	}
	sb, err := hex.DecodeString(strings.TrimPrefix(strings.ToLower(sigHex), "0x"))
	if err != nil || len(sb) != 64 {
		return false
	}
	pk, err := secp256k1.ParsePubKey(pb)
	if err != nil {
		return false
	}
	var r, s secp256k1.ModNScalar
	if r.SetByteSlice(sb[:32]) || s.SetByteSlice(sb[32:]) {
		return false // overflows the curve order
	}
	return ecdsa.NewSignature(&r, &s).Verify(digest, pk)
}

Other languages

The checks are identical anywhere — HMAC-SHA256 for the envelope, SHA-256 of canonical_string for the digest, and a secp256k1 verify of each compact signature against the signer's compressed public key. Recommended libraries:

Signatures are RFC-6979 deterministic and low-S normalized. Public keys are compressed SEC1 (33 bytes). The domain prefix is teamsigner-v1 — it's part of canonical_string, so you never handle it directly.

Test it end-to-end before going live

A real action fires your production webhook — so your first run shouldn't be the one that actually locks the database or freezes payouts. Prove the whole pipeline first against a throwaway endpoint that verifies everything but does nothing.

  1. Stand up a teamsigner-test endpoint. It runs the exact checks from above — HMAC, digest, every signature, member whitelist — but instead of acting, it returns 200 and prints a report. Keep it deployed only while you're setting up.
  2. Create a "Test" action in TeamSigner pointing at that endpoint, and set the threshold to your full team size. That forces every member to approve — exercising each person's device, key and signature end-to-end, and surfacing anyone who hasn't set up their signing key yet.
  3. Have everyone approve. When the threshold is met we deliver to your test endpoint. Read the report: HMAC valid, every signature verifies, and every public key matches a current member (copy each member's key from the Members screen).
  4. All green? Swap in production. Point your real action at your real endpoint and delete the test action — or keep it around to re-run after a key rotation or when someone new joins.

Deliveries from a test action arrive with a top-level "test": true in the body (alongside canonical_string, so it's covered by the HMAC but never part of the signed digest). Branch on it to verify-and-log without performing the real action — a live delivery omits the field.

// teamsigner-test — a throwaway receiver to prove your integration works
// end-to-end. It runs the SAME checks as production but DOES NOTHING real:
// it verifies and prints a report. Delete it once you're green.
import { createServer } from "node:http";
import { createHmac, createHash, timingSafeEqual } from "node:crypto";
import { secp256k1 } from "@noble/curves/secp256k1";

const HMAC_SECRET = process.env.TS_HMAC_SECRET!;   // from the action's page in TeamSigner
// Your team's member public keys → who they are. Copy each key from the
// Members screen. Lowercase 0x-hex.
const MEMBERS = new Map<string, string>([
  // ["0x0279be…", "alice@yourco.com"],
  // ["0x02c604…", "bob@yourco.com"],
]);

const bytes = (h: string) => Buffer.from(h.replace(/^0x/, ""), "hex");

createServer((req, res) => {
  let raw = "";
  req.on("data", (c) => (raw += c));
  req.on("end", () => {
    const xSig = (req.headers["x-signature"] as string) ?? "";
    const xTs = (req.headers["x-timestamp"] as string) ?? "";

    // 1. Envelope: HMAC-SHA256(secret, timestamp + "." + rawBody)
    const want = createHmac("sha256", HMAC_SECRET).update(xTs + "." + raw).digest("hex");
    const hmacOK =
      want.length === xSig.length && timingSafeEqual(Buffer.from(want), Buffer.from(xSig));

    // 2. Freshness + expiry, the two replay checks.
    const now = Math.floor(Date.now() / 1000);
    const skew = Math.abs(now - Number(xTs));
    const freshOK = /^\d+$/.test(xTs) && skew <= 300;

    const p = JSON.parse(raw);
    const notExpired = now <= p.canonical_message.expires_at;
    const digest = createHash("sha256").update(p.canonical_string).digest();

    // 3. Check every signature + confirm the key is a known member.
    const seen = new Set<string>();
    const rows = p.signatures.map((s: any) => {
      const k = s.pubkey.toLowerCase();
      let sigValid = false;
      try { sigValid = secp256k1.verify(bytes(s.signature), digest, bytes(k)); } catch {}
      const who = MEMBERS.get(k);
      const counted = sigValid && !!who && !seen.has(k);
      if (counted) seen.add(k);
      return { pubkey: k, sigValid, known: !!who, counted, who: who ?? "UNKNOWN KEY" };
    });
    const pass =
      hmacOK && freshOK && notExpired && !p.test && seen.size >= p.canonical_message.threshold;

    // 4. Print the report — but DO NOT perform the real action.
    console.log("── teamsigner-test delivery ──────────────");
    console.log("action:    ", p.canonical_message.action_name, p.test ? "(TEST ACTION)" : "");
    console.log("hmac:      ", hmacOK ? "OK" : "FAIL");
    console.log("timestamp: ", freshOK ? "fresh (" + skew + "s)" : "STALE (" + skew + "s)");
    console.log("expiry:    ", notExpired ? "OK" : "EXPIRED");
    console.log("threshold: ", seen.size + "/" + p.canonical_message.threshold + " valid members");
    for (const r of rows)
      console.log("  " + (r.counted ? "✓" : "✗") + " " + r.who.padEnd(22) + " " + r.pubkey +
        (r.known ? (r.sigValid ? "" : "  ← bad signature") : "  ← not a known member"));
    console.log("result:    ", pass ? "PASS ✅" : "FAIL ❌");

    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify({ ok: true, pass, hmac_valid: hmacOK, valid_members: seen.size }));
  });
}).listen(8080, () => console.log("teamsigner-test listening on :8080"));

The receiver above is intentionally dumb — it verifies and logs, nothing else. A PASS means TeamSigner, every member's signing key, and your verification code all work together, so the only thing left to wire up is the real action your production endpoint takes on a valid delivery.

The GitHub plugin

The GitHub plugin is the one integration where TeamSigner is the consumer rather than the caller: there is no endpoint of yours to verify anything, because the effect of an approval is a status check written back to the pull request. Nothing on this page needs implementing for it. Two details are still worth knowing if you audit the signed record:

Either field present switches the canonical string's domain prefix from teamsigner-v1 to teamsigner-v2; the serialization is otherwise identical. See /llms.txt for the exact form.

Keeping your key whitelist in sync

Your verifier trusts a whitelist of member public keys. When a member rotates a key, joins, or leaves, that whitelist drifts from the keys we hold — and at emergency time a valid approval would be rejected. From a team's Integration screen you can expose a key sync URL so we check for drift (on demand and hourly) and flag it before it bites.

  1. On the Integration screen, set your key sync URL and copy the generated HMAC secret.
  2. We POST that URL with the same envelope as a webhook — X-Signature = hex(HMAC(secret, X-Timestamp + "." + rawBody)), plus X-TeamSigner-Type: key-sync.
  3. Verify the HMAC, then respond 200 with the public keys you currently trust for the team.
// We send (detect mode):
POST https://your-system.example/teamsigner/keys
X-Signature:        3b1e…        (HMAC-SHA256 of the body, same as webhooks)
X-Timestamp:        1737673200
X-TeamSigner-Type:  key-sync

{ "type": "key-sync", "mode": "detect", "team_id": "…", "issued_at": 1737673200 }

// You reply (after verifying the HMAC) with the keys you trust:
{ "keys": ["0x0279be…", "0x02c604…"] }

We diff your list against the members we hold: keys we have that you're missing mean approvals will fail (critical); keys you trust that we've retired are stale (review). In push mode we also include a "members" array of {email, pubkey} in the request — apply it to your trust store, then reply with the resulting keys the same way. Everything shows up as an in-sync / out-of-sync status on the Integration screen.