Versioning and Compatibility
How webhook payloads are versioned, what each identifier and timestamp means, and how changes and deprecations are announced
MailLand changes webhook payloads only in ways an existing receiver can ignore. This page states that promise, the identifiers and timestamps a receiver can rely on, and what happens before anything incompatible could ever be introduced.
Schema version
Every event type has a payload schema version, a positive integer. All events are at version 1 today.
- The body carries it as
schema_version. - The
X-MLND-Event-Versionheader carries the same value. - A body without
schema_version(sent before the field existed) is version1.
The version is fixed when the event is created, so every retry of one event carries the same version.
The v1 inside X-MLND-Signature is the signature scheme version, which is independent of it.
Identifiers
| Identifier | Where | Stable across retries | Use it for |
|---|---|---|---|
event_id | body, X-MLND-Event-Id | Yes | Deduplication: process each event_id once |
event | body, X-MLND-Event | Yes | The event type, e.g. message.delivered |
Attachment id (inbound.received) | body, data.email.attachments[] | Yes | Recognising an attachment you already downloaded; its download_url is new on every attempt |
| Delivery id | X-MLND-Delivery-Id | No, new on every attempt | Support and diagnostics; it is the publicId that delivery history lists for the attempt |
X-MLND-Attempt | header | No | Attempt number, starting at 1 |
Deduplicate on event_id, never on the delivery id. Events can arrive more than once and out of order;
for message events, order by data.status_version, which only ever increases for one message.
Timestamps
| Field | Meaning |
|---|---|
occurred_at (body) | When the event itself happened. For message events this is the mail server's event time, the same instant as data.timestamp. |
timestamp (body) | When MailLand created the webhook event. Unchanged from earlier versions. |
t in X-MLND-Signature, and X-MLND-Delivery-Timestamp | When this attempt was sent, Unix seconds. Both always hold the same value. |
All body times are UTC in ISO 8601 with a trailing Z. The body is identical on every retry; only the
headers of an attempt change. The signature covers t and the raw body. It does not cover the other
headers, so treat them as routing metadata and trust the body.
Compatibility promise
Receivers must ignore JSON fields and HTTP headers they do not recognise. With that rule, every change below is safe and is made without a new schema version:
- adding a field to the body or to
data, or a new header; - adding a new event type, which you only receive after subscribing to it;
- adding a value to a field this documentation lists as open-ended.
Any of the following is a breaking change and is never made to an existing version:
- removing or renaming a field, header or event type;
- changing a field's type, format, unit or meaning;
- making an optional field required, or a non-null field nullable;
- adding a value to a field documented as a closed list.
A breaking change would ship only as a new schema version that you select explicitly. Existing subscriptions are never moved to a new version automatically.
Deprecation policy
- A deprecation is announced at least 180 days before any migration is requested, in the changelog, in the dashboard and by email to the account's owners.
- The notice names the exact event, version or field, its replacement and how to migrate.
- A deprecated field stays in the payload and a deprecated event stays available to existing subscribers. Deprecation alone never removes anything. Removing a version requires its own announcement, at least 180 days after the deprecation notice.
Exceptions
inbound.received uses this envelope too. One difference: each attachment's download_url is
signed per attempt, so it differs between retries; the attachment's id does not.
Automation Webhook steps POST a flat body with the subscriber's fields, not this envelope. Since
this release they add event_id, occurred_at and schema_version, and they are signed. See below.
Automation Webhook steps
An automation's Webhook steps are signed exactly like customer webhooks: verify X-MLND-Signature with
the signing secret of that step's receiver, using the same steps.
- Each receiver (a distinct step URL) has a secret of its own, so no receiver can produce signatures another receiver accepts. Steps that POST to the same URL are one receiver and share its secret.
- Read the secrets with
GET /v1/marketing/automations/{id}/webhook-signing-secrets: asecretslist of{ webhookUrl, secret }, in step order. They are created when an automation with a Webhook step is created or saved. An edit keeps the secret of every unchanged URL; a new or changed URL gets a new secret. - Replace them with
POST /v1/marketing/automations/{id}/webhook-signing-secrets/regenerate, which gives every receiver a new secret and returns them. The old secrets stop working at once, so first let each receiver accept both. A copy of an automation gets its own secrets, and an automation whose Webhook steps are all removed loses them. - To rotate one receiver only, send
{ "webhookUrl": "<step URL>" }as the body of the regenerate request; every other receiver keeps its secret. A URL that is not a Webhook step URL of the automation is a400. - An automation saved while its receivers still shared one secret is split into per-receiver secrets when it is activated or resumed, or before its next Webhook step is sent. A request is never signed with a secret another receiver holds: if the split cannot be made, the step is not sent.
X-MLND-Event-Idis stable for one execution of one step for one subscriber. Use it, together with the 5-minute window ont, to reject replays.- An automation saved before signing existed sends unsigned requests until it is saved again or its secrets are regenerated.
- These requests are best effort: one attempt and no retry schedule. The automation continues whatever the receiver answers.