Reference
Signature Verification
Every delivery is signed. Verifying that signature is what proves a request actually came from LobThat and wasn't forged or tampered with in transit — your server should reject any delivery that fails this check.
The headers you receive
Every delivery carries these headers. The three in bold are what the signature is computed from — the rest are informational.
| Header | Meaning |
|---|---|
| webhook-id | Unique per Event — reused on every retry of the same delivery, so you can dedupe on it. |
| webhook-timestamp | Unix seconds when this attempt was sent. Part of the signed string — reject anything too far outside your tolerance window. |
| webhook-signature | v1,{base64(HMAC-SHA256)} — see below. Two space-separated signatures during secret rotation (both valid). |
| webhook-attempt | Attempt number: 1 on first delivery, 2 on first retry, etc. Informational — not part of the signed string. |
| webhook-event | The Event Type name, e.g. order.created. |
| webhook-transform | Present only if a Payload Transform ran — applied; id=<hash>. |
| webhook-test | true on deliveries sent via Test Delivery — absent on real traffic. |
| Idempotency-Key | Present if you supplied one on publish. |
How the signature is computed
Signed string
{webhook-id}.{webhook-timestamp}.{raw request body} — joined with periods, exactly as received. Don't re-serialize the body before verifying; use the raw bytes.Signature
v1,{base64(HMAC-SHA256(signed_string, your_signing_secret))}. Compare using a constant-time comparison, not == — a naive comparison leaks timing information an attacker can use to guess the signature byte by byte.
This is the Standard Webhooks
spec exactly — any off-the-shelf Standard Webhooks verification library for your language works
without modification. You don't have to hand-roll this; the reference implementation below is
here so you know exactly what a library is doing under the hood.
Reference implementation (Go)
This is LobThat's own server-side verification code — the same logic any Standard Webhooks library implements.
Go
// secret: your raw signing secret bytes (from endpoint creation) // webhookID, timestamp, signature: the three headers above // body: the raw request body // tolerance: e.g. 5*time.Minute — pass 0 to skip the timestamp check func Verify(secret []byte, webhookID, timestamp, body, signature string, tolerance time.Duration) error { ts, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return fmt.Errorf("invalid timestamp: %w", err) } if tolerance > 0 { age := time.Since(time.Unix(ts, 0)) if age > tolerance || age < -tolerance { return errors.New("timestamp outside tolerance window") } } signed := webhookID + "." + timestamp + "." + body mac := hmac.New(sha256.New, secret) mac.Write([]byte(signed)) expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)) // Constant-time comparison — never use == here. if !hmac.Equal([]byte(expected), []byte(signature)) { return errors.New("signature mismatch") } return nil }
Secret rotation: dual signatures
While a signing secret is being rotated (old secret not yet sunset), deliveries carry two space-separated signatures in webhook-signature — accept if either one verifies.
webhook-signature during rotation
v1,{base64(HMAC with new secret)} v1,{base64(HMAC with old secret)}
Skip the timestamp check and you're exposed to replay attacks — a captured request could
be re-sent indefinitely and would still verify. Pick a tolerance window (a few minutes is typical)
and reject anything outside it.