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.
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 when you create the action). 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.
What your endpoint must check
- Recompute the HMAC over
X-Timestamp + "." + rawBodyand constant-time compare toX-Signature. Reject a staleX-Timestamp. - 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. - Dedupe on
X-Idempotency-Key, then act.
Verify it in your language
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)
}Other languages
The four steps 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.