package webhooksign
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
)
type Signer struct {
Secret []byte
Now func() time.Time
}
func NewSigner(secret []byte) *Signer {
return &Signer{Secret: secret, Now: time.Now}
}
func canonical(ts int64, body []byte) []byte {
return []byte(fmt.Sprintf("v1:%d:%s", ts, body))
}
// Sign returns the timestamp used and the hex-encoded HMAC-SHA256 digest.
func (s *Signer) Sign(body []byte) (ts int64, signature string) {
ts = s.Now().Unix()
mac := hmac.New(sha256.New, s.Secret)
mac.Write(canonical(ts, body))
return ts, hex.EncodeToString(mac.Sum(nil))
}
package webhooksign
import (
"bytes"
"io"
"net/http"
"strconv"
)
type SigningTransport struct {
Signer *Signer
Base http.RoundTripper
}
func (t *SigningTransport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return http.DefaultTransport
}
func (t *SigningTransport) RoundTrip(req *http.Request) (*http.Response, error) {
var body []byte
if req.Body != nil {
b, err := io.ReadAll(req.Body)
req.Body.Close()
if err != nil {
return nil, err
}
body = b
}
ts, sig := t.Signer.Sign(body)
signed := req.Clone(req.Context())
signed.Body = io.NopCloser(bytes.NewReader(body))
signed.ContentLength = int64(len(body))
signed.Header.Set("X-Signature", "v1="+sig)
signed.Header.Set("X-Signature-Timestamp", strconv.FormatInt(ts, 10))
return t.base().RoundTrip(signed)
}
package webhooksign
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
"time"
)
var (
ErrBadSignature = errors.New("webhooksign: signature mismatch")
ErrExpired = errors.New("webhooksign: timestamp outside tolerance")
)
func (s *Signer) Verify(body []byte, ts int64, header string, tolerance time.Duration) error {
drift := s.Now().Sub(time.Unix(ts, 0))
if drift < -tolerance || drift > tolerance {
return ErrExpired
}
provided, err := hex.DecodeString(strings.TrimPrefix(header, "v1="))
if err != nil {
return ErrBadSignature
}
mac := hmac.New(sha256.New, s.Secret)
mac.Write(canonical(ts, body))
if !hmac.Equal(provided, mac.Sum(nil)) {
return ErrBadSignature
}
return nil
}
var _ = sha256.Size
Signing outgoing webhooks lets receivers verify that a payload really came from the sender and was not tampered with in transit. The pattern shown here computes an HMAC-SHA256 over a canonical string built from a timestamp and the request body, keyed by a shared secret. Including the timestamp inside the signed string is what defends against replay attacks: a receiver rejects requests whose timestamp is too old, so a captured payload cannot be resent indefinitely.
signer.go defines the core Signer, which holds the secret and a Now clock function so tests can inject deterministic time. Sign builds the canonical message as v1:<unix-seconds>:<body> and returns both the timestamp and a hex-encoded digest. Keeping the message format explicit and versioned (v1:) matters because both sides must agree byte-for-byte; any difference in field order or separators produces a mismatched signature. The scheme prefix also leaves room to rotate the algorithm later without breaking old receivers.
transport.go wires the signer into Go's HTTP stack as a http.RoundTripper. This is the idiomatic place to cross-cut every outgoing request: SigningTransport.RoundTrip reads and buffers the body with io.ReadAll, restores it via io.NopCloser so the real transport can still send it, then sets X-Signature and X-Signature-Timestamp headers. Buffering is necessary because a request body is a one-shot stream; reading it to sign would otherwise consume it. It clones the request with req.Clone to avoid mutating the caller's object, which keeps the transport safe for concurrent reuse. Requests without a body are signed over an empty payload, so GETs still carry a valid signature.
verify.go shows the receiving side. Verify recomputes the digest from the same canonical string and compares using hmac.Equal, a constant-time comparison that avoids timing side channels — a plain == would leak information about how many leading bytes matched. It also enforces a freshness window with tolerance, returning ErrExpired when the timestamp drifts beyond it. Together these three files form a symmetric, testable signing contract: the same message construction on both ends, constant-time verification, and timestamp-bound replay protection.
Related snips
package api
import (
"net/http"
"runtime/debug"
)
Expose build metadata for debugging deploys
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
timestamp = request.headers.fetch('X-Signature-Timestamp')
signature = request.headers.fetch('X-Signature')
payload = request.raw_post
data = "#{timestamp}.#{payload}"
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('WEBHOOK_SECRET'), data)
HMAC signed API requests for webhook and partner integrity
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
#!/usr/bin/env bash
set -euo pipefail
export VAULT_ADDR="https://vault.internal:8200"
export VAULT_TOKEN="${VAULT_TOKEN:?missing VAULT_TOKEN}"
Secrets management with environment isolation and Vault
Share this code
Here's the card — post it anywhere.