PosthasteDocsGet an API key

Reference

Audit log

Every event on your account, in the order it was written, in a record that cannot be edited — including by us. You can page it, take a copy away, and check that copy yourself without taking our word for any of it.

What makes it an audit log

Each event carries the SHA-256 hash of everything before it. Change one payload and that event’s hash no longer matches its contents; change the hash too and it no longer matches the next event’s prevHash; and the whole chain would have to be rewritten from that point to the present to hide it. Every event also carries a seq that increases by exactly one, so deleting an event is caught as well — a removed row leaves a gap that no amount of re-linking fills.

The sequence is assigned by the database at insert, not by the application, and the table refuses UPDATE and DELETE outright. The chain makes tampering detectable; the refusal makes it hard. Neither is much use alone.

seq is per account, not per message. One message’s events are interleaved with everything else you were sending at the time, so a single message’s events run 41,908 and 41,912 rather than 1 and 2. That is normal, and it is why the per-message check on GET /v1/messages/:id proves less than this one does.

Read the log

GET/v1/audit/eventsmessages:read

Newest first, keyset paged. The cursor is a seq rather than an opaque token, because seq is already the number everything else here talks in — it is what brokenAt names and what an export is ordered by.

curl "https://api.posthastemail.dev/v1/audit/events?limit=2" \
  -H "authorization: Bearer $POSTHASTE_KEY"

# 200
# {
#   "data": [
#     {
#       "seq": 41912,
#       "type": "delivered",
#       "occurredAt": "2026-08-24T09:41:02.117Z",
#       "messageId": "msg_Qp4Ns8Vd1Lr6Ta0YhJ3mWc",
#       "detail": { "smtp": "250 2.0.0 OK" },
#       "hash": "9f2c…6b1a",
#       "prevHash": "41ad…c907"
#     },
#     {
#       "seq": 41911,
#       "type": "attempted",
#       "occurredAt": "2026-08-24T09:41:01.884Z",
#       "messageId": "msg_Qp4Ns8Vd1Lr6Ta0YhJ3mWc",
#       "detail": { "mx": "mx1.example.com" },
#       "hash": "41ad…c907",
#       "prevHash": "0c7e…22f5"
#     }
#   ],
#   "hasMore": true,
#   "nextCursor": "41911"
# }
ParameterNotes
limit1–100, default 50.
beforeA seq. Pass back nextCursor; stop when it is null.
typeOne event type — accepted, delivered, bounced and the rest of the webhook event list.
from / untilISO timestamps over occurredAt. until is exclusive.

This endpoint does not verify anything. Verification replays your entire history, and doing that on every page would make paging cost more the longer you have been a customer. The per-row hash and prevHash are here so a single entry can be matched against an export; the verdict is a separate call.

Prove it has not been altered

GET/v1/audit/verifymessages:read

Replays every event on the account, in order, recomputing each hash from the row’s own contents, and reports the first break. This is the strong form of the claim: not “these two rows still hash correctly” but “nothing in your whole history has been altered or removed”.

curl https://api.posthastemail.dev/v1/audit/verify \
  -H "authorization: Bearer $POSTHASTE_KEY"

# 200 — the record holds
# {
#   "intact": true,
#   "brokenAt": null,
#   "problem": null,
#   "events": 41912,
#   "firstSeq": 1,
#   "lastSeq": 41912,
#   "purgedThroughSeq": 0,
#   "checked": "every event on this account, in order"
# }

# 200 — it does not. This is evidence, not an outage.
# {
#   "intact": false,
#   "brokenAt": 40912,
#   "problem": "hash does not match the event contents",
#   "events": 41912,
#   "firstSeq": 1,
#   "lastSeq": 41912,
#   "purgedThroughSeq": 0,
#   "checked": "every event on this account, in order"
# }
FieldNotes
intactWhether the chain verifies end to end.
brokenAtThe sequence number at which the record stops being trustworthy, or null. A position, not an identifier.
problemWhat went wrong: a hash that does not match the event’s contents, a prevHash that does not match the event before, or a gap where an event was removed.
events / firstSeq / lastSeqHow much was checked, and which range. Computed in the same statement as the verdict, so the count is about the chain the verdict is about.
purgedThroughSeqNon-zero once your oldest events have aged out of your plan’s retention window. That is why a chain can legitimately start above 1 — it is a boundary, not a hole, and everything after it is checked normally.
StatusWhen
200The verdict — whether or not the record holds.
403 forbiddenThe key lacks messages:read.

A broken chain answers 200, not 500. It is a finding about your data, not a fault in ours, and returning a server error would make it look like something transient worth retrying past. Branch on intact.

On a large account this is the slowest read in the API — it touches every event you have ever had. It is a compliance control, not something to put on a health check.

Take a copy

GET/v1/audit/exportmessages:read

The whole chain as newline-delimited JSON, oldest first, streamed rather than assembled — so an account with millions of events downloads rather than times out. Accepts the same from and until as the list.

curl -OJ https://api.posthastemail.dev/v1/audit/export \
  -H "authorization: Bearer $POSTHASTE_KEY"

# posthaste-audit-2026-08-24.ndjson
# {"record":"header","account":"acct_Qp4…","exportedAt":"2026-08-24T09:44:10.201Z",
#  "chain":{"algorithm":"sha256","genesis":"0000…0000",
#           "material":"prevHash|account|seq|type|messageId|payload|occurredAt, joined by \"|\"",
#           "note":"\u0060payload\u0060 is the exact text the database hashed. …"},
#  "purgedThroughSeq":0,"range":{"from":null,"until":null}}
# {"seq":1,"account":"0198…","type":"accepted","messageId":"0198…",
#  "payload":"{\"source\": \"api\", \"to_domain\": \"example.com\"}",
#  "occurredAt":"2026-05-02T11:20:31.004Z","prevHash":"0000…0000","hash":"0c7e…22f5"}
# …
# {"record":"end","events":41912,"lastSeq":41912,
#  "chain":{"intact":true,"brokenAt":null,"problem":null,"checked":41912},
#  "complete":true}
RecordNotes
headerThe first line. Names the account, the export time, the purge boundary, and how the hash is constructed — written into the file rather than left in these docs, because the file outlives the page you are reading.
eventOne per line: seq, account, type, messageId, payload, occurredAt, prevHash, hash.
endThe last line: the event count, the last sequence, and the verdict over exactly the lines above it.

A file with no final record is incomplete. The response status is sent before the last row is read, so a connection that fails mid-transfer can only truncate the download — it cannot turn into an error you would notice. The trailer is how you tell a whole export from half of one. Check for it before you file the thing.

payload is a string, and it is the exact text the database hashed. Hash it as given. Parsing it and re-serialising it changes the bytes — key order and spacing are Postgres’s, not your language’s — and every event in an untouched file will then fail to verify.

A range-filtered export carries "chain": null in its trailer rather than a verdict. A window into a chain is missing rows on purpose, so no contiguity claim can honestly be made about it; only a whole export is checked.

Check the export yourself

The point of the export is that you do not have to believe the trailer. Everything needed to reach your own verdict is in the file: seven fields joined by |, hashed with SHA-256, each event linking to the one before it and the first linking to sixty-four zeroes.

import { createHash } from 'node:crypto';
import { createInterface } from 'node:readline';
import { createReadStream } from 'node:fs';

const GENESIS = '0'.repeat(64);
let prev = GENESIS;
let expected = null;

for await (const line of createInterface({
  input: createReadStream('posthaste-audit-2026-08-24.ndjson'),
})) {
  const r = JSON.parse(line);

  if (r.record === 'header') {
    expected = r.purgedThroughSeq + 1;
    continue;
  }
  if (r.record === 'end') {
    console.log(`intact through ${r.lastSeq} (${r.events} events)`);
    break; // Reaching this line at all is what proves the file is complete.
  }

  if (r.seq !== expected) throw new Error(`gap before seq ${r.seq}`);
  if (r.prevHash !== prev) throw new Error(`broken link at seq ${r.seq}`);

  // The order of these seven fields IS the chain's definition.
  const material = [
    r.prevHash, r.account, String(r.seq), r.type, r.messageId ?? '',
    r.payload,               // the exact text — do NOT re-serialise it
    r.occurredAt,
  ].join('|');

  if (createHash('sha256').update(material, 'utf8').digest('hex') !== r.hash) {
    throw new Error(`seq ${r.seq} has been altered`);
  }

  prev = r.hash;
  expected += 1;
}

Two mistakes account for almost every false alarm. Re-serialising payload instead of hashing the string you were given, and starting the sequence at 1 on an account with a non-zero purgedThroughSeq — a purged chain starts at the boundary plus one, and its first event links to a row that no longer exists, so that one link cannot be checked. Everything after it can.

Which key can read it

All three endpoints require messages:read. Every entry describes something that happened to a message, so a key that can already read your message log learns nothing new — whereas account:read is the scope people attach to a billing widget, and it would be a surprise for that credential to be able to download your delivery history.

GET /v1/account/verify answers the same yes-or-no question and needs only account:read, because it returns no part of the log itself. Being told whether the record holds and being able to read it are different amounts of access.

NextBillingSubscription state, the commercial history, and downloadable invoices.