{"record":{"id":"c8e6097afc277024","repo":"knadh/listmonk","slug":"signature-timestamp-expired","errorCode":null,"errorMessage":"signature timestamp expired","messagePattern":"signature timestamp expired","errorType":"http","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/bounce/webhooks/lettermint.go","lineNumber":58,"sourceCode":"func NewLettermint(key []byte) *Lettermint {\n\treturn &Lettermint{hmacKey: key}\n}\n\n// ProcessBounce processes an incoming Lettermint webhook payload and returns a bounce object.\nfunc (l *Lettermint) ProcessBounce(sig string, body []byte) ([]models.Bounce, error) {\n\tif len(l.hmacKey) == 0 {\n\t\treturn nil, fmt.Errorf(\"webhook key is not configured\")\n\t}\n\n\t// Parse the signature header: t={timestamp},v1={hex_signature}.\n\tts, sigHex, err := parseLettermintSignature(sig)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Verify timestamp tolerance (300 seconds).\n\tif math.Abs(float64(time.Now().Unix()-ts)) > 300 {\n\t\treturn nil, fmt.Errorf(\"signature timestamp expired\")\n\t}\n\n\t// Decode the hex signature from the header.\n\tsigB, err := hex.DecodeString(strings.TrimSpace(sigHex))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid signature encoding: %v\", err)\n\t}\n\n\t// Compute HMAC-SHA256 of \"{timestamp}.{body}\" and compare.\n\tmac := hmac.New(sha256.New, l.hmacKey)\n\tmac.Write([]byte(fmt.Sprintf(\"%d.%s\", ts, body)))\n\n\tif !hmac.Equal(mac.Sum(nil), sigB) {\n\t\treturn nil, fmt.Errorf(\"invalid signature\")\n\t}\n\n\tvar n lettermintNotif\n\tif err := json.Unmarshal(body, &n); err != nil {","sourceCodeStart":40,"sourceCodeEnd":76,"githubUrl":"https://github.com/knadh/listmonk/blob/670c01717d48647093335cc23a6be6f4b79c3b6b/internal/bounce/webhooks/lettermint.go#L40-L76","documentation":"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).","triggerScenarios":"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.","commonSituations":"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.","solutions":["Sync the server clock with NTP (chrony/systemd-timesyncd) — clock skew is the most common cause of false expirations.","Ensure webhooks are processed promptly on receipt; if queueing, process within 5 minutes or accept that stale events will be rejected.","Replay only fresh signed requests when testing; re-request a test webhook from Lettermint instead of reusing old captures.","If delays are unavoidable, consider a proxy that re-validates/forwards immediately rather than buffering signed bodies."],"exampleFix":"// before: stale signature reused in a test\nsig := \"t=1700000000,v1=abcd...\" // days old\nbounces, err := lm.ProcessBounce(sig, body) // signature timestamp expired\n\n// after: generate a fresh signature at request time\nts := time.Now().Unix()\nmac := hmac.New(sha256.New, key)\nmac.Write([]byte(fmt.Sprintf(\"%d.%s\", ts, body)))\nsig := fmt.Sprintf(\"t=%d,v1=%s\", ts, hex.EncodeToString(mac.Sum(nil)))","handlingStrategy":"try-catch","validationCode":"func isFreshLettermintSignature(sig string) (bool, error) {\n    // parse t=... and compare against now within 300s before calling the handler\n    for _, part := range strings.Split(sig, \",\") {\n        if kv := strings.SplitN(strings.TrimSpace(part), \"=\", 2); len(kv) == 2 && kv[0] == \"t\" {\n            var ts int64\n            if _, err := fmt.Sscanf(kv[1], \"%d\", &ts); err != nil {\n                return false, err\n            }\n            return math.Abs(float64(time.Now().Unix()-ts)) <= 300, nil\n        }\n    }\n    return false, errors.New(\"no timestamp in signature\")\n}","typeGuard":null,"tryCatchPattern":"bounces, err := lm.ProcessBounce(sig, body)\nif err != nil {\n    if err.Error() == \"signature timestamp expired\" {\n        // stale or clock-skewed; optionally reject old events without alerting\n        log.Printf(\"stale lettermint signature (ts too old or clock skew): %v\", err)\n        http.Error(w, \"expired signature\", http.StatusUnauthorized)\n        return\n    }\n    http.Error(w, \"bad request\", http.StatusBadRequest)\n}","preventionTips":["Run NTP time sync on all servers processing webhooks.","Process webhook requests immediately; don't queue them longer than the 5-minute tolerance.","Monitor for spikes of this error — a sudden rise usually means clock drift, not an attack.","In tests, always generate fresh signatures at request time."],"tags":["webhook","hmac","timestamp","replay-protection","lettermint"],"backgroundTag":"webhook-signature-timestamp-expired","analyzedSha":"670c01717d48647093335cc23a6be6f4b79c3b6b","analyzedAt":"2026-09-01T03:39:35.452Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-08T10:18:20.063Z"}