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
- Member signatures — each approval is an ECDSA signature on the secp256k1 curve, made on the member's device. The private key never leaves the phone; only the compressed public key (33 bytes,
0x-hex) is ever shared. - What is signed — a deterministic canonical string committing to the whole authorization (action, threshold, webhook URL, initiator, reason, issue/expiry timestamps). The signed digest is
SHA-256(canonical_string). Signatures are compactr||s, 64 bytes,0x-hex. - Envelope authenticity — the whole HTTP body is authenticated with HMAC-SHA256 using a per-action shared secret (shown on the action's page — give it to whoever builds your endpoint). Header
X-Signature = hex(HMAC(secret, X-Timestamp + "." + rawBody)). - Replay protection —
X-Timestamp(reject stale),X-Idempotency-Key= the request id (dedupe), andexpires_atinside the message.
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:
- The action's HMAC secret — open the action (web: the web app → your team → the action; mobile: the same action screen) and copy Webhook HMAC secret. It is generated when the action is created, is unique to that action, and is fixed for the action's lifetime — there is no rotate button, so to replace a leaked secret, create a new action and repoint your endpoint at it. Hand it to whoever builds the endpoint the same way you'd hand over any credential, and store it in your secret manager, not in source control.
- Your members' public keys — the team screen lists every member with their registered key(s). These are the keys your verifier whitelists. They are public by design, but the list changes whenever someone joins, leaves, or rotates a key, which is what key sync below exists to catch.
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
- Recompute the HMAC over
X-Timestamp + "." + rawBodyand constant-time compare toX-Signature. Reject a staleX-Timestamp(recommended: within ~5 minutes of your clock). - Confirm
expires_athas not passed. - Verify each
signatureoverSHA-256(canonical_string)using itspubkey. - Confirm each
pubkeyis a current, whitelisted member of your team, and count only distinct valid ones — require at leastthreshold. - If the body carries
"test": true, verify and log but do not act — it came from a test action. - 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:
- Java / Kotlin — Bouncy Castle (
org.bouncycastle), curvesecp256k1. - C# / .NET —
NBitcoin.Secp256k1or Bouncy Castle. - PHP —
simplito/elliptic-php(or thesecp256k1PECL extension). - Ruby —
ecdsagem, orrbsecp256k1(libsecp256k1 bindings). - Elixir —
:cryptofor HMAC/SHA-256 + a secp256k1 NIF such asex_secp256k1.
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.
- Stand up a
teamsigner-testendpoint. It runs the exact checks from above — HMAC, digest, every signature, member whitelist — but instead of acting, it returns200and prints a report. Keep it deployed only while you're setting up. - 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.
- 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).
- 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:
- The commit is inside the signature. The canonical message carries
subject: "github.merge|<repo_id>|<pr>|<sha40>", so an approval authorizes exactly one commit on one pull request. We re-derive the SHA from the signed subject before unblocking, and refuse if it doesn't match the commit the check covers. - One named signer.
required_signerpins the request to the person who was asked, andwebhook_urlis empty because nothing is delivered outbound.
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.
- On the Integration screen, set your key sync URL and copy the generated HMAC secret.
- We
POSTthat URL with the same envelope as a webhook —X-Signature=hex(HMAC(secret, X-Timestamp + "." + rawBody)), plusX-TeamSigner-Type: key-sync. - Verify the HMAC, then respond
200with 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.