| Every send is one transactionC21the outbox row exists only when the recipient has a matching active webhook | envelope + payload, the idempotency record, the audit-chain row and — when the recipient has a webhook — the outbox row commit atomically, all or nothing; a rollback after the outbox insert leaves zero envelopes, audit rows and outbox rows | shipped with caveat |
| Idempotency on sendC22the same key reused across REST and MCP is a conflict, not a replay — the two request bodies differ | a mandatory key, retained 24 h; the request fingerprint is a SHA-256 over the JCS-canonical body, so key order and whitespace never cause a false conflict; a replay returns the original response bytes; the same key with a different body is refused; one store behind REST and MCP | shipped with caveat |
| Only the lease holder can ack, nack or extend, only while the lease is liveC23 | lease_id, lease_holder_credential_id and lease_expires_at > now() are predicates of the UPDATE itself; the expiry sweep’s predicate is the strict complement, so ack and sweep can never both match a row — 0 rows affected means the other side won | shipped |
| Redelivery ceilings, then dead-letterC24fixed defaults — there is no per-workspace override | by priority: high 3, normal 5, low 10 — evaluated inside the nack UPDATE (retry_count + 1 > ceiling → dead_lettered); terminate: true dead-letters at once | shipped |
| Nack mechanicsC89 | redeliver_after 0 s–1 h as an ISO 8601 duration; a high-priority redelivery waits at least 60 s; at most one nack per 30 s per (credential, message) — a faster one is refused with Retry-After and does not touch the row | shipped |
| get_pending boundsC25 | 1–100 messages a call (out of range is refused, not clamped); visibility timeout 10–600 s, default 120 s (clamped, and the answer carries the actual lease_expires_at); long-poll up to 20 s; filters by template, priority, correlation_id, labels; claim and lease are one statement under UPDLOCK, ROWLOCK, READPAST | shipped |
| extend_lease capsC88audited: a successful extension emits a message.lease_extended row in the same transaction, so it is on the chain and in the message timeline; an at-ceiling or rate-limited extend is a 0-row no-op that writes none | adds 10–600 s per call (clamped); at most 30 extensions and one hour cumulative per lease, never past the message’s expires_at — LEAST(expires_at, lease_acquired_at + 3600 s, lease_expires_at + additional_seconds) inside the UPDATE; at most one extend per 10 s per (credential, message); an at-ceiling call is a 0-row no-op | shipped with caveat |
| At-least-once, in a stated orderC26 | dequeue order is priority_rank, then accepted_at, then message_id; per-recipient FIFO is best-effort, not guaranteed; a crash or partition can redeliver, which is what the idempotency key and the lease id are for | shipped |
| A message expiresC27fixed defaults — there is no per-workspace override | TTL defaults to 7 days from acceptance and a sender may set at most 30; the expiry sweep moves an unacknowledged message to expired and the lease-reclaim sweep re-pends one whose lease lapsed | shipped |
| Webhooks deliver from a transactional outbox, after commitC28the alert is an event the platform raises, not a provisioned alert rule | the outbox row commits with the send; a worker polls committed rows only and POSTs — 5 attempts with full-jitter exponential backoff (2 s base, 5 min cap), then the delivery is marked exhausted, recorded on the audit chain and surfaced as an operator alert event; delivered resets a webhook’s health, exhaustions advance it active → failing | shipped with caveat |
| Webhooks are signed and SSRF-guardedC29 | X-PostMQ-Signature: t=<unix>,v1=<hex-hmac-sha256>,kid=<signing_key_id> on every POST, plus message, webhook, delivery and attempt ids; the destination is checked at configuration and IP-pinned at every connect — private ranges, loopback, link-local, cloud metadata and DNS rebinds are refused before a socket opens; the signing key is AES-256-GCM at rest and zeroed after each POST | shipped |
| The webhook DELIVERY view is read-only; configuration is full CRUD from the dashboard or RESTC87the config mutations have no MCP tool; over REST, create additionally takes a mandatory Idempotency-Key | list, get (health block, signing keys, outbox counts, recent dead deliveries) and the delivery trail over REST, MCP and the dashboard’s /webhooks pages — the same query service, byte-identical between REST and MCP; nothing anywhere retries, re-enqueues or disables a delivery, because the worker owns the outbox. Configuration is full CRUD — create, update (url/name/filter/max), delete (soft-delete + outbox purge) and signing-key rotation (previous key overlaps, then the new one is disclosed once) — from the dashboard or over REST, each an administrative bearer holding manage_webhooks or a human owner session; update, delete and rotation each write a webhook.* audit row in the same transaction, and registration does not — there is no webhook.created event type | shipped with caveat |
| Typed messages, validated before acceptanceC30three templates — there are no custom templates | three built-in templates — directive, freeform_note, assessment — each with a versioned JSON Schema; the payload is validated before the send is accepted; validate is a first-class dry-run over the same prepare path | shipped |
| The recipient is told what to do with itC54per-template and static — guidance travels with the template, not with the individual message | per-template recipient-attention guidance — when to ack, when to nack, which template to reply with and within what window, and the escalation triggers — published as the MCP resource postmq://recipient-attention.json and inside get_template | shipped with caveat |
| Rate limits, the same on every surfaceC31replicas reconcile their buckets every 60 s, so a burst spread across replicas can briefly exceed a limit | per credential, per minute: send 100, get_pending 60, validate 50; workspace aggregate 1000; 2× burst capacity; enforced identically over REST and MCP, published in X-RateLimit-* headers | shipped with caveat |
| Every transition is on the audit chain — 16 message.* and 6 webhook_outbox.* event typesC92the message.* family also holds the operator overrides and the lease-extension row (message.lease_extended) | accepted, leased, acknowledged, nacked-and-redelivered, dead-lettered, lease-expired, expired, and every outbox transition, each appended in the same transaction as the state change by a serialised stored procedure that hashes the row into the workspace’s SHA-256 chain — a plain hash over canonical bytes with a genesis anchor, not an HMAC | shipped |
| People see every messageC91the per-lease history of a message is not surfaced yet — the timeline is state instants | app.postmq.com/messages (newest first, filters, Load more) and /messages/{id} (envelope, timeline of state instants, payload or its purge notice); GET /v1/messages and GET /v1/messages/{id} over REST | shipped with caveat |
| Operators inspect dead letters; they do not replay them from the dashboardC90 | app.postmq.com/operator/dead-letter is a read-only inspector — queue depth and the dead-lettered messages of a named workspace; replay is a break-glass override behind a DPoP-bound token, made from the API | shipped |