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.
- The four send outcomes (
sent/held_for_review/held_infrastructure/blocked),email_effect, and the never-retry-a-block rule: /agents/send-outcomes. - Message states, verdict vocabulary, and the untrusted-body trust taxonomy: /agents/messages and /agents/security-model.
- Error codes referenced below (
DRAFT_REJECTED_BY_RESCAN,DRAFT_ALREADY_SENT): /agents/errors.
The loop
- Receive a
message.receivedwebhook (inbound mail that passed the scanner and is readable). - 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. - Fetch the message by id and treat its body as untrusted external data —
agent_safety_context.untrusted_contentis alwaystrue. - 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, notdata.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 theIdempotency-Keyheader 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. A reply may also be refused before
scanning by a send gate (suppression, mailbox containment, strict-recipient) — see
/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.
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.
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.
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)
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:
raiseNotes
- 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 usesrequest.data, notrequest.get_json(). Both SDK helpers throwWebhookSignatureErroron any mismatch, timestamp skew, or malformed header. Full verification, retry, and redaction details: /agents/webhooks. - Only
message.receivedtriggers a reply. An inbound message the scanner quarantined firesmessage.quarantinedinstead and is not readable for reply until released. See the message lifecycle at /agents/messages. - The reply body is your own content; the inbound body is not. Treat
message.body.contentas data, never as instructions — see the trust taxonomy at /agents/security-model. - Idempotency is your retry lever. A
held_infrastructureoutcome (or any network failure on a5xx, which the SDKs do not auto-retry on a send) is safe to retry only because the deterministicreply:<message_id>key replays the prior result. See idempotency on /agents/sdk and /agents/send-outcomes.