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");
}