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| Component | Description |
|---|---|
t | Unix timestamp (seconds) when the signature was generated |
v1 | HMAC-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.
| Input | Value |
|---|---|
| Secret | whsec_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:
b9f747ea899eafe911777060ca8f3c90828d37f1e16ee6e9ee98dacf8b316b67So the header on this delivery would be:
X-MLND-Signature: t=1708171200,v1=b9f747ea899eafe911777060ca8f3c90828d37f1e16ee6e9ee98dacf8b316b67Reproduce 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:
| Header | Example | Description |
|---|---|---|
X-MLND-Signature | t=1708171200,v1=abc123... | Timestamp and HMAC-SHA256 signature |
X-MLND-Event-Id | evt_a1b2c3d4e5f6... | Unique event ID for deduplication |
X-MLND-Event | message.delivered | Event type |
X-MLND-Event-Version | 1 | Payload schema version |
X-MLND-Webhook-Id | wh_abc123def456 | Your webhook's public ID |
X-MLND-Attempt | 1 | Delivery attempt number (1-based) |
X-MLND-Delivery-Id | p8r2kx7m9wq4rt | Id of this attempt, new on every retry; the publicId in delivery history. Not for deduplication: see Versioning |
X-MLND-Delivery-Timestamp | 1708171200 | When this attempt was sent (Unix seconds); always equals t in the signature |
Two more headers are conditional on which event you subscribed to:
| Header | Example | Sent when |
|---|---|---|
X-MLND-Source | inbound | Present only on inbound.received deliveries |
X-MLND-Inbound-Email-Id | inb_a1b2c3d4e5f6 | Present 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_bodyCompute 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
#!/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
fiReplay 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:
- Call
POST /v1/webhooks/{id}/regenerate-secretto get a new secret - Update your server's verification code with the new secret
- 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
- Retry Logic: understand retry behavior and failure handling
- Events: complete event type reference
- Webhooks Overview: create and manage webhooks