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

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

  1. Recompute the HMAC over X-Timestamp + "." + rawBody and constant-time compare to X-Signature. Reject a stale X-Timestamp.
  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. 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:

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.