MailLandDocs

Signature Verification

Verify webhook signatures to ensure authenticity of incoming webhook payloads

Every webhook delivery includes a cryptographic signature so you can verify the request came from MailLand and was not tampered with in transit. Always verify signatures in production.

How Signing Works

MailLand signs every webhook payload using HMAC-SHA256 with your webhook's secret key. The signature is included in the X-MLND-Signature header.

Signature format:

X-MLND-Signature: t=1708171200,v1=b9f747ea899eafe911777060ca8f3c90828d37f1e16ee6e9ee98dacf8b316b67
ComponentDescription
tUnix timestamp (seconds) when the signature was generated
v1HMAC-SHA256 hex signature (lowercase)

Signed payload:

{timestamp}.{raw_json_body}

The signature is computed over the timestamp concatenated with a period and the raw JSON request body.

Worked Example

Check your implementation against these exact values before you go live. Every byte below is real: the signature is what MailLand produces for this secret, timestamp and body.

InputValue
Secretwhsec_docs_example_secret_do_not_use
Timestamp (t)1708171200
Raw body{"event":"message.delivered","event_id":"evt_a1b2c3d4e5f6"}

Signed payload: the timestamp, a literal period, then the body exactly as received:

1708171200.{"event":"message.delivered","event_id":"evt_a1b2c3d4e5f6"}

HMAC-SHA256 of that string, keyed with the secret, lowercase hex:

b9f747ea899eafe911777060ca8f3c90828d37f1e16ee6e9ee98dacf8b316b67

So the header on this delivery would be:

X-MLND-Signature: t=1708171200,v1=b9f747ea899eafe911777060ca8f3c90828d37f1e16ee6e9ee98dacf8b316b67

Reproduce it in one line:

printf '%s' '1708171200.{"event":"message.delivered","event_id":"evt_a1b2c3d4e5f6"}' \
  | openssl dgst -sha256 -hmac 'whsec_docs_example_secret_do_not_use'

Note that this example's timestamp is in the past, so a real verifier would reject it on the tolerance check. Test the HMAC first with the timestamp check disabled, then re-enable it.

Headers Sent

Every webhook delivery includes these headers:

HeaderExampleDescription
X-MLND-Signaturet=1708171200,v1=abc123...Timestamp and HMAC-SHA256 signature
X-MLND-Event-Idevt_a1b2c3d4e5f6...Unique event ID for deduplication
X-MLND-Eventmessage.deliveredEvent type
X-MLND-Event-Version1Payload schema version
X-MLND-Webhook-Idwh_abc123def456Your webhook's public ID
X-MLND-Attempt1Delivery attempt number (1-based)
X-MLND-Delivery-Idp8r2kx7m9wq4rtId of this attempt, new on every retry; the publicId in delivery history. Not for deduplication: see Versioning
X-MLND-Delivery-Timestamp1708171200When this attempt was sent (Unix seconds); always equals t in the signature

Two more headers are conditional on which event you subscribed to:

HeaderExampleSent when
X-MLND-SourceinboundPresent only on inbound.received deliveries
X-MLND-Inbound-Email-Idinb_a1b2c3d4e5f6Present only on inbound.received deliveries

X-MLND-Message-Id is not currently sent on message.* deliveries. Use the data.message_id field in the event payload instead.

Verification Steps

Extract the timestamp and signature

Parse the X-MLND-Signature header. Split on , to get t=... and v1=... components.

Prepare the signed payload

Concatenate the timestamp, a period (.), and the raw request body:

signed_payload = timestamp + "." + raw_body

Compute the expected signature

Generate an HMAC-SHA256 hash using your webhook secret as the key and the signed payload as the message. Convert to lowercase hex.

Compare signatures

Use a constant-time comparison to check if the computed signature matches the v1 value from the header.

Validate the timestamp

Check that the timestamp is within 5 minutes of the current time to prevent replay attacks.

Code Examples

verify-signature.sh
#!/usr/bin/env bash
# Compute and verify an HMAC-SHA256 webhook signature using openssl

SIGNATURE_HEADER="t=1708171200,v1=b9f747ea899eafe911777060ca8f3c90828d37f1e16ee6e9ee98dacf8b316b67"
RAW_BODY='{"event":"message.delivered","event_id":"evt_a1b2c3d4e5f6"}'
SECRET="whsec_docs_example_secret_do_not_use"
TOLERANCE=300   # set to a huge number to try the values above, whose timestamp is in the past

# Parse timestamp and signature from header
TIMESTAMP=$(echo "$SIGNATURE_HEADER" | grep -oP '(?<=t=)[^,]+')
RECEIVED_SIG=$(echo "$SIGNATURE_HEADER" | grep -oP '(?<=v1=).+')

# Check timestamp tolerance
NOW=$(date +%s)
DIFF=$(( NOW - TIMESTAMP ))
if [ "${DIFF#-}" -gt "$TOLERANCE" ]; then
  echo "Error: Timestamp outside tolerance window" >&2
  exit 1
fi

# Compute expected signature
SIGNED_PAYLOAD="${TIMESTAMP}.${RAW_BODY}"
EXPECTED=$(printf '%s' "$SIGNED_PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

# Compare signatures
if [ "$EXPECTED" = "$RECEIVED_SIG" ]; then
  echo "Signature valid"
else
  echo "Signature invalid" >&2
  exit 1
fi

Replay Attack Prevention

The timestamp in the signature header prevents replay attacks. If an attacker captures a valid webhook delivery and resends it later, the timestamp check will reject it.

MailLand's default tolerance is 5 minutes (300 seconds). You can adjust this in your verification code, but keep it short enough to prevent meaningful replay attacks.

Additionally, each event has a unique event_id (in the X-MLND-Event-Id header). Store processed event IDs and reject duplicates for extra protection.

MailLand deduplicates dispatch on its side: an event that is generated twice is only ever dispatched once per webhook within a 24-hour window. That is not the same as "delivered at most once": a delivery that fails is retried, and every retry carries the same event_id and the same body, with only X-MLND-Attempt and the signature timestamp changing. Deduplicate on event_id and treat repeat deliveries as expected.

Every Delivery Is Signed

There is no unsigned delivery path. If a webhook has no usable signing secret, MailLand fails the delivery and records the failure rather than sending the payload without an X-MLND-Signature header, so a missing signature header is never something your verifier has to tolerate. Reject any request that does not carry one.

Regenerating Secrets

If you suspect your webhook secret has been compromised:

  1. Call POST /v1/webhooks/{id}/regenerate-secret to get a new secret
  2. Update your server's verification code with the new secret
  3. The old secret is invalidated immediately

There is no grace period when regenerating secrets. Update your verification code before the next webhook delivery to avoid rejecting valid events.

Common Mistakes

  • Parsing the body as JSON before verifying: the signature is computed over the raw bytes, not a re-serialized JSON object. Verify first, then parse.
  • Using string comparison instead of constant-time: regular string comparison is vulnerable to timing attacks.
  • Ignoring the timestamp: without timestamp validation, captured payloads can be replayed indefinitely.
  • Hardcoding the secret: use environment variables or a secrets manager.

Next Steps

On this page