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.

HeaderMeaning
webhook-idUnique per Event — reused on every retry of the same delivery, so you can dedupe on it.
webhook-timestampUnix seconds when this attempt was sent. Part of the signed string — reject anything too far outside your tolerance window.
webhook-signaturev1,{base64(HMAC-SHA256)} — see below. Two space-separated signatures during secret rotation (both valid).
webhook-attemptAttempt number: 1 on first delivery, 2 on first retry, etc. Informational — not part of the signed string.
webhook-eventThe Event Type name, e.g. order.created.
webhook-transformPresent only if a Payload Transform ran — applied; id=<hash>.
webhook-testtrue on deliveries sent via Test Delivery — absent on real traffic.
Idempotency-KeyPresent 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.