Agentic Email Webhook Payload Parsing Error: Why Your Handler Reads an Empty Body

Your agent's inbound email webhook returns 200 and the payload is empty. This walks through the raw-body ordering bug, the signature check that depends on it, and the validation layer that keeps malformed payloads out of your agent loop.

An agentic email webhook payload parsing error typically occurs when upstream HTTP middleware consumes the incoming socket stream before your signature verification code or JSON deserializer touches it. Because modern application runtimes expose incoming HTTP request bodies as readable, non-rewindable streams, any utility that reads the stream leaves an empty memory buffer for every subsequent handler. This results in empty payload bodies, failed cryptographic signatures, or immediate HTTP 400 responses on requests that your email delivery provider logged as successful.

When an autonomous agent system fails at the inbound transport layer, downstream processes halt immediately. If the agent cannot inspect the message, it cannot triage intent, update context, schedule appointments, or request human intervention. Fixing this failure requires capturing the raw request stream before any body-parsing middleware runs, preserving the byte buffer for signature verification, and delaying JSON parsing until the cryptographic check succeeds.

The fix first: parse the raw body before anything else touches it

Most webhook failures in agent systems are stream ordering defects, not schema bugs. The HTTP transport server receives raw TCP packets and exposes them through an event-driven I/O stream. Once that stream emits its end event, the socket closes and the payload buffer is discarded unless explicitly preserved in memory. If generic middleware runs first, it reads the stream to completion. When your route handler subsequently attempts to read the body or compute an HMAC signature, it receives zero bytes.

The concrete symptoms of this stream consumption issue in your application logs include:

  • An HTTP 400 Bad Request returned to the webhook provider containing an empty error body or an empty string.
  • A signature verification failure on a payload that appears identical inside the email provider's delivery dashboard.
  • A JSONDecodeError: Expecting value: line 1 column 1 (char 0) in Python runtimes, or SyntaxError: Unexpected end of JSON input in Node.js runtimes.

To eliminate this failure mode, read the raw bytes once, store them in a request-scoped buffer, verify the cryptographic signature against those raw bytes, and deserialize the JSON payload from that preserved buffer. Do not reconstruct a raw payload by calling JSON.stringify() on an already-parsed JavaScript object. As specified in the HMAC guidelines in RFC 2104, cryptographic digests depend on exact byte sequences; key reordering, whitespace changes, and character escaping alter the hash and invalidate the signature.

Common web application frameworks introduce specific stream-consumption traps:

  • Express.js: Registering global middleware such as app.use(express.json()) before your webhook router consumes the stream globally. To fix this, mount the raw buffer parser selectively on the webhook path: app.use('/webhooks/email', express.raw({ type: 'application/json' })).
  • FastAPI / Starlette: Calling await request.json() before calling await request.body() can cause unexpected stream state transitions if custom middleware wraps the receive channel. Read raw_body = await request.body() first.
  • Next.js App Router: Calling await req.json() in a Route Handler drains the request. Call const rawBody = await req.text() or await req.arrayBuffer() to obtain the exact string or byte sequence needed for HMAC validation before parsing.
  • Serverless Adapters (AWS Lambda / Google Cloud Functions): API Gateway or runtime adapters may parse JSON payloads into event objects or decode base64 strings inconsistently. Configure API Gateway binary media types to pass the raw payload intact, or retrieve the original string from event.isBase64Encoded ? Buffer.from(event.body, 'base64') : event.body.

The ingestion sequence must follow this execution order:

  1. Capture raw body: Read the incoming network stream into an immutable byte buffer or raw UTF-8 string.
  2. Verify HMAC: Hash the raw buffer with your webhook signing secret and compare it against the provider's signature header using a constant-time equality check.
  3. Parse JSON: Deserialize the validated byte buffer into an in-memory object or struct.
  4. Validate schema: Assert that required fields exist and conform to expected structural and data types.
  5. Enqueue job: Push the validated message payload to an internal queue or durable store for asynchronous execution.
  6. Return 2xx: Immediately return an HTTP 200 or 202 to the sender to acknowledge delivery.

What the raw body actually contains, and why the shape surprises parsers

Developers accustomed to building internal microservices often expect webhooks to arrive as flat, predictable JSON documents. Inbound email webhook services, however, such as Postmark, translate incoming email messages into structured JSON payloads. Because email originates across diverse mail clients, legacy mail transfer agents, and enterprise relays, the resulting payloads exhibit structural variance that standard REST endpoints rarely encounter.

An incoming email webhook payload typically encapsulates nested data structures containing:

  • Envelope metadata: SMTP routing fields, including the envelope sender (the return path), the envelope recipient (which may differ from the header To: address in BCC scenarios), client IP, and SPF/DKIM/DMARC authentication verdicts.
  • Header collections: An array of key-value pairs or a dictionary containing standard headers (Message-ID, Subject, Date, From) and threading indicators (In-Reply-To, References).
  • Message bodies: Separate representations of the message text, typically a plain-text payload (text/plain), an HTML payload (text/html), and occasionally an extracted markdown view.
  • Attachment records: Metadata lists containing file names, MIME types, byte sizes, Content-ID tags for inline images, and either a download URL or a base64-encoded string representing the file contents.

Parsers fail when code makes assumptions about this schema. A common pitfall involves recipient addressing: depending on whether the email had one recipient or several, the to field might arrive as a single formatted string ("Jane Doe <jane@example.com>") or an array of objects ([{"address": "jane@example.com", "name": "Jane Doe"}]). The References header might be a single string for the first reply, a space-delimited string, or an array of strings as the thread deepens.

Encoding variance adds another layer of complexity. One provider may supply the HTML body as a raw string, while another uses base64 or quoted-printable encodings depending on the characters in the body. If the original email contained only plain text, the html property may be null, an empty string, or omitted entirely. If your handler assumes payload.html.trim() is callable without checking for nulls, it will raise an unhandled exception.

Do not decode, clean, or transform payload bytes before completing signature verification. Cryptographic signatures are calculated strictly against the literal byte array transmitted over the wire. If your server decodes quoted-printable strings or normalizes CRLF line endings to LF before checking the signature, the resulting hash will fail verification every time.

Before passing an inbound payload to agent logic, your parser should validate and normalize these fundamental properties:

  • message_id: Non-empty string containing the message identifier.
  • from: Validated object or normalized string containing the sender's address.
  • to: Array containing at least one valid recipient address.
  • text or html: At least one non-null content body present.
  • timestamp: Valid integer or ISO 8601 string representing the receipt time.
  • attachments: Normalized array (instantiated as empty [] if no attachments exist, rather than left undefined).

Webhook payload validation: the five checks that catch real failures

Protecting an agentic system requires strict webhook payload validation. AI agents consume compute resources and execute state-changing actions in external systems. Allowing unverified or malformed data into an agent's context window exposes your system to injection attacks, prompt exploits, and unnecessary execution loops.

Implement these five programmatic checks on every incoming webhook request:

1. Constant-time cryptographic signature verification

Inbound webhook providers compute a Hash-based Message Authentication Code (HMAC)—typically using SHA-256—across the raw request payload concatenated with a timestamp, using a shared secret key. Your handler must recompute this digest over the identical raw bytes and compare the calculated signature to the signature provided in the headers (such as X-Webhook-Signature or X-Signature-SHA256).

This comparison must use a constant-time comparison function, such as crypto.timingSafeEqual() documented in the Node.js crypto documentation or Python's hmac.compare_digest(). Using a standard equality operator (=== or ==) leaks timing information based on how many leading characters match, exposing endpoints to potential timing attacks over high-sample-rate networks.

2. Timestamp tolerance enforcement

Signature headers typically bundle a UNIX epoch timestamp (for example, t=1727337600,v1=abc123...). Extract this timestamp and compare it to your server's current time. If the difference exceeds your replay tolerance window—which Stripe sets to a default of 5 minutes—reject the request immediately with an HTTP 400 status code.

Timestamp checks prevent replay attacks. Without a strict replay window, an attacker who intercepts a legitimate webhook payload can re-post that payload repeatedly to your public endpoint, forcing your agent to re-execute operations, burn model tokens, or trigger downstream actions multiple times.

3. Idempotency deduplication

Email providers operate distributed delivery queues configured with at-least-once delivery guarantees. Network timeouts, slow database writes, or deployment restarts cause providers to retry delivery. Your validation layer must extract the unique delivery identifier (such as webhook_id, event_id, or the underlying email's Message-ID) and verify whether that ID has already been recorded.

Maintain an atomic key-value store (such as Redis or DynamoDB) with an explicit time-to-live (TTL) of at least 24 to 72 hours. Attempt an atomic conditional insert of the ID (such as SET key value NX EX 86400). If the key exists, abort downstream execution and immediately return HTTP 200 OK. Retries are a normal transport characteristic, but duplicate agent actions cause uncoordinated state changes.

4. Strict schema validation with data coercion

Once raw bytes pass verification, deserialize the JSON and validate the resulting structure using a schema enforcement library such as Pydantic, Zod, or TypeBox. Configure your schema validator to strictly enforce required fields while safely coercing optional variants.

Unless you control both sides of the integration, avoid rejecting requests based on unknown top-level keys. Email providers regularly update webhook envelopes with new analytical telemetry or diagnostic flags. A schema validator that rejects unknown properties (for example, using strict extra = "forbid") can cause unexpected outages when a vendor deploys a minor platform enhancement.

5. Pre-parse payload size limits

Email messages containing binary attachments can produce JSON payloads exceeding 20 MB when base64-encoded. Passing an unconstrained byte stream into a JSON deserializer blocks the single-threaded event loop in Node.js or triggers aggressive memory allocation spikes in Python, leading to out-of-memory process termination.

Enforce an upper bound on payload size at the web server or reverse proxy level (such as NGINX client_max_body_size 10M or Express express.raw({ limit: '10mb' })). If a message exceeds this limit, reject the connection with an HTTP 413 Payload Too Large before running JSON parsing or cryptographic hashing.

Agentic email debugging: separating transport failures from parse failures

When an agent fails to respond to an incoming message, developers often look directly at prompt engineering traces or model logs. In practice, breakdowns frequently occur in the underlying network or transport layer. Systematic agentic email debugging requires separating transport failures from application-level parsing errors through targeted telemetry.

To diagnose inbound failures quickly, ensure your ingestion middleware emits a structured log entry containing these four fields for every incoming request:

  • sha256(raw_body): A SHA-256 hash of the exact incoming byte stream.
  • signature_header: The raw value of the signature or authentication header.
  • delivery_id: The provider's unique event identifier.
  • outcome: The terminal evaluation code (for example, EMPTY_BODY, HMAC_MISMATCH, TIMESTAMP_EXPIRED, JSON_SYNTAX_ERROR, SCHEMA_INVALID, QUEUED).

Without the raw body hash, it is difficult to determine whether an HMAC verification failure was caused by payload corruption in transit, a misconfigured secret, or stream consumption by prior middleware. Comparing your calculated SHA-256 body hash against the vendor's diagnostic dashboard confirms whether the payload arrived intact.

Use the following diagnostic matrix to troubleshoot inbound email webhook anomalies:

Observed SymptomRoot CauseDiagnostic Verification Step
No HTTP request appears in application logsDNS misconfiguration, edge firewall block, or invalid webhook target URLQuery your endpoint using curl -v -X POST https://your-domain.com/webhook to verify network reachability from outside your internal network.
HTTP 400 Bad Request with 0-byte bodyBody stream was consumed by prior middleware before the handler executedInspect middleware registration order. Log req.body.length immediately before and after route dispatch.
HMAC signature verification fails on every deliveryWrong secret key, incorrect raw byte encoding, or pre-parsing modificationsPrint crypto.createHash('sha256').update(rawBuffer).digest('hex') and compare it to the provider's documented payload hash.
Signature fails intermittently on retriesSystem clock skew causing timestamp tolerance validation to failRun ntpq -p or check your container's virtualization host clock against an NTP time source to identify drift.
JSON parsing fails on specific customer emailsInvalid character encoding (such as unescaped control characters or mixed UTF-8/ISO-8859-1)Save the raw buffer to a binary file and inspect it with iconv -f utf-8 -t utf-8 input.bin to isolate malformed byte sequences.
Valid JSON fails application schema validationProvider schema evolution, missing optional fields, or nested null valuesLog the raw JSON string alongside the schema validator's granular error path to isolate the unexpected field type.

Clock skew is a frequent source of false-positive signature errors in production container environments. If an orchestration node drifts by even a few seconds, requests landing near the boundary of your timestamp tolerance window (for example, 300 seconds) will be rejected as stale. Maintain strict NTP synchronization across all worker instances. When rotating webhook signing secrets, configure verification code to accept both the active secret and the retiring secret concurrently for a transitional window so routine rotations do not interrupt ingestion.

API integration troubleshooting: what to return, and when to return it

A critical aspect of API integration troubleshooting is establishing a consistent HTTP response contract. The status code your endpoint returns dictates whether the sending service retries delivery, pauses, or drops the message. Returning an improper status code can lose email communications or flood infrastructure with unresolvable retry loops.

Adhere to this HTTP status code contract:

  • Return HTTP 202 Accepted (or 200 OK): Send this status code only after the raw payload has passed cryptographic verification, satisfied schema constraints, and been safely written to a persistent message queue. Webhook endpoints should decouple ingestion from agent execution by buffering validated jobs to a queue and returning an immediate HTTP 202 Accepted status, which RFC 9110 Section 15.3.3 defines for requests accepted for processing but not yet completed. Executing agent reasoning or tool invocations inside the synchronous HTTP request-response cycle couples application latency to the provider's connection timeout (often 5 to 15 seconds), prompting premature client disconnects and duplicate delivery retries.
  • Return HTTP 400 Bad Request / 401 Unauthorized: Send these codes when signature verification fails, required headers are missing, the payload size is excessive, or the JSON syntax is fundamentally unparseable. Under the HTTP semantics established in RFC 9110, client-side data defects warrant a 4xx response. These client errors instruct the provider that the payload itself is invalid and that automated retries will not succeed.
  • Return HTTP 500 Internal Server Error / 503 Service Unavailable: Send these codes when your internal message broker (such as Redis, SQS, or PostgreSQL) is unavailable or encounters an unexpected database lock. This signals a transient failure on your infrastructure, instructing the provider to initiate an exponential backoff retry.

Do not return HTTP 200 OK when signature verification or JSON decoding fails simply to avoid retry storms. As specified under RFC 9110, client errors require a 4xx status code; returning 200 on invalid signatures conceals transport defects, prevents alerts when signing secrets expire, and drops legitimate messages without a retry signal.

When the parse succeeds but the agent still does the wrong thing

Successfully validating a webhook payload guarantees only that the transport layer delivered the bytes intact. It provides no guarantee that downstream autonomous actions will be correct or safe.

Consider an AI scheduling agent. The webhook arrives, passes HMAC validation, and yields a clean JSON payload representing an inbound meeting request: "Can we meet tomorrow at 2:00 PM?" The agent extracts the intent, queries available openings, and attempts to reserve the slot. If a second agent—or another concurrent process—is handling an incoming email from a different participant, both agents may attempt to write to the identical calendar window simultaneously.

Application-level checks (such as if (!calendar.isBooked(slot)) { calendar.book(slot); }) fail under real-world concurrency because of time-of-check to time-of-use (TOCTOU) race conditions. The solution must live at the persistence layer. To resolve this, AgentDraft coordinates holds and commits through a priority-aware conflict engine so multiple agents can act on the same calendar without double-booking. AgentDraft syncs Google Calendar today; Microsoft 365 / Outlook calendar sync is planned, not yet shipped. By anchoring holds at the storage layer using transactional condition checks, conflicting reservations are rejected deterministically before an agent issues a confirmation.

A second failure mode occurs when an agent attempts irreversible external actions—such as dispatching an unreviewed contract, updating a production database, or issuing a customer refund—solely because an inbound email requested it. Inbound emails represent untrusted user input subject to prompt injection and social engineering.

To safeguard consequential actions, AgentDraft lets an agent pause any consequential action for human sign-off: it opens an approval request carrying a one-line summary and a JSON evidence payload, a person approves or denies it in the dashboard with an optional note, and the agent reads the outcome back. The gated action does not have to be one AgentDraft performs — a deploy, a migration, or a refund is gated the same way. Every transition lands in the append-only audit trail and fires an approval.* webhook. Approvals are decided in the AgentDraft dashboard. AgentDraft emails the workspace owner a notification linking to the queue, but the decision itself is made signed in — there are deliberately no approve-from-email links, because an unauthenticated one-click approve is an attack surface. Slack, Discord, Teams, SMS and push delivery are not available today.

Finally, when unexpected actions occur, teams face operational scrutiny: who authorized this operation, which email triggered it, and what exact prompt context did the model evaluate? Without structured logging, reconstructive analysis is impossible. To maintain traceability, AgentDraft records state-changing agent actions in an append-only audit trail. AgentDraft does not hold formal compliance certifications (SOC 2, HIPAA, ISO 27001, etc.). It does keep an append-only audit trail.

Edge cases that only show up in production

Standard unit tests rarely capture the anomalies encountered across thousands of public email exchanges. When building production handlers for a per-agent inbox, prepare your pipeline for these specific real-world edge cases:

Multi-recipient fan-out

An email delivered to agent-billing@yourdomain.com and CC'd to agent-support@yourdomain.com represents a single inbound SMTP envelope that may trigger multiple webhook dispatches, or a single webhook containing multiple local addresses. Determine whether your architecture processes the message as a unified conversation or splits it into two isolated agent contexts. If splitting, append the recipient address to the deduplication key (for example, ${messageId}:${recipient}) to prevent the first worker from discarding the second worker's event as a duplicate.

Attachment-only emails without text content

Users frequently forward receipts, invoices, or scanned documents with no accompanying body text. In these cases, the text and html properties in the webhook payload will be empty or null. If your ingestion schema enforces a non-empty string on message bodies, the parser will fail on valid user submissions. Your validation layer should require that either text content or an attachment array containing at least one item is present.

Header truncation on deep email threads

Extended email chains accumulate extensive References and In-Reply-To chains. Certain email gateways and MTAs enforce strict 1024-character line-length limits on SMTP headers, silently truncating long message reference strings or dropping legacy IDs. Ensure your threading logic accommodates truncated references and falls back to matching normalized subjects or conversation tokens when parent message IDs are missing.

MIME encoded-words in subject lines

Non-ASCII characters in email headers are encoded using MIME Encoded-Word syntax (such as =?UTF-8?B?U3ViamVjdA==?=). While modern email webhook providers usually decode these strings into native UTF-8 JSON properties, legacy private mail relays may forward the raw encoded strings directly. If an agent attempts to reason over raw encoded headers without prior normalization, semantic intent extraction will fail.

Provider version drift

Webhook providers regularly iterate on payload schemas, adding top-level attributes, renaming metrics, or adjusting error reporting structures. Always pin your webhook integrations to a specific API version. Monitor the public changelog of your upstream providers to prepare for breaking schema transitions well before deprecation windows close.

A minimal handler you can adapt

The following Express.js route demonstrates the sequence: capturing the raw wire bytes, verifying the HMAC digest over the raw buffer, checking the timestamp tolerance window, parsing the JSON payload, and acknowledging delivery with an HTTP 202 response before asynchronous agent dispatch.

import express from 'express';
import crypto from 'crypto';

const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SIGNING_SECRET;
const TOLERANCE_SECONDS = 300;
const processedEvents = new Set(); // Replace with Redis / DynamoDB in production

// 1. Capture raw bytes strictly on the webhook route
app.post(
  '/api/v1/webhooks/inbound-email',
  express.raw({ type: 'application/json', limit: '10mb' }),
  async (req, res) => {
    const rawBody = req.body; // Buffer containing exact wire bytes
    const signatureHeader = req.headers['x-webhook-signature'];
    const timestampHeader = req.headers['x-webhook-timestamp'];

    // Guard: ensure headers and raw body exist
    if (!rawBody || !signatureHeader || !timestampHeader) {
      return res.status(400).json({ error: 'Missing required payload or verification headers' });
    }

    // 2. Enforce timestamp tolerance (Replay attack defense)
    const requestTimestamp = parseInt(timestampHeader, 10);
    const currentTimestamp = Math.floor(Date.now() / 1000);

    if (isNaN(requestTimestamp) || Math.abs(currentTimestamp - requestTimestamp) > TOLERANCE_SECONDS) {
      return res.status(400).json({ error: 'Timestamp outside acceptable window' });
    }

    // 3. Verify HMAC signature using constant-time equality
    const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
    hmac.update(`${requestTimestamp}.`);
    hmac.update(rawBody);
    const expectedSignature = hmac.digest('hex');

    const signatureBuffer = Buffer.from(signatureHeader, 'hex');
    const expectedBuffer = Buffer.from(expectedSignature, 'hex');

    if (
      signatureBuffer.length !== expectedBuffer.length ||
      !crypto.timingSafeEqual(signatureBuffer, expectedBuffer)
    ) {
      return res.status(401).json({ error: 'Cryptographic signature mismatch' });
    }

    // 4. Parse JSON only after signature verification passes
    let payload;
    try {
      payload = JSON.parse(rawBody.toString('utf8'));
    } catch (err) {
      return res.status(400).json({ error: 'Payload body contains invalid JSON' });
    }

    // 5. Idempotency deduplication
    const eventId = payload.id || payload.message_id;
    if (!eventId) {
      return res.status(400).json({ error: 'Missing unique message identifier' });
    }

    if (processedEvents.has(eventId)) {
      // Duplicate delivery: acknowledge receipt without reprocessing
      return res.status(200).json({ status: 'duplicate_acknowledged' });
    }
    processedEvents.add(eventId);

    // 6. Enqueue message for asynchronous agent processing
    // Example: await queue.push({ eventId, payload });

    // 7. Return 202 Accepted immediately
    return res.status(202).json({ status: 'queued', id: eventId });
  }
);

app.listen(3000, () => console.log('Webhook endpoint listening on port 3000'));

Two critical implementation details in this handler prevent common errors:

  • The raw body buffer capture: Mounting express.raw() directly on the specific endpoint route rather than globally isolates raw-byte preservation to incoming webhooks while leaving standard REST routes unaffected.
  • Buffer length validation prior to timingSafeEqual: The crypto.timingSafeEqual() function throws an exception if the two input buffers have differing byte lengths. Checking signatureBuffer.length !== expectedBuffer.length first prevents an automatic HTTP 500 error on malformed signature inputs.

Isolating these handlers per agent reinforces system reliability. When architectures provision isolated mailboxes, per-agent mailboxes isolate blast radius, so one runaway agent exhausts its own quota rather than the whole sending domain. If a downstream parser bug introduces an unhandled failure mode on a single agent's inbox, the rest of your organization's automated communication infrastructure remains online.

Frequently Asked Questions

Why is my webhook body empty even though the provider shows a successful delivery?

Your web application framework or global middleware consumed the incoming HTTP network stream before your handler executed. Because HTTP request streams can only be read once, prior execution of generic JSON parsers or authentication middleware drains the buffer, leaving an empty stream for downstream routes. You must configure your router to preserve the raw byte buffer for the specific webhook path.

Should I return 200 or 400 when the payload fails signature verification?

Return HTTP 400 Bad Request or HTTP 401 Unauthorized. Under the client error conventions defined in RFC 9110, 4xx responses inform the sending relay that the payload failed authentication or schema checks and should not be retried verbatim. Acknowledging an invalid signature with HTTP 200 masks secret mismatches and drops real messages without firing delivery failure alerts.

How do I stop duplicate agent actions when the provider retries a delivery?

Implement an atomic idempotency check using the webhook event ID or the email's Message-ID. Store this identifier in an atomic cache or database (such as Redis or DynamoDB) using a conditional set operation with a 24- to 72-hour TTL. If the key already exists, skip downstream task creation and return an HTTP 200 OK immediately.

What timestamp tolerance should I use for webhook replay protection?

If the difference exceeds your replay tolerance window—which Stripe sets to a default of 5 minutes—reject the request immediately with an HTTP 400 status code. This window accommodates routine network latency and minor NTP clock drift across virtualized cloud environments while strictly limiting the window an adversary has to replay an intercepted webhook payload.

Does a valid webhook payload mean the agent's action is safe to execute?

No. Successful webhook validation confirms only that the payload was delivered intact from an authenticated sender. Inbound emails represent untrusted user inputs that can contain malicious instructions or prompt injection attacks. High-risk operations—such as financial transactions, record deletions, or public calendar bookings—should execute under atomic storage-level condition checks or pause for dashboard-based human approval before committing changes.

If the parse is fixed and the next failure is a double-booked slot or an unapproved send, the problem has moved out of your parser. AgentDraft gives AI agents per-agent email inboxes with inbound webhooks, replies, and audit evidence, and coordinates holds and commits through a priority-aware conflict engine so multiple agents can act on the same calendar without double-booking. AgentDraft has a free tier that needs no card — point one agent at a mailbox and watch the webhook land.