Sub-addressing and secure replies
When an agent opens a thread — "support ticket 4821", "order #99 status" — it usually
wants replies to come back to that same agent instance, not just to the shared
mailbox. Sub-addressing gives you that: a single mailbox (support@your-domain)
can accept routing-qualified mail at support+ticket-4821@your-domain, and every
send that carries an instance tag also travels with a tamper-evident header bundle
so an attacker who guesses the address can't inject into the thread.
You opt in per send by setting an instance ID. Everything else — the outgoing address rewrite, the signed headers, the inbound routing back to the right instance — is handled for you.
At a glance
| Field | Notes | |
|---|---|---|
| Tag a send | subaddress_instance_id | Lowercased ASCII, starts alphanumeric, ≤128 bytes. Optional. |
| Rewrite mode | subaddress_mode | reply_to (default) · from · none. Overrides the mailbox default for one send. |
| Mailbox default | default_subaddress_mode | Per-mailbox default rewrite mode, set with a mailbox update. |
| Read it back | subaddress_instance_id | On the inbound message (and every message.* webhook), the resolved instance the reply landed on. |
How the instance ID travels
Give a send an instance ID and ReplyLayer does three things:
- Rewrites the outgoing address according to the effective mode (see below), so
the recipient's reply is addressed to
mailbox+instance@your-domain. - Injects a secure-reply header bundle (headers prefixed
X-ReplyLayer-Agent-*) that is tamper-evident and expires after 30 days. - Resolves the inbound reply back to the originating instance, then exposes it
on the message you read as
subaddress_instance_id.
import { ReplyLayer } from '@replylayer/sdk';
const client = new ReplyLayer({ apiKey: process.env.REPLYLAYER_API_KEY! });
await client.messages.send({
from_mailbox: 'support',
to: '[email protected]',
subject: 'Ticket 4821 opened',
body: 'Hi — we received your request...',
subaddress_instance_id: 'ticket-4821', // routing tag for the reply
});When the customer hits Reply, the inbound arrives carrying one or more of the
routing signals below — the header bundle, your outbound Message-Id in
In-Reply-To, or the support+ticket-4821@your-domain address. Any single one of
them reliably lands the message on the right instance. On read:
const msg = await client.messages.get(inboundMessageId);
msg.subaddress_instance_id; // 'ticket-4821' — resume the right agentThe same field appears on every message.* webhook payload. For the full webhook
event catalog and payload shapes, see /agents/webhooks.
Instance ID rules
Instance IDs are validated strictly when you send and sanitized (never rejected) when a reply comes back in.
On send — the value must match ^[a-z0-9][a-z0-9._-]{0,127}$:
- Lowercase ASCII, starts with a letter or digit.
- Only letters, digits,
.,_,-after the first character. ≤128bytes. The value is lowercased before it is used.- The composed local part (
base+++instance_id) must fit the RFC 5321 64-byte local-part limit. A tag that pushes the whole address over that limit is rejected with400 INVALID_SUBADDRESS_INSTANCE_ID(see /agents/errors for the error catalog).
On inbound — an incoming +detail tag is cleaned up, not bounced: it is
lowercased, truncated to 128 bytes, and stripped of control characters. If nothing
usable remains, the mail simply routes to the base mailbox. An oversized or odd
sub-address never rejects the message — the base mailbox still accepts it.
Rewrite modes
The mode controls how the outgoing address is rewritten. It is a per-mailbox default that you can override on any single send.
| Mode | What changes on the wire | When to use |
|---|---|---|
reply_to (default) | Only Reply-To becomes mailbox+instance@your-domain. From stays clean. | Default — keeps your sender address and display name intact while still steering replies. |
from | From also becomes mailbox+instance@your-domain. | Machine-to-machine bridges where the address survives more reliably than headers. |
none | No address rewrite. The secure-reply headers are still injected. | You're composing headers yourself but still want tamper-evident routing. |
Set the per-mailbox default with a mailbox update:
await client.mailboxes.update(mailboxId, {
scanner_policy: {},
default_subaddress_mode: 'reply_to',
});Override it for one send:
await client.messages.send({
// ...
subaddress_instance_id: 'ticket-4821',
subaddress_mode: 'from', // overrides the mailbox default for this send only
});What the secure-reply headers guarantee
Every outbound that carries an instance ID includes a signed X-ReplyLayer-Agent-*
header bundle. You don't build or verify these — ReplyLayer mints them on the way
out and checks them on the way in. What they buy you:
- Tamper-evidence. The bundle proves the routing headers were minted by ReplyLayer and haven't been altered in transit. A reply that arrives with a forged or edited bundle is treated as if the bundle weren't there at all — it falls through to the other routing signals rather than being trusted.
- Anti-spoofing on the thread. Because a guessed
mailbox+instance@your-domainaddress alone doesn't carry a valid signature, an attacker who guesses the sub-address can't forge their way into your thread via the header path. - A 30-day lifetime. Signatures expire after 30 days. A stale reply (older than
that window) doesn't error — it simply falls through to the
In-Reply-Toor sub-address routing signals, which are longer-lived.
You never handle the signing key, header values, or verification yourself; treat the
X-ReplyLayer-Agent-* headers as opaque platform metadata.
How an inbound reply is routed
Each inbound runs through four routing stages in order. The first stage that produces a target wins; any stage that can't produce a confident target falls through silently to the next. This is observable behavior you can reason about:
- Secure-reply headers. If the signed
X-ReplyLayer-Agent-*bundle is present, well-formed, unexpired, and verifies, the reply routes to the exact(mailbox, instance_id)it names. A signature that verifies but points at a paused, deleted, or different-tenant mailbox falls through instead of routing there. In-Reply-Tolookup. TheIn-Reply-Toheader is matched against your prior outbound message. The match is tenant-scoped — the outbound mailbox's domain must match the inbound recipient's domain — which blocks cross-tenantIn-Reply-Tospoofing. On a match, the reply inherits that outbound's mailbox and instance ID.- Sub-addressed recipient. The
mailbox+detail@domainaddress is parsed. If a literal+mailbox actually exists (e.g. you really createdsupport+vip), that exact mailbox wins. Otherwise the+detailis stripped and the reply routes to the base mailbox withdetailcaptured as the (sanitized) instance ID. - Base mailbox. A plain exact-address match on the mailbox.
If no stage matches, the message is not routable and is rejected at ingest — the
same behavior as any mail sent to an unknown address. It is never a 500.
Composing replies
The instance-ID and mode fields are accepted anywhere you compose outbound mail — send, reply, and drafts — across every surface:
SDK — subaddress_instance_id and subaddress_mode on messages.send and
messages.reply.
CLI:
rly send --instance ticket-4821 --mode reply_to ...
rly reply --instance ticket-4821 ...
rly draft create --instance ticket-4821 --mode from ...MCP — the send, reply, and draft-create tools take subaddress_instance_id and
subaddress_mode.
Editing or clearing on a draft
On a draft you can change or clear the instance before you send:
| Draft patch value | Effect |
|---|---|
"ticket-42" | sets or updates the instance ID |
null | clears the instance; also clears the mode iff you don't set subaddress_mode in the same patch (a draft never carries a mode with no instance) |
| omitted | left unchanged |
To change only the mode and keep the instance, patch subaddress_mode on its own.
Once a draft is sent, the instance is frozen on the outbound message — if the
instance needs to change after that, compose a new send.
Interactions worth knowing
- Suppressions match the exact address. A suppression on
[email protected]does not block[email protected], and vice versa — sub-addressed variants are independent entries. See /docs/guides/suppressions for the do-not-contact contract. - Reply-loop limits count the base address. The outbound reply-loop limiter
buckets on base addresses, so rotating instance IDs can't be used to multiply your
allowed exchange budget — a burst to
bob+i1@y,bob+i2@y, … all counts against the same(you, bob)bucket. - PII redaction covers the instance ID. If you embed an identifier (like an email
address) in an instance ID and the mailbox is set to redacted delivery, the
subaddress_instance_idis replaced with<REDACTED>in read responses and webhook payloads, matching how the subject is treated.
Related
- /agents/messages — message lifecycle and where
subaddress_instance_idsits on the read model. - /agents/webhooks — the
message.*event catalog that carries the resolved instance. - /docs/guides/suppressions — the do-not-contact gate.
- /agents/errors — the error-code catalog, including
INVALID_SUBADDRESS_INSTANCE_ID.