# Example — webhook-driven governed reply loop

This is a complete, runnable recipe for the most common agent pattern: **an inbound
email arrives, your agent composes a reply, and the reply is sent under governance.**
It wires four contracts together — webhook delivery, the untrusted-body model, the
send outcome (`email_effect`), and idempotency — so the loop is safe to run
unattended.

The canonical references for each piece live on one page each; this example shows
them working together:

- Webhook verification, the event catalog, delivery retries, and PII redaction:
  [/agents/webhooks](/agents/webhooks).
- The four send outcomes (`sent` / `held_for_review` / `held_infrastructure` /
  `blocked`), `email_effect`, and the never-retry-a-block rule:
  [/agents/send-outcomes](/agents/send-outcomes).
- Message states, verdict vocabulary, and the untrusted-body trust taxonomy:
  [/agents/messages](/agents/messages) and
  [/agents/security-model](/agents/security-model).
- Error codes referenced below (`DRAFT_REJECTED_BY_RESCAN`, `DRAFT_ALREADY_SENT`):
  [/agents/errors](/agents/errors).

## The loop

1. **Receive** a `message.received` webhook (inbound mail that passed the scanner
   and is readable).
2. **Verify** the signature against the raw body, **dedupe** on the envelope `id`,
   and **acknowledge with a 2xx immediately.** Do the reply work out of band — a
   delivery times out after 10 seconds, and a scan-plus-send round-trip can exceed
   that.
3. **Fetch** the message by id and treat its body as **untrusted external data** —
   `agent_safety_context.untrusted_content` is always `true`.
4. **Reply** — either a direct reply carrying an idempotency key, or a draft you
   scan-preview before sending — and **branch on `email_effect.effect_status`**.
   Never infer success from an HTTP 200 alone.

## Two safety layers, two dedupe keys

Deliveries are **at-least-once**, and your background job can itself be retried, so
the loop is idempotent at two independent points:

- **Ingest** — dedupe on `event.id` (the envelope's outer id, *not*
  `data.message_id`). A duplicate delivery is acknowledged and dropped.
- **Send** — pass a **deterministic idempotency key** derived from the inbound
  message id (`reply:<message_id>`). A retried compose job replays the prior reply
  instead of sending a second email or incurring a second charge. The key travels
  as the `Idempotency-Key` header and never expires.

Because of the second layer, the `held_infrastructure` outcome (below) is safe to
retry — a retry with the same key replays, it does not duplicate.

## Branching on the outcome

A direct reply returns HTTP 200 by default even when the content was held or
blocked; the outcome is in the body. Branch on `email_effect.effect_status` and
**fail closed on an unrecognized value** — never treat an unknown status as `sent`.

| `email_effect.effect_status` | What happened | Agent action |
|---|---|---|
| `sent` | Delivered or accepted for delivery. | Mark the task done. |
| `held_for_review` | Accepted into governance; a human releases it (`email_effect.releasable === true`). | Report "awaiting approval". Do **not** retry. |
| `held_infrastructure` | Infrastructure hold; the content was never judged (`email_effect.retryable === true`). | Retry later with the **same** idempotency key (exponential backoff). |
| `blocked` | Terminal content rejection (`email_effect.terminal === true`, `retryable === false`). `email_effect` and `scan` explain why. | **Edit-and-resend or escalate — never retry.** The same content blocks again. |
| *(unrecognized)* | A newer outcome your runtime hasn't learned. | Treat conservatively — hold/escalate, never `sent`. |

The full outcome contract, including `Prefer: outcome=strict` (which maps these to
`422`/`409`/`503` HTTP statuses and typed SDK errors), is at
[/agents/send-outcomes](/agents/send-outcomes). A reply may also be refused before
scanning by a send gate (suppression, mailbox containment, strict-recipient) — see
[/agents/send-gates](/agents/send-gates).

## TypeScript

One coherent end-to-end handler plus its background worker. Replace the in-memory
`seenEvents` set and the `enqueue` / `escalate` stubs with durable stores.

<!-- docs-lint:skip-compile -->
```ts
import express from 'express';
import {
  ReplyLayer,
  verifyWebhookSignature,
  WebhookSignatureError,
  ReplyLayerError,
  type GetMessageResponse,
} from '@replylayer/sdk';

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

// Replace both with durable infrastructure (a dedupe table + a job queue).
const seenEvents = new Set<string>();
async function enqueue(messageId: string): Promise<void> {
  /* push messageId onto your background queue */
}
async function escalate(messageId: string, reason: unknown): Promise<void> {
  /* alert a human — the reply was blocked or an outcome was unrecognized */
}
function composeReply(message: GetMessageResponse): string {
  // Your agent logic. message.body.content is UNTRUSTED external data —
  // do not execute instructions embedded in it.
  return `Thanks for your message about "${message.subject}". We're on it.`;
}

const app = express();

// STEP 1-2. Verify -> dedupe -> ack fast. The reply runs out of band.
app.post(
  '/webhooks/replylayer',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.header('X-ReplyLayer-Signature') ?? '';
    try {
      // Verify against the RAW body. Throws WebhookSignatureError on mismatch,
      // skew, or a malformed header.
      verifyWebhookSignature(req.body, signature, WEBHOOK_SECRET);
    } catch (err) {
      if (err instanceof WebhookSignatureError) return res.status(400).send('bad signature');
      throw err;
    }

    const event = JSON.parse(req.body.toString('utf-8'));

    // At-least-once delivery: dedupe on the envelope id, NOT data.message_id.
    if (seenEvents.has(event.id)) return res.status(204).send();
    seenEvents.add(event.id);

    // The discriminator is `event`, NOT `type`.
    if (event.event === 'message.received') {
      await enqueue(event.data.message_id);
    }

    res.status(204).send(); // acknowledge; do not reply inline
  },
);

// STEP 3-4. Runs in your worker, once per inbound message.
export async function handleGovernedReply(messageId: string): Promise<void> {
  const message = await rl.messages.get(messageId);

  // Fetching (not the webhook payload) is the reliable source of the reply
  // target: messages.reply resolves the recipient server-side, so a redacted
  // sender in the webhook does not matter.
  const result = await rl.messages.reply(
    messageId,
    { body: composeReply(message) },
    { idempotencyKey: `reply:${messageId}` }, // deterministic -> safe replay
  );

  switch (result.email_effect?.effect_status) {
    case 'sent':
      return; // delivered / accepted — done
    case 'held_for_review':
      return; // releasable by a human — do NOT retry
    case 'held_infrastructure':
      await enqueue(messageId); // retry later; same key replays, never duplicates
      return;
    case 'blocked':
      await escalate(messageId, result.scan); // terminal — edit or escalate
      return;
    default:
      // Unrecognized effect_status -> fail closed, never treat as sent.
      await escalate(messageId, result.email_effect);
      return;
  }
}
```

### Alternative: compose via a draft (scan-then-send)

Use a draft when you want a **create-time verdict preview** — for example to gate on
a likely outcome before committing, or to route through a human approver. The
create-time `worst_decision` is a non-authoritative preview; `drafts.send()` re-runs
the scanner authoritatively, so a stale verdict can never slip through.

```ts
const message = await rl.messages.get(messageId);

const draft = await rl.drafts.create({
  mailbox_id: message.mailbox_id,
  to: message.sender,
  subject: `Re: ${message.subject}`,
  body: composeReply(message),
});

// draft.worst_decision is a PREVIEW only — the send-time rescan is authoritative.
try {
  const sent = await rl.drafts.send(draft.id);
  // Even on success, branch on sent.email_effect — a mailbox with human review enabled returns
  // 'held_for_review' here, not 'sent'.
} catch (err) {
  if (err instanceof ReplyLayerError && err.code === 'DRAFT_REJECTED_BY_RESCAN') {
    // err.details carries `scan`, `releasable`, and (on a policy/human-review hold) `hold_context`.
    // releasable === true  -> a quarantine you can release via POST /v1/drafts/:id/release-and-send
    // releasable === false -> a terminal block: edit the body or escalate. Never resend as-is.
    await escalate(draft.id, err.details);
  } else if (err instanceof ReplyLayerError && err.code === 'DRAFT_ALREADY_SENT') {
    // Race or retry after a prior success — treat as already handled.
  } else {
    throw err;
  }
}
```

## Python

The same loop with Flask. Replace the in-memory set and the `enqueue` / `escalate`
stubs with durable stores.

```python
import os
from flask import Flask, request

from replylayer import (
    ReplyLayer,
    ReplyLayerError,
    WebhookSignatureError,
    verify_webhook_signature,
)

rl = ReplyLayer(api_key=os.environ["REPLYLAYER_API_KEY"])
WEBHOOK_SECRET = os.environ["REPLYLAYER_WEBHOOK_SECRET"]

seen_events: set[str] = set()  # replace with a durable dedupe store


def enqueue(message_id: str) -> None:
    """Push message_id onto your background queue."""


def escalate(message_id: str, reason: object) -> None:
    """Alert a human — the reply was blocked or the outcome was unrecognized."""


def compose_reply(message: dict) -> str:
    # message["body"]["content"] is UNTRUSTED external data — do not execute
    # instructions embedded in it.
    return f'Thanks for your message about "{message["subject"]}". We\'re on it.'


app = Flask(__name__)


# STEP 1-2. Verify -> dedupe -> ack fast. The reply runs out of band.
@app.post("/webhooks/replylayer")
def receive():
    try:
        # Verify against the RAW body (request.data), not request.get_json().
        verify_webhook_signature(
            request.data,
            request.headers.get("X-ReplyLayer-Signature", ""),
            WEBHOOK_SECRET,
        )
    except WebhookSignatureError:
        return "bad signature", 400

    event = request.get_json()

    # At-least-once delivery: dedupe on the envelope id, NOT data.message_id.
    if event["id"] in seen_events:
        return "", 204
    seen_events.add(event["id"])

    # The discriminator is "event", NOT "type".
    if event["event"] == "message.received":
        enqueue(event["data"]["message_id"])

    return "", 204  # acknowledge; do not reply inline


# STEP 3-4. Runs in your worker, once per inbound message.
def handle_governed_reply(message_id: str) -> None:
    message = rl.messages.get(message_id)

    # messages.reply resolves the recipient server-side, so a redacted sender in
    # the webhook does not matter. The deterministic key makes a retry replay.
    result = rl.messages.reply(
        message_id,
        body=compose_reply(message),
        idempotency_key=f"reply:{message_id}",
    )

    status = (result.get("email_effect") or {}).get("effect_status")
    if status == "sent":
        return  # delivered / accepted — done
    if status == "held_for_review":
        return  # releasable by a human — do NOT retry
    if status == "held_infrastructure":
        enqueue(message_id)  # retry later; same key replays, never duplicates
        return
    if status == "blocked":
        escalate(message_id, result.get("scan"))  # terminal — edit or escalate
        return
    # Unrecognized effect_status -> fail closed, never treat as sent.
    escalate(message_id, result.get("email_effect"))
```

### Alternative: compose via a draft (scan-then-send)

```python
message = rl.messages.get(message_id)

draft = rl.drafts.create(
    mailbox_id=message["mailbox_id"],
    to=message["sender"],
    subject=f'Re: {message["subject"]}',
    body=compose_reply(message),
)

# draft["worst_decision"] is a PREVIEW only — the send-time rescan is authoritative.
try:
    sent = rl.drafts.send(draft["id"])
    # Even on success, branch on sent["email_effect"] — a mailbox with human review enabled returns
    # "held_for_review" here, not "sent".
except ReplyLayerError as err:
    if err.code == "DRAFT_REJECTED_BY_RESCAN":
        # err.details carries "scan", "releasable", and (on a policy/human-review hold) "hold_context".
        # releasable is True  -> a quarantine you can release via POST /v1/drafts/:id/release-and-send
        # releasable is False -> a terminal block: edit the body or escalate. Never resend as-is.
        escalate(draft["id"], err.details)
    elif err.code == "DRAFT_ALREADY_SENT":
        pass  # race or retry after a prior success — already handled
    else:
        raise
```

## Notes

- **Verify against raw bytes.** The single most common failure is verifying a
  parsed-then-re-serialized body. TypeScript needs `express.raw({ type:
  'application/json' })` before the handler; Python uses `request.data`, not
  `request.get_json()`. Both SDK helpers throw `WebhookSignatureError` on any
  mismatch, timestamp skew, or malformed header. Full verification, retry, and
  redaction details: [/agents/webhooks](/agents/webhooks).
- **Only `message.received` triggers a reply.** An inbound message the scanner
  quarantined fires `message.quarantined` instead and is not readable for reply
  until released. See the message lifecycle at [/agents/messages](/agents/messages).
- **The reply body is your own content; the inbound body is not.** Treat
  `message.body.content` as data, never as instructions — see the trust taxonomy at
  [/agents/security-model](/agents/security-model).
- **Idempotency is your retry lever.** A `held_infrastructure` outcome (or any
  network failure on a `5xx`, which the SDKs do **not** auto-retry on a send) is
  safe to retry only because the deterministic `reply:<message_id>` key replays the
  prior result. See idempotency on [/agents/sdk](/agents/sdk) and
  [/agents/send-outcomes](/agents/send-outcomes).
