# TeamSigner — Webhook Integration & Verification > Complete, self-contained contract for integrating TeamSigner. TeamSigner collects cryptographic > multi-party approvals from a team; once a configured number of members approve a request, it delivers > a signed webhook to your endpoint. Your endpoint MUST verify the delivery before acting on it. > Human version: https://teamsigner.com/docs ## Concepts - **Action**: a predefined operation with a name, an approval `threshold`, and a `webhook_url`. - **Request**: one instance of an action, awaiting approvals. - **Approval**: a member's secp256k1 signature over the request's canonical message. - When valid approvals reach `threshold`, TeamSigner sends `POST {webhook_url}` with the signed payload. - A **test action** delivers with a top-level `"test": true` so your endpoint can verify without acting. ## The frozen signing contract (`teamsigner-v1`) Each approval is an ECDSA/secp256k1 signature over a digest derived deterministically from these 10 fields (field set + types are FROZEN; a change bumps the version prefix): | Field | Type | Notes | |---|---|---| | `request_id` | string (UUID) | | | `team_id` | string (UUID) | | | `action_id` | string (UUID) | | | `action_name` | string | e.g. `LOCK DATABASE` | | `threshold` | integer | required valid approvals | | `webhook_url` | string | where the approved action is delivered | | `initiator` | string | initiator's public key, `0x`-hex; empty when the raiser has no signing key (e.g. raised from the web) | | `message` | string | optional reason; `""` if none (never omitted) | | `issued_at` | integer | Unix seconds | | `expires_at` | integer | Unix seconds | **Canonicalization (produces the signed bytes):** 1. Build a JSON object of exactly the 10 fields above. 2. Serialize as canonical JSON: keys sorted ascending by Unicode code point; compact (no whitespace); RFC 8259 string escaping; **do NOT HTML-escape** `<`, `>`, `&`; integers plain base-10; UTF-8. 3. Prepend the domain prefix: the signing string is `teamsigner-v1|` (literal `|`, no space). 4. `digest = SHA-256(utf8(signingString))` → 32 bytes. This is what is signed and what you verify against. You normally never build this yourself: we send you `canonical_string` (the exact pre-image) in the body — verify signatures against `SHA-256(canonical_string)` directly. Do sanity-check the `canonical_message` fields (action, threshold, expiry) match what you expect. ### Golden test vector (verify your implementation reproduces this) Signing string: ``` teamsigner-v1|{"action_id":"33333333-3333-3333-3333-333333333333","action_name":"LOCK DATABASE","expires_at":1737676800,"initiator":"0x02a1b2c3","issued_at":1737673200,"message":"prod db compromised","request_id":"11111111-1111-1111-1111-111111111111","team_id":"22222222-2222-2222-2222-222222222222","threshold":2,"webhook_url":"https://org.example/webhooks/lock"} ``` SHA-256 digest (the 32 bytes signed): ``` 8043845174fcbcecc99145f060a20bfca007e0ff6d4bbb68ddf6818cc772f645 ``` Golden signature — private scalar = 1 (pubkey = generator G), over the digest above: ``` pubkey = 0x0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 signature = 0x57001671ea9d2c50beebe26d101c8abf29dc9ff51b66198643650198f42396247b7280735c6af77316e88122569865c004e859d0d7af2ee69dda71eb309b63b7 ``` ### Wire formats - **Public key**: compressed secp256k1, 33 bytes, `0x`-hex (`0x02`/`0x03` prefix). - **Signature**: compact `r||s`, 64 bytes, `0x`-hex; deterministic ECDSA (RFC 6979), low-S normalized (matches `@noble/curves` and decred `secp256k1`). ## The webhook request we send ``` POST {your webhook_url} X-Signature: X-Timestamp: 1737673200 X-Idempotency-Key: Content-Type: application/json { "test": false, // present and true ONLY for test actions; omitted for live "canonical_string": "teamsigner-v1|{...sorted compact json...}", "canonical_message": { request_id, team_id, action_id, action_name, threshold, webhook_url, initiator, message, issued_at, expires_at }, "signatures": [ { "pubkey": "0x02…", "signature": "0x…" }, … ] } ``` Envelope authenticity: `X-Signature = hex(HMAC-SHA256(secret, X-Timestamp + "." + rawBody))`, where `secret` is the **per-action HMAC secret** (find it on the action's page in TeamSigner). ## What your endpoint MUST do, in order 1. Recompute the HMAC over `X-Timestamp + "." + rawBody` and **constant-time compare** to `X-Signature`. 2. Reject a stale `X-Timestamp` (recommended window: within ~5 minutes of your clock). 3. Confirm `canonical_message.expires_at` (Unix seconds) has not passed. 4. Verify each `signature` over `SHA-256(canonical_string)` using its `pubkey`. 5. Confirm each `pubkey` is a **current, whitelisted member** of your team; count only DISTINCT valid ones and require at least `threshold`. 6. Dedupe on `X-Idempotency-Key` (= `request_id`), then act. If `test` is true, verify but do NOT act. Get the whitelist of member public keys from the team's **Members** screen, and keep it current with **key sync** (below). ## Verification code ### Go ```go import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "strings" "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" ) type payload struct { CanonicalString string `json:"canonical_string"` CanonicalMessage struct { 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 and has enough valid approvals. // whitelist = the set of your team's known member public keys (lowercase 0x-hex). func Verify(rawBody []byte, xSig, xTs, hmacSecret string, whitelist map[string]bool) error { // 1. Envelope: HMAC-SHA256(secret, timestamp + "." + rawBody) mac := hmac.New(sha256.New, []byte(hmacSecret)) mac.Write([]byte(xTs + "." + string(rawBody))) if !hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(xSig)) { return errors.New("bad HMAC signature") } // (also reject if xTs is more than a few minutes from your clock) var p payload if err := json.Unmarshal(rawBody, &p); err != nil { return err } // 2. The signed digest is SHA-256 of the canonical string. digest := sha256.Sum256([]byte(p.CanonicalString)) // 3. Verify each signature against a whitelisted key; count distinct valid. seen, valid := map[string]bool{}, 0 for _, s := range p.Signatures { k := strings.ToLower(s.Pubkey) if !whitelist[k] || seen[k] { continue } if verifySig(s.Pubkey, s.Signature, digest[:]) { seen[k] = true valid++ } } // 4. Enough approvals? if valid < p.CanonicalMessage.Threshold { return errors.New("not enough valid signatures") } return nil } func verifySig(pubHex, sigHex string, digest []byte) bool { pb, _ := hex.DecodeString(strings.TrimPrefix(pubHex, "0x")) sb, _ := hex.DecodeString(strings.TrimPrefix(sigHex, "0x")) pk, err := secp256k1.ParsePubKey(pb) if err != nil || len(sb) != 64 { return false } var r, s secp256k1.ModNScalar r.SetByteSlice(sb[:32]) s.SetByteSlice(sb[32:]) return ecdsa.NewSignature(&r, &s).Verify(digest, pk) } ``` ### TypeScript / Node ```ts import { createHmac, createHash, timingSafeEqual } from "node:crypto"; import { secp256k1 } from "@noble/curves/secp256k1"; const bytes = (h: string) => Buffer.from(h.replace(/^0x/, ""), "hex"); // whitelist = Set of your team's known member public keys (lowercase 0x-hex). export function verify( rawBody: string, xSig: string, xTs: string, hmacSecret: string, whitelist: Set, ): boolean { // 1. Envelope: HMAC-SHA256(secret, timestamp + "." + rawBody) const want = createHmac("sha256", hmacSecret).update(xTs + "." + rawBody).digest("hex"); if (want.length !== xSig.length || !timingSafeEqual(Buffer.from(want), Buffer.from(xSig))) { return false; } // (also reject if xTs is far from your clock) const p = JSON.parse(rawBody); // 2. Signed digest = SHA-256 of the canonical string. const digest = createHash("sha256").update(p.canonical_string).digest(); // 3. Verify each signature against a whitelisted key. const seen = new Set(); let valid = 0; for (const s of p.signatures) { const k = s.pubkey.toLowerCase(); if (!whitelist.has(k) || seen.has(k)) continue; if (secp256k1.verify(bytes(s.signature), digest, bytes(s.pubkey))) { seen.add(k); valid++; } } // 4. Enough approvals? return valid >= p.canonical_message.threshold; } ``` ### Python ```python import hashlib, hmac, json from ecdsa import VerifyingKey, SECP256k1, BadSignatureError from ecdsa.util import sigdecode_string # whitelist = set of your team's known member public keys (lowercase 0x-hex). def verify(raw_body: bytes, x_sig: str, x_ts: str, hmac_secret: str, whitelist: set) -> bool: # 1. Envelope: HMAC-SHA256(secret, timestamp + "." + raw_body) want = hmac.new(hmac_secret.encode(), (x_ts + ".").encode() + raw_body, hashlib.sha256).hexdigest() if not hmac.compare_digest(want, x_sig): return False # (also reject if x_ts is far from your clock) p = json.loads(raw_body) # 2. Signed digest = SHA-256 of the canonical string. digest = hashlib.sha256(p["canonical_string"].encode()).digest() # 3. Verify each signature against a whitelisted key. seen, valid = set(), 0 for s in p["signatures"]: k = s["pubkey"].lower() if k not in whitelist or k in seen: continue vk = VerifyingKey.from_string(bytes.fromhex(s["pubkey"][2:]), curve=SECP256k1) try: vk.verify_digest(bytes.fromhex(s["signature"][2:]), digest, sigdecode=sigdecode_string) seen.add(k); valid += 1 except BadSignatureError: pass # 4. Enough approvals? return valid >= p["canonical_message"]["threshold"] ``` ### Rust ```rust use hmac::{Hmac, Mac}; use sha2::{Digest, Sha256}; use std::collections::HashSet; use k256::ecdsa::{signature::hazmat::PrehashVerifier, Signature, VerifyingKey}; // whitelist = your team's known member public keys (lowercase 0x-hex). fn verify(raw_body: &[u8], x_sig: &str, x_ts: &str, hmac_secret: &str, whitelist: &HashSet) -> bool { // 1. Envelope: HMAC-SHA256(secret, timestamp + "." + raw_body) let mut mac = >::new_from_slice(hmac_secret.as_bytes()).unwrap(); mac.update(x_ts.as_bytes()); mac.update(b"."); mac.update(raw_body); if hex::encode(mac.finalize().into_bytes()) != x_sig { return false; // use a constant-time compare in production } let p: serde_json::Value = serde_json::from_slice(raw_body).unwrap(); // 2. Signed digest = SHA-256 of the canonical string. let digest = Sha256::digest(p["canonical_string"].as_str().unwrap().as_bytes()); // 3. Verify each signature against a whitelisted key. let threshold = p["canonical_message"]["threshold"].as_u64().unwrap(); let (mut seen, mut valid) = (HashSet::new(), 0u64); for s in p["signatures"].as_array().unwrap_or(&vec![]) { let pk = s["pubkey"].as_str().unwrap().trim_start_matches("0x"); let sg = s["signature"].as_str().unwrap().trim_start_matches("0x"); let k = pk.to_lowercase(); if !whitelist.contains(&k) || seen.contains(&k) { continue; } if let (Ok(vk), Ok(sig)) = ( VerifyingKey::from_sec1_bytes(&hex::decode(pk).unwrap()), Signature::from_slice(&hex::decode(sg).unwrap()), ) { if vk.verify_prehash(&digest, &sig).is_ok() { seen.insert(k); valid += 1; } } } // 4. Enough approvals? valid >= threshold } ``` **Other languages** — same four steps everywhere: Java/Kotlin (Bouncy Castle), C#/.NET (`NBitcoin.Secp256k1`), PHP (`simplito/elliptic-php`), Ruby (`rbsecp256k1`), Elixir (`ex_secp256k1`). ## Test before going live Don't let your first real delivery be the one that actually fires. Stand up a throwaway receiver that runs the checks above but only logs, create a **test action** pointing at it with `threshold` = full team size (forces everyone to sign), and approve. Test deliveries carry top-level `"test": true` (inside the HMAC-authenticated body, but NOT part of `canonical_string`, so verifiers are unaffected). A ready-to-run receiver: ```ts // 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([ // ["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)); const p = JSON.parse(raw); const digest = createHash("sha256").update(p.canonical_string).digest(); // 2. Check every signature + confirm the key is a known member. const rows = p.signatures.map((s: any) => { const k = s.pubkey.toLowerCase(); const sigValid = secp256k1.verify(bytes(s.signature), digest, bytes(s.pubkey)); const who = MEMBERS.get(k); return { pubkey: k, sigValid, known: !!who, who: who ?? "UNKNOWN KEY" }; }); const validMembers = rows.filter((r) => r.sigValid && r.known).length; const pass = hmacOK && validMembers >= p.canonical_message.threshold; // 3. Print the report — but DO NOT perform the real action. console.log("── teamsigner-test delivery ──────────────"); console.log("action: ", p.canonical_message.action_name); console.log("hmac: ", hmacOK ? "OK" : "FAIL"); console.log("threshold: ", validMembers + "/" + p.canonical_message.threshold + " valid members"); for (const r of rows) console.log(" " + (r.sigValid ? "✓" : "✗") + " " + r.who.padEnd(22) + " " + r.pubkey + (r.known ? "" : " ← 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: validMembers })); }); }).listen(8080, () => console.log("teamsigner-test listening on :8080")); ``` ## Key sync — keep your whitelist current Your verifier's trusted-key whitelist drifts from ours when members rotate keys / join / leave; at emergency time that silently rejects valid approvals. Expose a **key sync URL** on the team's **Integration** screen and we check for drift (on demand + hourly). We `POST` your key sync URL, authenticated with the **per-team sync secret** (shown on the Integration screen), same HMAC envelope as webhooks, plus header `X-TeamSigner-Type: key-sync`: ``` // detect mode (default) — request: { "type": "key-sync", "mode": "detect", "team_id": "…", "issued_at": 1737673200 } // your reply (after verifying our HMAC) — the pubkeys you currently trust: { "keys": ["0x0279be…", "0x02c604…"] } ``` We diff your list against the members we hold: keys we hold that you're missing → **approvals will fail** (critical); keys you trust that we've retired → stale (review). In **push** mode the request also carries a `"members": [{email, pubkey}]` array — apply it to your trust store, then reply with the resulting `keys` the same way. ## Common mistakes - HTML-escaping the canonical JSON (`<`,`>`,`&` → `\u00XX`). Don't — it changes the bytes. - Re-serializing `canonical_message` yourself to verify. Don't — verify against `canonical_string` as sent. - Non-constant-time HMAC compare. Use a constant-time comparison. - Counting duplicate signatures from the same key toward the threshold. Count DISTINCT valid pubkeys. - Forgetting the `"test": true` branch — acting on a test delivery in production. - Not rejecting stale `X-Timestamp` / expired `expires_at`.