API reference

Every route, limit and promise the sending API makes.

The API contract for HAMANI Email Engine as actually implemented in src/handler.ts, src/send/*.ts and src/auth/*.ts — every shape below is read off that code, not aspirational. Two audiences share one keyed surface: our own platforms (Part A, ownerType: "platform") and pay-as-you-go customers (Part B, ownerType: "customer", surfaces landing in later phases).

Status: P1 (keyed send API) + P2 (exactly-once queue) + P3 (Lambda senders → SES) + P5 (suppression/events) + P6 (templating/i18n) + P8 (customer self-serve domain verification) + P9–P11 (Stripe billing, usage metering, AUD pricing) + P13–P17 (abuse/reputation, account health, AU Spam Act, Living Circle, runtime-shape verification) + P18 (fair-use monthly bulk cap) + P19 (infrastructure as code, infra/) + P12 (dashboard: hub-seam read-API — real per-domain analytics, open/click ingestion, suppression listing, domain health, SPF/DMARC records — plus batch send, template CRUD, and scheduled sends) + P20 (inbound mailboxes — receive: SES receipt → MIME parse → per-address mailbox store + the hub webmail seam) built and gate-green. This contract grows with each phase and never weakens a rule in CONSTITUTION.md.

Transport

A single AWS Lambda Function URL. Method + path decide routing. Every response is application/json.

MethodPathAuthPurpose
GET/healthznonereadiness — configured-driver health
POST/v1/admin/keysX-Admin-Secret headerissue an API key (platform onboarding / customer provisioning)
POST/v1/sendAuthorization: Bearer hme_...enqueue one email for delivery
POST/v1/send/batchAuthorization: Bearer hme_...enqueue a broadcast to many recipients (P12)
GET/v1/scheduledBearer, scope sendlist this owner's scheduled sends (P12)
GET/DELETE/v1/scheduled/:idBearer, scope sendfetch / cancel a scheduled send (P12)
POST/v1/unsubscribe?t=<token>signed token (query)one-click unsubscribe (RFC 8058, AU Spam Act)
POST/GET/PUT/DELETE/v1/templates[/:id]Bearer, scope templatesCRUD a customer's own branded, reusable templates (P12)
POST/v1/domainsBearer, scope domainsregister a customer's own domain — returns the full record set to publish: DKIM CNAMEs + SPF + DMARC + the receiving MX (P8, §2)
GET/v1/domains/:domainBearer, scope domainsverification status + the LAST per-record DNS read (no DNS I/O of its own)
POST/v1/domains/:domain/recheckBearer, scope domains"check now": reads the published DNS per record AND polls SES; flips to verified ONLY on a genuine SES confirmation
POST/v1/admin/customersX-Admin-Secret headerprovision a customer's real Stripe billing identity (P9) — idempotent
POST/v1/billing/webhookStripe-Signature header (HMAC)Stripe billing webhook receiver (P9)
GET/v1/dashboard/analyticsX-Hamani-Hub-Signature (HMAC)per-owner send funnel by domain (P12)
GET/v1/dashboard/domainsX-Hamani-Hub-Signature (HMAC)per-owner domain health + DNS records (P12)
GET/v1/dashboard/suppressionsX-Hamani-Hub-Signature (HMAC)global suppression list, hashed + paginated (P12)
GET/v1/dashboard/overviewX-Hamani-Hub-Signature (HMAC)combined hub-landing summary (P12)
GET/v1/dashboard/keyshub HMAC + ?ownerId=list this owner's API keys, secret-free (self-service)
POST/v1/dashboard/keyshub HMAC + ?ownerId=mint a send-scoped customer key; returns the secret ONCE. Body { label }
POST/v1/dashboard/keys/:id/revokehub HMAC + ?ownerId=revoke one of this owner's keys (a key that is not theirs is a 404)
GET/v1/mailbox/:addr/messagesX-Hamani-Hub-Signature (HMAC)list a mailbox folder (P20)
GET/v1/mailbox/:addr/messages/:idX-Hamani-Hub-Signature (HMAC)read one message (bodies inlined) (P20)
GET/v1/mailbox/:addr/messages/:id/attachments/:nX-Hamani-Hub-Signature (HMAC)stream an attachment (P20)
POST/v1/mailbox/:addr/messages/:id/readX-Hamani-Hub-Signature (HMAC)mark read/unread (P20)
POST/v1/mailbox/:addr/messages/:id/folderX-Hamani-Hub-Signature (HMAC)move folder, e.g. trash (P20)
POST/v1/mailbox/:addr/sendX-Hamani-Hub-Signature (HMAC)compose/reply from a mailbox; stores a Sent copy (P20)
GET/v1/mailbox/:addr/search?q=hub HMAC + ?ownerId=search a mailbox (§4)
POST/v1/mailbox/:addr/draftshub HMAC + ?ownerId=save or replace a draft (§4)
POST/v1/mailbox/:addr/messages/:id/spamhub HMAC + ?ownerId=mark as spam — files it AND suppresses the sender (§4)
GET/POST/v1/mailboxeshub HMAC + ?ownerId=list / create mailboxes on this account's verified domains (§3)
GET/PATCH/DELETE/v1/mailboxes/:addresshub HMAC + ?ownerId=read / update (name, quota, aliases, signature, auto-reply, status) / delete a mailbox (§3)
POST/v1/mailboxes/:address/passwordhub HMAC + ?ownerId=set or reset the mailbox password (§3)
GET/PUT/DELETE/v1/domains/:domain/catch-allhub HMAC + ?ownerId=read / set / clear a domain catch-all (§3)

**Every /v1/mailbox/* and /v1/mailboxes/* route now requires ?ownerId=**, the same tenancy parameter the dashboard routes take. This is a BREAKING change to the webmail seam and it closes a real hole: before §3 those routes verified the hub's signature and then read whatever address the path named. The signature proves the caller is the hub; it says nothing about which customer the hub is acting for, so any hub bug — or anyone holding the hub secret — could read any tenant's mail by guessing an address. Ownership resolves from the mailbox account when one exists, and otherwise from the owner of the address's verified domain, so mailboxes created before §3 keep working. A mailbox belonging to another tenant answers 404 mailbox_not_found, identical to one that does not exist — a distinguishable refusal would be an address-enumeration oracle.

Every POST route above, plus GET /v1/domains/:domain, is throttled per source IP before authentication (PRE_AUTH_RATE_LIMIT_PER_MINUTE, default 300) — an unauthenticated flood can't amplify key-store reads and the admin secret can't be brute-forced at line speed. Over the limit ⇒ 429 rate_limited.

Authentication

Authorization: Bearer hme_<keyId>_<secret>. The secret is SHA-256 hashed at rest; lookups are O(1) by keyId; comparison is timing-safe. A key carries scopes (send, domains, templates) and allowedDomains (the domains it may send FROM — may be empty at issuance: a self-serve customer key is provisioned with scopes: ["domains"] and zero domains, then gains sending rights the moment its own domain verifies via POST /v1/domains + /recheck; see P8).

StatuserrorMeaning
401missing_api_keyno/!bearer Authorization header
401invalid_api_keykey not recognized or wrong secret
401api_key_revokedkey was revoked
403insufficient_scopevalid key lacks the required scope

POST /v1/admin/keys

Header X-Admin-Secret: <HAMANI_EMAIL_ADMIN_SECRET> (constant-time compared; no length leak). Unset secret ⇒ always 503 admin_key_issuance_not_configured.

Request: ``json { "ownerType": "platform", "ownerId": "hamani-crm", "label": "crm-prod", "scopes": ["send"], "allowedDomains": ["hamanicrm.com.au"] } ` Response 201 (the key is shown exactly once): `json { "keyId": "...", "apiKey": "hme_..._...", "ownerType": "platform", "ownerId": "hamani-crm", "scopes": ["send"], "allowedDomains": ["hamanicrm.com.au"], "warning": "store this key now — it is shown exactly once and is not recoverable" } ``

POST /v1/send (scope: send)

A superset of hamani-crm-saas/lib/email/send.ts, so a platform swaps one transport line and deletes nothing.

Request (strict — unknown keys rejected): ``json { "to": "[email protected]", "from": "HAMANI CRM <[email protected]>", "subject": "Welcome", "html": "<p>…</p>", "text": "…", "templateId": "welcome", "data": { "name": "Sam" }, "locale": "en-AU", "category": "transactional", "replyTo": "[email protected]", "headers": { "List-Unsubscribe": "<https://…/u>" }, "idempotencyKey": "order-1001" } `` Rules enforced:

Responses:

Statusbody status/errorMeaning
202status: "queued"accepted and enqueued; messageId + idempotencyKey returned. A non-transactional send also carries fairUse: {capPerMonth, usedThisMonth, overCap, topUpAvailable} (P18) — going over cap NEVER changes the status or blocks the send
202status: "duplicate", deduplicated: truea prior identical send already committed — same messageId, nothing re-enqueued, no fairUse (not re-evaluated)
400invalid_requestmalformed JSON or schema validation failure (issues[] included)
400recipient_refusedthe recipient failed the pre-send hygiene gate (see below); findings[] carries each {risk, detail, suggestion?}
403from_domain_not_allowedkey may not send from that domain
409send_in_progressan identical send is mid-flight (or crashed within grace) — retry shortly; it will not be sent twice
409idempotency_conflictthe key collides with a different in-flight send (astronomically rare)
413payload_too_largeserialized job exceeds the SQS message limit (MAX_SQS_MESSAGE_BYTES)
429rate_limitedper-key or pre-auth per-IP minute limit exceeded
429warmup_cap_exceededthe sending domain is inside its automated warm-up ramp and has reached that warm-up day's ceiling (see below)
503domain_pausedthe sending domain's own bounce/complaint rate breached its brake threshold (see below); sibling domains are unaffected
503rate_limit_unavailable / send_unavailable / enqueue_failed / warmup_unavailable / domain_health_unavailablean infra blip — retry with the same idempotencyKey; a duplicate is safely ignored, never a fabricated success

A 202 additionally carries hygiene: [{risk, detail, suggestion?}] whenever the recipient raised a risk the gate chose not to block on — reported so a sender can clean their list, never silently swallowed.

Automated domain warm-up ramp

A domain verified through this engine (POST /v1/domains, so it carries a verifiedAt) is held to a daily ceiling that grows over a 30-day ramp — 50 on day 1 up to 50,000 — after which it graduates and carries no warm-up ceiling of its own. This paces volume so mailbox providers build the domain's reputation gradually; it is *not* a deliverability or inbox-placement guarantee. The ramp is enforced on POST /v1/send, per recipient on POST /v1/send/batch (the excess recipients fail individually with error: "warmup_cap_exceeded", the rest still go), and on POST /v1/mailbox/:addr/send. It is independent of the per-owner reputation cap — both must pass, so the tighter one binds.

Stated honestly, because each is real:

Self-service domain onboarding

POST /v1/domains returns the complete record set to publish — six records, each with an honest note, a purpose (dkim / spf / dmarc / mx) and an enables saying what publishing it actually buys:

RecordsenablesEffect
3 × DKIM CNAMEverificationSES verifies the identity and DKIM-signs mail from it
SPF TXT, DMARC TXTdeliverabilityrecommended; neither is needed to verify
MXreceivingmail sent to the domain reaches SES

The MX value is 10 inbound-smtp.<region>.amazonaws.com — the SES *receiving* endpoint, which is an amazonaws.com host, not an amazonses.com one. The region is always passed explicitly and never defaulted: an MX aimed at the wrong region's endpoint does not bounce loudly, it silently fails to deliver.

Send-ready and receive-ready are independent. A domain can be fully verified, DKIM-signed and sending happily while every message sent *to* it bounces at the sender, because SES answers one identity-level question and knows nothing about the MX. readiness reports the two separately for exactly that reason.

Per-domain DKIM signing is SES Easy DKIM: each domain gets its own key pair and its own three CNAMEs, and SES signs mail from that domain with that domain's key. This engine does not implement a signer of its own and does not claim to — what it does is generate the per-domain records and refuse to call a domain verified until SES confirms the signing is live.

Auto-verification

POST /v1/domains/:domain/recheck is the on-demand "check now", and a scheduled sweep (hamani-email-domain-verify, every 5 minutes) does the same unprompted for every still-pending domain, across all owners. No admin step exists in this flow.

Both read the customer's published DNS and return a per-record verdict:

StatusMeaning
publishedfound, and the value matches
missingthe lookup definitively returned nothing
mismatchsomething else is published there — observed[] names it (e.g. a competing MX still pointing at a previous provider)
unknownthe lookup did not complete. Not the customer's failure, and never reported as one

Matching is tolerant exactly where DNS genuinely varies — trailing dots and case are insignificant, any MX priority is accepted, a merged SPF record counts as published (a domain may hold only one SPF record, so merging is the correct action the note itself instructs), and a DMARC record the customer has tightened or added rua= to still counts.

Stated honestly, because each is real:

Pre-send recipient hygiene

Every other reputation control here is reactive — it reads bounces and complaints that have already cost the domain reputation. This gate is the preventative one: it judges the recipient before the message is handed to SES, on POST /v1/send and per recipient on POST /v1/send/batch (a refused recipient fails alone with error: "recipient_refused"; the rest of the batch still goes).

Two confidence tiers, because a false positive refuses a customer's real mail:

TierRisksEffect
Hightypo_domain (a curated list of observed misspellings — gmial.com), role_address (RFC 2142 infrastructure mailboxes such as postmaster@, abuse@, plus the no-reply family), disposable_domain (known throwaway-mailbox providers)refuses a marketing send; flags a transactional one
Advisorytypo_domain_suspected — a single-edit-distance heuristic against the major mailbox providersnever refuses, in any category; reported only
unparseable_addressrefuses in every category

The category split is deliberate: nobody is waiting for marketing mail, so refusing it costs nothing, while wrongly blocking a password reset breaks the customer's product. The advisory tier never blocks because it has genuine false positives — wahoo.com is one substitution from yahoo.com, and both are real.

Stated honestly, because each is real:

Per-domain reputation and brake

Bounce and complaint counts are attributed to the sending domain as well as to the owner and the account-wide aggregate. A domain whose own rate breaches the threshold is braked on its own — 503 domain_paused — while every sibling domain of the same owner keeps sending. Thresholds: a 200-send minimum sample, then a brake at a 5% bounce rate or a 0.3% complaint rate.

Those look looser than the account brake (4% / 0.08%) until you do the arithmetic: the account rate is the volume-weighted average of the domain rates, so a bad domain is diluted at account scope. A domain that is 10% of volume bouncing at 20% puts the account at 2% — under the account brake, invisible, and still climbing. The per-domain brake catches it there. Where one domain *is* all the volume the two rates converge and the tighter account brake fires first, correctly, because there is nothing to isolate it from.

Stated honestly, because each is real:

Scheduled sends (sendAt, P12)

POST /v1/send accepts an optional sendAt (ISO-8601); scheduling a whole batch is a later addition. A sendAt in the future parks the send — fully gated (auth, rate-limit, reputation, account-brake) and with its marketing unsubscribe header already baked in — and returns: ``json { "status": "scheduled", "scheduledSendId": "…", "sendAt": "2026-07-20T09:00:00.000Z", "fairUse": { … } } ` from-domain ownership and payload size are checked at schedule time; a non-transactional scheduled send counts against the fair-use cap now. A sendAt` in the past/now falls through to an immediate send.

A recurring due-sweep Lambda (src/scheduledHandler.ts, an EventBridge ~1-minute rate rule — the Living Circle idiom, never a public trigger) releases due sends into the same SQS pipeline via the exact acceptSend path. Release is exactly-once by layering: a conditional claim (pending → releasing, with a re-claim grace for a crashed claimer) plus the send's own P2/P3 idempotency downstream — a double release can never double-send. The live key is re-fetched at release; a revoked or missing key cancels the send (never sent). A stored request that no longer validates, or a domain the key no longer owns, is cancelled honestly rather than looped.

Manage scheduled sends (owner-scoped): GET /v1/scheduled (list with a {to, subject, category} summary — never the full body), GET /v1/scheduled/:id (404 if absent), DELETE /v1/scheduled/:id (cancel a still-pending send; 409 scheduled_not_cancellable once released).

POST /v1/send/batch (scope: send, P12)

A broadcast to many recipients in one call — the dashboard's batch-send panel. Shared content + a recipients[] array of { to, data?, idempotencyKey? } (each recipient's data overrides the shared data for personalization):

``json { "from": "HAMANI CRM <[email protected]>", "subject": "Hi {{name}}", "templateId": "welcome", "category": "marketing", "recipients": [ { "to": "[email protected]", "data": { "name": "A" } }, { "to": "[email protected]" } ] } ``

Each recipient becomes an independent send through the exact same acceptSend path as POST /v1/send — per-recipient idempotency, per-recipient one-click unsubscribe token (marketing), suppression-at-send, and queue. This is orchestration over the single-send engine, never a second engine. Batch-level gates evaluated once: from-domain ownership (403 from_domain_not_allowed), account brake, reputation, and — for a marketing batch — that unsubscribe is configured (400 unsubscribe_required). recipients is capped at MAX_BATCH_RECIPIENTS (default 500 ⇒ 400 batch_too_large); the whole body is still bounded by MAX_SEND_BODY_BYTES.

Response 202: ``json { "status": "accepted", "total": 2, "queued": 2, "duplicates": 0, "failed": 0, "results": [ { "to": "[email protected]", "status": "queued", "messageId": "…", "idempotencyKey": "…", "deduplicated": false } ], "fairUse": { "capPerMonth": 50000, "usedThisMonth": 2, "overCap": false, "topUpAvailable": false } } ` A per-recipient failure is an entry with status: "error" (+ error`), never a thrown batch — one bad recipient never sinks the rest, and nothing is ever fabricated as sent. Non-transactional recipients count against the fair-use cap (P18); going over never blocks.

Templates (scope: templates, P12)

A customer's own branded, reusable templates, resolved at send time ahead of the shared HAMANI registry (welcome/password-reset stay code-defined). Every route is owner-scoped — a key only ever sees or edits its own owner's templates.

MethodPathPurpose
POST/v1/templatescreate (create-only ⇒ 409 template_exists) — body { templateId, defaultLocale, subject, html, text }
GET/v1/templateslist this owner's templates
GET/v1/templates/:idfetch one (404 template_not_found)
PUT/v1/templates/:idreplace (404 if absent) — preserves createdAt
DELETE/v1/templates/:iddelete (404 if absent)

subject/html/text are locale→string maps (with {{var}} placeholders, rendered exactly as the shared templates: HTML-escaped in html, control- stripped in subject, raw in text); each must include the defaultLocale (400 otherwise). At send time, POST /v1/send { templateId } resolves the owner's stored template first and falls back to the shared registry; an id matching neither is unknown_template (preserved to the DLQ, never sent empty).

Exactly-once (P2)

Two-phase durable reservation: a send reserves a pending record (short grace TTL), enqueues to SQS, then confirms to a committed record (full dedup TTL). A duplicate against a committed record is answered from the recorded messageId. A crash between reserve and enqueue delays — never loses — a genuine retry (the pending grace elapses and the retry re-drives). Recipient- facing exactly-once at the SES boundary (against SQS at-least-once redelivery) is completed by sender-side idempotency in P3.

Delivery (P3 — internal, off the queue)

The Sender Lambda (src/senderHandler.ts) is driven by an SQS event source:

  1. Each record is validated against SendJobSchema (a poison/malformed message

is re-driven so it lands in the DLQ, never silently dropped).

  1. Two-phase delivery claim on the tenant-namespaced idempotency key: a

sending claim (short grace) → the provider send → a delivered record (full TTL). An SQS at-least-once redelivery of an already-delivered message is suppressed (no second send) — recipient-facing exactly-once.

  1. Provider = SES v2 (ap-southeast-2) via Simple content (subject, html/

text, allowlisted headers, reply-to, configuration set, correlation tags). P4 adds a fallback provider behind the same EmailProvider interface.

  1. Retry/backoff = SQS partial batch failure: retryable outcomes (SES

throttle/5xx/transport, delivery-in-progress, unrenderable) are reported as batchItemFailures so SQS re-drives them and, after the queue's maxReceiveCount, routes them to the DLQ. Permanent rejections (MessageRejected, domain-not-verified, suspended) are dropped, not retried. A message is never acknowledged as sent unless the provider returned a real message id.

The delivery status endpoint + full message log land with P5 (events/suppression).

Limits (src/lib/config.ts)

LimitDefault
RATE_LIMIT_PER_MINUTE (per key)120
PRE_AUTH_RATE_LIMIT_PER_MINUTE (per IP)300
MAX_SEND_BODY_BYTES (request)6 MB
MAX_SQS_MESSAGE_BYTES (serialized job)262,144
PENDING_GRACE_SECONDS120
DURABLE_RECORD_TTL_SECONDS (committed dedup)86,400

Runtime-shape verification (P17)

The final engine phase adds no surface — it VERIFIES the CONSTITUTION §0 runtime shape (asynchronous, scale-to-zero, exactly-once, at-least-once-safe, no-cascade) with cross-cutting guards under test/verify/: N-way concurrency races on every conditional-write/atomic op, cross-container statelessness, runtime slice-conformance + a static narrow-slice guard, one large mixed SQS batch across every partial-batch-failure branch, the full backpressure ladder, and a bounded telemetry buffer under sustained overflow. Real scale/throughput and the DELIVERY_GRACE_SECONDS ≥ VT×maxReceiveCount CDK invariant remain INTEGRATION-GATED (Boss's deploy). See docs/SCALE-VERIFY.md and ASSESS/P17_ASSESSOR_FINDINGS.md.

Living Circle control plane (P16)

Separate from this keyed /v1 API, the engine runs the Living Circle as its own scheduled/admin Lambda (src/agents/livingCircleHandler.ts) — a one-way-door control plane, never on the customer send path. It implements LIVING-CIRCLE-CONTRACT.md as a product/client of the HAMANI Command Centre: it calls IN to pull config (GET /api/config/hamani-email-engine, HCK_READ) and push telemetry (POST /api/ingest/hamani-email-engine, HCK_WRITE) over HTTPS; the Centre never calls in and is never a runtime dependency. With COMMAND_CENTER_URL unset the client is a silent no-op and the engine serves on last-good config / code defaults. It manages the two agent lanes (deliverability, concierge) under a forward-only lifecycle + self-heal layer + two-gate spend permission. Model calls and the Command Centre round-trip are INTEGRATION-GATED. See docs/LIVING-CIRCLE.md.

Customer self-serve domain verification (P8)

A customer proves ownership of their OWN sending domain with SES Easy DKIM — never a static admin-set list alone. POST /v1/domains {domain} registers the identity and returns 3 DKIM CNAME records (<token>._domainkey.<domain><token>.dkim.amazonses.com) for the customer to publish; the domain starts status: "pending". POST /v1/domains/:domain/recheck polls SES and flips to "verified" only on a genuine SES confirmation (both VerifiedForSendingStatus and DKIM Status: "SUCCESS") — never optimistic (CONSTITUTION §6). A domain is globally claimed by whichever owner registers it first (DomainStore's conditional create); a later claim by a different owner is refused (409 domain_already_claimed), closing the same spoofing hole allowedDomains already closes for platform keys. Once verified, POST /v1/send accepts that domain for this owner exactly as if it were in the key's static allowedDomains (see src/send/acceptSend.ts's isDomainSendAllowed).

SesDomainProvider (production) and InMemoryDomainProvider (local dev/tests, with a verifyNow() test hook standing in for real DNS propagation) implement the same DomainProvider interface — selected by the existing EMAIL_PROVIDER driver (P8 shares SES-vs-memory with sending rather than introducing a second flag for the same choice).

Billing & usage metering (P9–P11)

POST /v1/admin/customers {ownerId} provisions a real Stripe customer for an owner (idempotent — a repeat call returns the existing billingCustomerId, never a second Stripe customer). Every real delivery (the Sender Lambda's markDelivered success path) increments a durable, permanent, per-owner per-UTC-calendar-month usage counter (UsageStore) — this is the CANONICAL record of what a customer sent, independent of Stripe. If the owner has a provisioned billing account, the same delivery is ALSO reported to Stripe as one Billing Meter Event (STRIPE_METER_EVENT_NAME), using the send's own messageId as Stripe's dedup identifier — the same exactly-once discipline P2 already applies to the send itself. Both the local counter and the Stripe report are best-effort relative to the send: neither can ever fail or retroactively un-send an already-delivered message.

Billing that would charge stays honestly disabled (DisabledBillingProvider) until ALL THREE of STRIPE_SECRET_KEY, STRIPE_METER_EVENT_NAME, and SELL_RATE_AUD_CENTS_PER_1000 are set — usage is still metered locally regardless. The moment a real sell rate IS set, the engine asserts a ≥60% margin over the ~A$0.15/1,000 SES cost (src/billing/pricing.ts) and refuses to serve a price that breaches it (CONSTITUTION §5) — this engine never invents a customer-facing price, and the Boss creates the Stripe Meter and its recurring Price in the Dashboard; this engine only ever reports raw usage against it.

POST /v1/billing/webhook verifies Stripe's Stripe-Signature header (a hand-rolled HMAC-SHA256 reimplementation of Stripe's documented scheme, src/billing/webhookVerify.ts — no live Stripe SDK call needed to verify), then dispatches into the subscription state machine (src/billing/ subscription.ts) by resolving the event's Stripe customer id back to an ownerId — via BillingProvider.getOwnerIdForCustomer, which reads the hamani_owner_id tag createCustomer already writes into Stripe customer metadata, so no second store/index is needed for the mapping:

EventEffect
invoice.payment_failedactive → past_due (markOverdue) on first failure. If ALREADY past_due for 5+ days, escalates past_due → paused (pauseForNonPayment) instead — Harry's rule, "invoice, five days, then pause, never cancel." Sending is already blocked at past_due (sendingAllowed treats past_due and paused identically), so this window is about the state escalating honestly on repeated failures, not a sending grace period.
invoice.paidpast_due/pausedactive immediately (restoreAfterPayment) — a customer who pays is not made to wait for a sweep.
customer.subscription.deletedcancelled (cancel, which deletes nothing — mail, mailboxes and domains stay). Keeps our record in sync with Stripe's actual state; this is not how non-payment is handled.
customer.subscription.created / customer.subscription.updatedReads which mailbox tier and which storage add-ons the subscription's line items carry (classifyStripeSubscription, keyed on the hamani_key metadata scripts/stripe-setup.py writes on every product/price — never on amount or name), stores it (setStoragePlan), then recomputes and writes the owner's total storage allowance (plans.ts's totalStorageBytes) to every mailbox they own. Not a price migration — no existing price is ever created twice or altered, so an existing line item's key is unaffected by a later catalogue change; this only mirrors what the customer's OWN subscription currently and actually contains.

An unmapped customer id (Stripe's data ahead of ours — e.g. a test event) is a 200 no-op, logged, never a 500 that would provoke a Stripe retry storm.

Fair-use monthly bulk cap (P18)

A per-tenant, per-UTC-calendar-month ceiling (FAIR_USE_BULK_CAP_PER_MONTH, default 50,000) on non-transactional (category !== "transactional") sends only — transactional mail is exempt and never counted, matching "unlimited transactional." The cap is soft: a genuinely new (non-deduplicated) accepted send always increments the counter and is always queued regardless of the result — going over cap never blocks, throttles the HTTP response, or changes the 202. The response's fairUse block simply makes the overage honest (overCap: true, topUpAvailable: true) so the calling platform (e.g. Marketing) can offer the customer a top-up itself — this engine never calls back into a product to push the offer (CONSTITUTION §4, one-way door). A deduplicated retry is not re-evaluated (no fairUse on that response) since it was already counted on its first acceptance.

Dashboard read-API — the hub seam (P12)

The premium dashboard lives in the hub (hamaniservices.com.au), a HAMANI product; this engine exposes only the read-only data behind a signed one-way seam (CONSTITUTION §4). The hub authenticates the human and calls GET /v1/dashboard/* IN with an X-Hamani-Hub-Signature: t=<unix-seconds>, v1=<hmac> header over ${t}.${METHOD}.${pathWithQuery}.${sha256(body)} (src/dashboard/hubSignature.ts, mirroring the Stripe/unsubscribe HMAC idiom). There is no viewer login in the engine.

Data source: real store rollups (AnalyticsStore) fed by the send path (sent) and the SES-event ingest (delivered/opened/clicked/bounced/ complained), attributed per sending domain via a reversible hamani_domain SES tag. Opens/clicks are total (not unique — PRIVACY LAW §3 forbids storing the recipient). Suppression listing is hashed-only. Domain health is SES's own verification status; SPF/DMARC are reported "provided" (given, not DNS-probed). See docs/DASHBOARD.md for the full endpoint contract. The premium UI itself is built in the hub repo, not here.

Inbound mailboxes — receive (P20)

Real business mailboxes ([email protected]) that RECEIVE mail, so HAMANI runs its own email off SES instead of paying per-seat elsewhere. Distinct from the delivery-EVENT ingest (P5): that records bounces/opens about mail we SENT; this stores inbound messages you can read.

Pipeline (INTEGRATION-GATED — real mail needs the deploy + MX records): SES receipt rule (scan on) → S3 raw MIME + SNS → Inbound Lambda (src/inboundHandler.ts) → mailparser → ObjectStore (S3: bodies/attachments) + MailboxStore (DynamoDB metadata index). Address-keyed: a mailbox is its own address, owned by the domain's verified owner — no identity subsystem in the engine ("identity separate, never a hard dependency"). Idempotent by the SES receipt id (a redelivery never duplicates). Spam/virus verdicts from SES route a FAIL to a quarantine folder; threading is by References/In-Reply-To (else a subject key). PRIVACY §3: the store necessarily holds the mail, but the log sink only ever sees ids/hashes/counts.

The webmail lives in the hub and reads/acts over the SAME signed one-way seam as the dashboard (X-Hamani-Hub-Signature; unset secret ⇒ 503): GET .../messages (folder list, metadata only), GET .../messages/:id (bodies inlined from S3), GET .../messages/:id/attachments/:n (streamed, base64), POST .../read {read}, POST .../folder {folder}. Folders: inbox|sent|trash|quarantine. See docs/DASHBOARD.md for the seam mechanics.

Compose / replyPOST /v1/mailbox/:addr/send {to, subject, html|text, replyTo?, inReplyTo?, references?}: the mailbox's domain must be verified (403 otherwise), the shared-reputation gate still applies, it sends through the same acceptSend path (a HAMANI-owned platform send), and stores a copy in the mailbox's Sent folder (best-effort — a mailbox-write blip never fails an accepted send). In-Reply-To/References are allowlisted send headers so a reply threads with the received conversation.

Super-admin — cross-tenant control plane (/v1/admin/tenants/*)

Level 1: the Boss's own reach across every tenant, reached THROUGH the hub — never called directly by a browser (no HMAC secret there). Gated three ways, none skippable: (1) the hub's X-Hamani-Hub-Signature; (2) the acting owner's SEALED SESSION (X-Hamani-Actor names the mailbox, X-Hamani-Session proves it is signed in); (3) assertSuperAdmin — the acting mailbox must be an exact member of OWNER_EMAILS and have ACTIVE 2FA. There is no opt-out anywhere in the chain, and owner-ness is re-derived on every call, never cached — strip an address from OWNER_EMAILS and its next call is refused even with a perfectly valid, still-signed session.

Routes: GET /v1/admin/tenants (registry), GET /v1/admin/tenants/:id (control record), GET /v1/admin/tenants/:id/audit (read-only, see below), POST /v1/admin/tenants/:id/{suspend,restore,delete,kill,restore-sending,cap, flag,grant,impersonate}. grant ({mailbox?, storageBytes?, freeSending?}) is the CUSTOMER COMP mechanism: it can raise (never shrink) a mailbox's quota and sets the tenant comp flag, which the send path treats as free — skipping the non-payment pause and the fair-use cap — without touching Stripe at all. kill/restore-sending is the emergency brake, checked first on every send; neither owner nor comp status bypasses it. impersonate writes ONLY an audit row (admin.impersonation.started) — the engine never mints an impersonation session; that is the hub's job, not yet built.

Every mutating action appends an entry to the TARGET tenant's own audit trail (actor, action, detail, at) via a store that exposes append + read only — there is no update or delete method, so not even the Boss can erase the record of an administrative action.

Owner console UI: GET /admin in the hub (session + owner flag gated, otherwise a plain 404) relays every route above through hub/routes/console.ts — the acting mailbox always comes from the signed cookie, never the request.

Platform staff, credit, and approvals (/v1/staff/*, /v1/credits/*)

Level 1.5: HAMANI's own people, sitting between the super-admin (unlimited, OWNER_EMAILS, deploy-gated) and a customer's own workers (/v1/workers/*, tenant-scoped). Deliberately not self-service at the super-admin level — minting a super-admin from a web session would let one stolen session hand over the company permanently, so that power stays in OWNER_EMAILS only. Staff (admin | support) are durable records a super-admin manages.

Gated by requireStaff — the same hub-signature + sealed-session chain as the super-admin plane, but resolved through resolveStaffActor: OWNER_EMAILSsuper_admin (unlimited); else an ACTIVE StaffRecord → its role and limits; else 403 not_staff. A suspended staff record is refused immediately, on the very next call — the revoke does not wait for a session to expire.

The ledger is append-only by contract — the store exposes append + read only, exactly like the super-admin audit trail. A credit issued in error is corrected by appending a REVERSAL (a negative entry naming who reversed it), never by rewriting history. Honesty note: nothing in this engine generates invoices yet (no production caller for buildInvoice), so a credit is a durable, audited standing balance to be honoured — not a claim that a Stripe refund or invoice adjustment has happened. See LAUNCH-AUDIT.md §3.

Owner console UI: the same GET /admin page adds a Team section (add/ suspend/remove staff, their limits shown plainly), an Approvals section with a pending-count badge, and a per-customer "Give credit" action — all relayed through hub/routes/console.ts, the acting mailbox always from the cookie.

Deliberately not promised yet