# 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:

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`.

```ts
import { ReplyLayer } from '@replylayer/sdk';

const client = new ReplyLayer({ apiKey: process.env.REPLYLAYER_API_KEY! });

await client.messages.send({
  from_mailbox: 'support',
  to: 'customer@example.com',
  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:

```ts
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](/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](/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:

```ts
await client.mailboxes.update(mailboxId, {
  scanner_policy: {},
  default_subaddress_mode: 'reply_to',
});
```

Override it for one send:

```ts
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:

**SDK** — `subaddress_instance_id` and `subaddress_mode` on `messages.send` and
`messages.reply`.

**CLI:**

```bash
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 `alice@x.com` does
  **not** block `alice+foo@x.com`, and vice versa — sub-addressed variants are
  independent entries. See [/docs/guides/suppressions](/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.

## Related

- [/agents/messages](/agents/messages) — message lifecycle and where
  `subaddress_instance_id` sits on the read model.
- [/agents/webhooks](/agents/webhooks) — the `message.*` event catalog that carries
  the resolved instance.
- [/docs/guides/suppressions](/docs/guides/suppressions) — the do-not-contact gate.
- [/agents/errors](/agents/errors) — the error-code catalog, including
  `INVALID_SUBADDRESS_INSTANCE_ID`.
