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

FieldNotes
Tag a sendsubaddress_instance_idLowercased ASCII, starts alphanumeric, ≤128 bytes. Optional.
Rewrite modesubaddress_modereply_to (default) · from · none. Overrides the mailbox default for one send.
Mailbox defaultdefault_subaddress_modePer-mailbox default rewrite mode, set with a mailbox update.
Read it backsubaddress_instance_idOn 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:

  1. Rewrites the outgoing address according to the effective mode (see below), so the recipient's reply is addressed to mailbox+instance@your-domain.
  2. Injects a secure-reply header bundle (headers prefixed X-ReplyLayer-Agent-*) that is tamper-evident and expires after 30 days.
  3. 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 agent

The 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.
  • ≤128 bytes. 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 with 400 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.

ModeWhat changes on the wireWhen 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.
fromFrom also becomes mailbox+instance@your-domain.Machine-to-machine bridges where the address survives more reliably than headers.
noneNo 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-domain address 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-To or 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:

  1. 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.
  2. In-Reply-To lookup. The In-Reply-To header 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-tenant In-Reply-To spoofing. On a match, the reply inherits that outbound's mailbox and instance ID.
  3. Sub-addressed recipient. The mailbox+detail@domain address is parsed. If a literal + mailbox actually exists (e.g. you really created support+vip), that exact mailbox wins. Otherwise the +detail is stripped and the reply routes to the base mailbox with detail captured as the (sanitized) instance ID.
  4. 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:

SDKsubaddress_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 valueEffect
"ticket-42"sets or updates the instance ID
nullclears 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)
omittedleft 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_id is replaced with <REDACTED> in read responses and webhook payloads, matching how the subject is treated.