knadh/listmonk · error

signature timestamp expired

Error message

signature timestamp expired

What it means

Lettermint signs webhooks with a timestamped scheme (t={timestamp},v1={hex}). To prevent replay attacks, ProcessBounce rejects signatures whose timestamp is more than 300 seconds (5 minutes) away from the server's current time. This error means the signature header parsed fine but is stale (or skewed).

Source

Thrown at internal/bounce/webhooks/lettermint.go:58

func NewLettermint(key []byte) *Lettermint {
	return &Lettermint{hmacKey: key}
}

// ProcessBounce processes an incoming Lettermint webhook payload and returns a bounce object.
func (l *Lettermint) ProcessBounce(sig string, body []byte) ([]models.Bounce, error) {
	if len(l.hmacKey) == 0 {
		return nil, fmt.Errorf("webhook key is not configured")
	}

	// Parse the signature header: t={timestamp},v1={hex_signature}.
	ts, sigHex, err := parseLettermintSignature(sig)
	if err != nil {
		return nil, err
	}

	// Verify timestamp tolerance (300 seconds).
	if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
		return nil, fmt.Errorf("signature timestamp expired")
	}

	// Decode the hex signature from the header.
	sigB, err := hex.DecodeString(strings.TrimSpace(sigHex))
	if err != nil {
		return nil, fmt.Errorf("invalid signature encoding: %v", err)
	}

	// Compute HMAC-SHA256 of "{timestamp}.{body}" and compare.
	mac := hmac.New(sha256.New, l.hmacKey)
	mac.Write([]byte(fmt.Sprintf("%d.%s", ts, body)))

	if !hmac.Equal(mac.Sum(nil), sigB) {
		return nil, fmt.Errorf("invalid signature")
	}

	var n lettermintNotif
	if err := json.Unmarshal(body, &n); err != nil {

View on GitHub (pinned to 670c01717d)

Solutions

  1. Sync the server clock with NTP (chrony/systemd-timesyncd) — clock skew is the most common cause of false expirations.
  2. Ensure webhooks are processed promptly on receipt; if queueing, process within 5 minutes or accept that stale events will be rejected.
  3. Replay only fresh signed requests when testing; re-request a test webhook from Lettermint instead of reusing old captures.
  4. If delays are unavoidable, consider a proxy that re-validates/forwards immediately rather than buffering signed bodies.

Example fix

// before: stale signature reused in a test
sig := "t=1700000000,v1=abcd..." // days old
bounces, err := lm.ProcessBounce(sig, body) // signature timestamp expired

// after: generate a fresh signature at request time
ts := time.Now().Unix()
mac := hmac.New(sha256.New, key)
mac.Write([]byte(fmt.Sprintf("%d.%s", ts, body)))
sig := fmt.Sprintf("t=%d,v1=%s", ts, hex.EncodeToString(mac.Sum(nil)))
Defensive patterns

Strategy: try-catch

Validate before calling

func isFreshLettermintSignature(sig string) (bool, error) {
    // parse t=... and compare against now within 300s before calling the handler
    for _, part := range strings.Split(sig, ",") {
        if kv := strings.SplitN(strings.TrimSpace(part), "=", 2); len(kv) == 2 && kv[0] == "t" {
            var ts int64
            if _, err := fmt.Sscanf(kv[1], "%d", &ts); err != nil {
                return false, err
            }
            return math.Abs(float64(time.Now().Unix()-ts)) <= 300, nil
        }
    }
    return false, errors.New("no timestamp in signature")
}

Try / catch

bounces, err := lm.ProcessBounce(sig, body)
if err != nil {
    if err.Error() == "signature timestamp expired" {
        // stale or clock-skewed; optionally reject old events without alerting
        log.Printf("stale lettermint signature (ts too old or clock skew): %v", err)
        http.Error(w, "expired signature", http.StatusUnauthorized)
        return
    }
    http.Error(w, "bad request", http.StatusBadRequest)
}

Prevention

When it happens

Trigger: Processing a webhook whose signature was generated >300s ago: delayed delivery/retries from Lettermint, a queued/replayed request, clock drift between your server and Lettermint, or replaying a captured request in tests.

Common situations: Server clock out of sync (no NTP) causing even fresh webhooks to fail; webhook queue backlog delaying processing beyond 5 minutes; debugging with an old saved request; container with wrong timezone/clock; Lettermint retrying a failed delivery after their own backoff.

Related errors


AI-assisted analysis of knadh/listmonk@670c01717d (2026-09-01). Data as JSON: /api/errors/c8e6097afc277024. Report an issue: GitHub.