Documentation

Audit log

Every inbound message produces exactly one primary audit entry, regardless of outcome. Outbound replies and bounces, when they happen, append data onto the same entry (joined by messageId).

The audit log is the source of truth for "what happened to this message." It is what you query to debug rejections, prove a body's hash, and reconstruct conversations.

AuditEntry

Field Type Description
id integer Stable monotonic id; used as the cursor in pagination.
message_id string The inbound message's stable JMAP identifier. Joins to webhook data.email_id.
thread_id string | null Conversation thread id, when known.
sender_address string | null Verified sender.
recipient_address string | null Mailbox address.
received_at integer Unix epoch seconds.
outcome AuditOutcome What happened. See enumeration below.
reason string | null Free-text explanation of a rejection (e.g. "no_matching_sender_rule", "rate_limit_per_hour").
verification_dkim Verdict | null DKIM verdict at receipt time.
verification_spf Verdict | null SPF verdict at receipt time.
verification_dmarc Verdict | null DMARC verdict at receipt time.
from_alignment boolean | null DKIM/SPF From-alignment flag.
body_hash string | null SHA-256 of inbound body bytes (only when auditLog.includeBodyHash == true).
capabilities_granted {capabilities: string[], rule_index: integer} | null Which sender-rule matched and what it granted. Only present on delivered.
tools_used JSON | null Whatever the agent runtime reported. Shape is agent-defined.
tokens_consumed JSON | null Token-usage report from the agent runtime. Shape is agent-defined.
reply_sent JSON | null Outbound reply receipt, if any.

AuditOutcome

The outcome field is one of:

Value Meaning
delivered Passed every gate; the agent received the webhook.
rejected_at_verification Failed requireDkim or requireSpf from the matched sender rule.
rejected_at_policy No sender rule matched (and defaultAction was applied).
rejected_at_content_guard A contentGuard.reject regex matched the body.
rate_limited The matched rule's rateLimit was exceeded.
budget_exhausted The matched rule's tokenBudget was exhausted.

Querying

GET /v1/mailboxes/{mailboxId}/audit-logs
Query parameter Type Default Description
message_id string Exact-match a single message.
thread_id string Filter to a single conversation thread.
outcome AuditOutcome Filter to one outcome.
limit integer 50 Page size. Clamped to [1, 200].
cursor integer Pass next_cursor from the previous page to continue.

The endpoint requires the same Authorization: Bearer <api_key> header as every other v1 endpoint. The mailbox must be owned by the API key's customer.

Pagination

Responses look like:

{
  "items": [ /* AuditEntry[] */ ],
  "next_cursor": 12345
}

next_cursor is the smallest id returned in the page. To fetch the next page, pass it back as cursor. When next_cursor is null, you have reached the end.

This is cursor-based, not offset-based — so concurrent inserts do not cause skipped or duplicated rows.

Retention

Entries older than the policy's auditLog.retentionDays are deleted by a background process. Increasing retentionDays does not retroactively recover entries that were already deleted under the old setting.

Body hashes

When auditLog.includeBodyHash is true, every entry carries a SHA-256 of the inbound body bytes (lowercase hex). This is enough to prove that a particular body produced a particular audit row, without needing the platform to retain the body itself. Useful for:

  • Independent verification that what you replayed is what was received.
  • Compliance: prove an audit row corresponds to a stored copy of the message.

The hash covers the message's text body bytes as received over JMAP.

Examples

Last 24 hours of rejections

const cutoff = Math.floor(Date.now() / 1000) - 24 * 3600;
const page = await client.getAuditLog(MAILBOX_ID, {
  outcome: "rejected_at_policy",
  limit: 200,
});
const recent = page.items.filter((e) => e.receivedAt >= cutoff);

Reconstruct a thread from audit entries

async def reconstruct(client: Mailbuttons, mailbox_id: int, thread_id: str) -> list[AuditEntry]:
    out: list[AuditEntry] = []
    cursor: int | None = None
    while True:
        page = await client.get_audit_log(mailbox_id, thread_id=thread_id, cursor=cursor, limit=200)
        out.extend(page.items)
        if page.next_cursor is None:
            break
        cursor = page.next_cursor
    return sorted(out, key=lambda e: e.received_at)

Verify a body hash against a captured message

import { createHash } from "node:crypto";

const expected = createHash("sha256").update(capturedBody).digest("hex");
const page = await client.getAuditLog(MAILBOX_ID, { messageId: "abc-123" });
const entry = page.items[0];
if (entry?.bodyHash !== expected) {
  throw new Error("Body does not match audit-log hash");
}