addressium
Prototype
GitHub
Admin user pool · staff only

Sign in to the console

Newsletter operators & administrators
Forgot your password?
🔐 MFA required · TOTP
Hosted UI secured by Amazon Cognito · admin.addressium.northwindtimes.example
Northwind Times / Dashboard
PROD us-east-1
DA
Design reference — illustrative data, no backend. addressium is pre-1.0 and has never been deployed to a real AWS account. Anything carrying Not yet built is a design the code does not implement yet.

Dashboard System health: OK

Sending health and audience at a glance — last 30 days. The health badge is one derived value from GET /orgs/{org}/health; raw CloudWatch alarms deliberately stay out of a marketing console.

Audience & sending · rolling 30 days
Not yet built
Total subscribers128,430▲ 3.1% vs last mo.
Emails sent (30d)2.41M▲ 8.4%
Avg. open rate42.6%▲ 1.2 pts
Avg. click rate7.9%▼ 0.4 pts

Sends & engagement

Not yet built
Trend charts over time — list growth, deliverability and engagement — are not yet built. What ships today is the per-campaign report: counters, rates and the per-link click table. See a real report →

Deliverability · latest edition

Healthy
Delivered99.2%
Bounces0.6%
Complaints0.03%

Complaint rule
warn 0.1% · halt 0.3%
Bounce rule
warn 2% · halt 5%
Auto-halt
Armed
Org setup
3 / 3 required
Rates are the Jul 20 edition’s own counters, not a 30-day aggregate. The thresholds come from GET /orgs/{org}/alerts, the setup line from GET /orgs/{org}/setup. DKIM/SPF/DMARC state and the SES send quota are not readable — see Settings → Domains.

Recent campaigns

View all →
The Morning Ledger — Jul 20
Sent to 96,204 · 2 hours ago
44.1% open8.3% clickSent
Market Signal — Weekly Brief
Sent to 8,410 paid · yesterday
61.7% open14.2% clickSent
Weekend Long Read — Jul 20
Scheduled · Sat 8:00 AM ET
Scheduled
Product Dispatch — v4 launch
Draft · edited 3 days ago
Draft

Newsletters

Each newsletter is a list with its own opt-in policy, from-address and compliance footer.

Open newsletters appear on the public opt-in page and accept signups. Closed ones are hidden from the directory and reject new signups — existing subscribers still receive them.
NewsletterOpt-inSubscribersFrom addressCadenceAccessSignups
The Morning LedgerDouble opt-in97,408ledger@northwindtimes.exampleDaily · 6am ETFreeOpen
Market SignalDouble opt-in8,410signal@northwindtimes.exampleWeeklyPaidOpen
Field NotesDouble opt-in54,880notes@northwindtimes.exampleWeekly · ThuFreeOpen
Weekend Long ReadDouble opt-in61,300read@northwindtimes.exampleWeekly · SatFreeOpen
Product DispatchSingle opt-in12,940product@northwindtimes.exampleAd-hocFreeClosed

Subscribers

The addressium subscriber record is the primary identity — sub is a UUID minted at signup. A Cognito pool is optional and link-only, joined on externalId; no pool has to exist anywhere to run a list. Click a row to view, manually unsubscribe, or suppress.

There is no “add subscriber” here, on purpose. A subscriber is created by a public signup (which captures the consent timestamp, IP and source URL), by an import (which makes you declare the consent basis first), or by the identity webhook matched on externalId — never by an operator typing an address into the console, because that record would carry no consent evidence at all. The same rule shows up in segments: adding an address to an explicit cohort that is not already a subscriber is rejected rather than created. The admin routes here read, unsubscribe and suppress; none of them creates.
128,430 people
SubscriberStatusEntitlementListsLast clicksub
JR
Jordan Reyes
jordan.reyes@example.com
ConfirmedPaid32h agoa1f3…9c2
MO
Mei Okafor
mei@okafor.example
ConfirmedFree21d ago7be0…14a
TS
Tomás Silva
tsilva@silva.example
PendingFree1c40d…8f1
AH
Aisha Hassan
aisha.h@example.com
ConfirmedPaid45h ago2d99…30b
DK
Daniel Kim
dan.kim@example.com
UnsubscribedFree012d ago5aa1…7e4

Segments

Saved predicates over lists, status and attributes. The engine is chosen at deploy time: the v1 GSI engine (shipped default) needs a base list and cannot resolve engagement recency. The opt-in OpenSearch mirror lifts the base-list requirement; engagement recency is not resolvable on either engine today — see the builder.

SegmentSizeBasisEngine
Paid subscribers14,860list + entitlement = paidGSI
NYC metro9,410list + attribute · cityGSI
Launch test cohort42explicit · 42 subscriber idsany
An explicit cohort is a list of subscriber ids, not addresses — but membership is not consent. The resolved set is intersected with the list's confirmed subscriptions and never used in their place, and adding an address that is not already a subscriber is rejected rather than created.
Segment builder
Save refused · 400

Lapsed — no open 90d

Match ALL of the following
List is The Morning Ledger
Status is Confirmed
Last open before 90 days ago

engagement predicates are not supported by the v1 segment engine (#28)
deploy with -c enableOpenSearchMirror=true to use this predicate, or add a list in <listId> condition
The two lines above are the API’s own 400 and hint, verbatim. Refused at save time, not send time — otherwise the first anyone hears of it is a campaign that has already claimed itself. The mirror is off in a default deployment.
Engagement recency, end to end
Not yet built
The flag is necessary but not sufficient — turning the mirror on would not make this segment work. The mirror document has a last_open_at field and the mirror’s query builder can range over it, but the indexer never passes a value for it: it projects the subscriber and their confirmed lists and stops there, so last_open_at is always absent and the predicate would match nobody. There is also no open-recency field on the subscriber record to source it from — lastEngagedAt is clicks only, deliberately. So engagement-recency segmentation is not yet built, and the hint names the flag that would be the first step rather than the whole fix.

Campaigns

One-offs and recurring series. Reporting is per campaign; recurring sends run on EventBridge Scheduler, and every schedule carries a lifecycle record that is the source of truth for whether it may fire.

Start · pause · archive — never delete. POST /campaigns/lifecycle moves a schedule between active, paused and archived. A paused series keeps ticking but builds no edition; a paused one-off is parked on its lifecycle record and re-enqueued on resume, so pausing never burns the idempotency claim. Archive discards the parked send — a terminal state that leaves something waiting to fire is not terminal. The EventBridge schedule and the record are never deleted.
recent sends
CampaignTypeStatusAudienceOpenClickWhen
The Morning Ledger — Jul 20Ongoing · DailySent96,20444.1%8.3%2h ago
Market Signal — Weekly BriefOngoing · WeeklySent8,41061.7%14.2%Yesterday
Weekend Long Read — Jul 20Ongoing · WeeklyScheduled61,300Sat 8:00 ET
Field Notes — Jul 17Ongoing · WeeklyPaused54,880Held 2d
Product Dispatch — v4 launchOne-offDraft3d ago
Series-level reporting
Not yet built

A CampaignSeries type exists but no code writes one. There is no aggregate across editions — edition count, average open/click, trend — and no reschedule and no resend. Reporting today is per campaign, on the report screen.

New campaign

Compose → choose audience → review & send.

Content
Audience
3Review
Magic-link on · 2 ad blocks in this template. Editorial links get each recipient’s token; an ad block’s HTML is hard-sanitized when this campaign is scheduled, then inserted verbatim — never tokenized, never click-tracked, excluded from the click table. Ad tags →
Review
Newsletter
The Morning Ledger
Audience
All confirmed · 97,408
Suppressed
1,204 (bounces + complaints) → 96,204 mailable
From
ledger@northwindtimes.example
Unsubscribe
One-click (RFC 8058)
Send
Now — placed 5 minutes out · 340 msg/s
“Send now” is still a one-off schedule, never earlier than MIN_ONEOFF_LEAD_MS — five minutes — so there is a window to pause before anything leaves.
One SendEmail per recipient, permanently. SendBulkEmail would batch fifty at a time, but per-destination Headers do not exist on a bulk send — all fifty would share one List-Unsubscribe, and ours is per-subscriber. Batching would trade the one-click POST Gmail and Yahoo require for a cost saving. Not a gap; a decision.

No “send test”. There is no test-send route, handler or domain function anywhere — POST /campaigns/schedule mails a list, and nothing mails one address on request. §11 still lists it as an open question (“a rendering-preview service, or rely on test sends in v1”), so neither half is built. What you get instead is the five-minute lead window below and, on a dev org, a fail-closed allowlist that makes a real send reach only the addresses you named.

Templates

Three authoring modes — pick the right one per team. All render through the same responsive pipeline, and List-Unsubscribe headers (incl. RFC 8058 one-click) are set on every message.

The compliance footer — physical address plus unsubscribe link — is a reserved merge value: the send path supplies {{compliance_footer}} and {{physical_address}} from the list record on every message. But nothing places them for you — not the pipeline, and not the seeded template either: the smoke-test seed declares only first_name, list_name and unsubscribe_url, and its three bodies contain neither reserved name. So every template, seeded or hand-built, has to include the footer itself or the send goes out without one. The list-level guard is the setup checklist, which refuses to call an org complete while any list is missing a physical address or footer.
Visual builderno code

Drag-and-drop blocks (GrapesJS → MJML). For editors & ad reps building polished sends without touching code. Open →

MJMLdevelopers

Responsive markup + merge tags with live preview. Full control over layout.

Raw HTML blastone-off

Paste advertiser-supplied HTML as-is. Hard-sanitized at save and schedule time; the footer merge value is yours to include.

MJML source
autosaved
<mj-section>
  <mj-column>
    <mj-text font-size="17px">
      Good morning, {{ first_name }}.
    </mj-text>
    <mj-button href="{{ editorial_url }}">
      Read today’s lead
    </mj-button>
    <mj-text>{{ compliance_footer }}</mj-text>
  </mj-column>
</mj-section>
Live preview

Automations

Linear drip sequences on Step Functions — waits and sends, in order. Two triggers exist: signup (a completed double opt-in) and manual. No conditional splits, branches, holdouts, goal exits or re-entry rules.

SequenceTriggerStepsConsent gate
Welcome seriessignup The Morning Ledger3per step
Field Notes primersignup Field Notes2per step
Paid onboardingmanual enrolled by hand4per step + entitlement
There is no “how many are enrolled” column, because there is no answer to read. Nothing persists an enrolment: DripSequence carries an org, a name, a trigger and its steps — no enrolment state — and there is no enrolment entity anywhere. A run exists only as a Step Functions execution name, and the API role is granted states:DescribeExecution for a name it already knows and nothing else; no role holds ListExecutions. So neither a per-sequence count nor a per-subscriber “step 2 of 3” is computable, and both used to be shown here.
Enroll a subscriber
Not yet built
POST /drip-sequences/enroll (campaigns:manage) exists and works — this console screen for it does not. It refuses hand-enrolling into a signup-triggered sequence rather than silently duplicating, and refuses a subscriber who has not confirmed the list step 0 mails.
Welcome series
Trigger — Opt-in confirmed
Double opt-in on The Morning Ledger enrolls automatically
wait 0 min
Consent gate — this step’s list
Confirmed → send · missing or pending → exit
Send — Welcome + what to expect
Template: Welcome 01
wait 2 days
Send — Best of the Ledger
Template: Welcome 02 · consent re-checked first
Consent is checked at every step, not only at the door. A step sends only when the subscriber’s subscription to that step’s list is confirmed; a missing or pending one exits the sequence. A broadcast needs no such check — it fans out over the confirmed index — but a drip step is handed a bare subscriber id, so it has to ask.
The newest enrollment wins. Each run is namespaced by the subscription’s consent timestamp, which also namespaces every step’s send claim. If someone re-signs up mid-sequence, the older run checks whether it is still current and retires itself — so the two never both deliver, and clicking the confirmation link three times enrolls once.
Re-engagement → sunset · the second automation
Not yet built

The sweep is built and runs: one weekly EventBridge rule (Mondays 04:00 UTC) invokes a dispatcher that finds the orgs with reengagement.enabled and sweeps each in checkpointed pages, resuming where it stopped. What is not yet built is any console surface for it — no route writes Organization.reengagement, so the policy below is deployment configuration, not a screen. The values shown are the shipped defaults.

enabled
false per-org opt-in, never deployment-wide
listId
required once enabled · no default
coldAfterDays
180
steps
3 win-back emails
stepIntervalDays
7
Coldness is click-weighted. lastEngagedAt advances on clicks only — Apple Mail Privacy Protection auto-opens mean an open no longer proves a human looked. With no click yet the anchor falls back to the consent time, and a subscriber with no anchor at all is left alone: never mailed, so there is nothing to judge.
Graduate or sunset. A click at any point clears the enrolment and graduates them. A sequence that completes with no click unsubscribes them from every list and suppresses with source: "inactive" — org-scoped and self-clearable, so a later genuine re-opt-in restores them. That is the Inactive row on Suppression.
Off by default, and that is the design. The terminal step unsubscribes people, and a shrunk list is not something an operator can undo — a default-on sweep would start silently trimming installs where nobody asked. Weekly rather than daily, because the default waits 180 days for coldness and a daily pass would pay for a full org scan to do nothing six days in seven. An org that enables the policy without naming a list is reported by the dispatcher rather than swept, because silence would look identical to “no cold subscribers” — precisely the failure this exists to prevent. Each win-back step is its own reengagement:{list}#{n} sub-campaign, so it aggregates separately and the send is idempotent.

Analytics — The Morning Ledger, Jul 20

Per-campaign counters, deliverability rates and the per-link click table. Magic-link tokens are redacted before a click is stored.

Delivered95,41099.2%
Unique opens42,07644.1%
Unique clicks7,9188.3% CTR
Unsub / complaint610.06%
Not yet built — the overlay, not the numbers. A per-campaign EmailArchive record stores the link map and the S3 key the generic rendered body would live at, but writing that body is not built, so painting these badges onto it in a sandboxed iframe is not either. The click counts themselves are real and ship on the Links tab.
Click map · archived copy
Not yet built
editorial link · click count hottest link LiveIntent ad — excluded

Top links

editorial only
LinkClicksCTR
the chart everyone’s sharing3,2043.4%
a deal to watch1,0901.1%
rates6420.7%
energy4180.4%
Unsubscribe540.1%
Not yet built. The report screen shows counters, rates and the click table; funnels, trends and per-subscriber timelines are not built. The rows below are the arithmetic those counters already support, drawn as a proposal.
Engagement funnel
Sent
96,204
Delivered
95,410 · 99.2%
Unique opens
42,076 · 44.1%
Unique clicks
7,918 · 8.3%

sent − delivered − bounces − rejects is how far a send got: 96,204 − 95,410 − 573 − 12 = 209 still in delivery delay, being retried.

All links

editorial only
LinkClicksUniqueCTR
the chart everyone’s sharing3,2042,9813.4%
a deal to watch1,0901,0041.1%
rates6425980.7%
energy4184020.4%
Unsubscribe54540.1%

Every other SES event

own counter each
Bounces573
Rejects12
Delivery delays209
Rendering failures0

None of the three new ones suppresses. A reject means SES accepted the message then refused to send it — nothing reached a receiver, so it is not a bounce and suppressing would punish a subscriber for our attachment. A delivery delay is a full mailbox or a throttling receiver, still being retried; suppression is global, so acting on a transient condition would kill a valid subscriber everywhere.
A rendering failure alarms mid-send. It means a merge tag did not resolve — the one event in the feed that points at our bug rather than a recipient’s mailbox, and campaign-wide by nature. The handler logs it at error level and a CloudWatch alarm (RenderingFailures) fires while the send is still running, which is the only time the information is worth anything.
SES Send and Subscription stay unconsumed on purpose: Send duplicates what the sender already counted, and we never enable SES’s own subscription page — unsubscribe is RFC 8058 through our handler — so an event about a preference we do not honour would write a row nothing reads.

Settings

Deployment configuration for this addressium account.

Sending domains

Built
DomainOn the org recordSES identityConfig set
northwindtimes.exampledomains[0]Created at provisioningCreated at provisioning
news.northwindtimes.exampledomains[1]Created at provisioningCreated at provisioning
Provisioning creates the SES domain identity (with DKIM/SPF/DMARC records to publish) and the configuration set unconditionally. What the console can then read back is this list of names on the organization record — nothing more. The setup checklist’s “sending domain” step is literally org.domains.length > 0.

Verification & quota readout

Not yet built
DomainDKIMSPFDMARCTracking
northwindtimes.exampleVerifiedPassp=rejectclick.northwindtimes.example
news.northwindtimes.exampleVerifiedPassp=noneclick.northwindtimes.example
Every cell in the table above is a design, not a reading. No route returns SES identity or verification state — ses:GetEmailIdentity is held by the provisioning function alone and the admin router has no SES read grant at all. Nothing in the repo calls GetSendQuota, so there is no quota or send-rate readout and no way to tell whether the account is out of the SES sandbox. And no custom click-tracking or redirect domain is configured anywhere — TrackingOptions appears nowhere, so click.… is invented. The setup module says as much in its own words: “SES domain verification and sandbox-exit are AWS-side steps the wizard surfaces as guidance; everything here is derived from data we own.”
Magic-link tokens
Signing
Asymmetric · ES256 (KMS)
JWKS
/.well-known/jwks.json
Placement
URL fragment #tok=
Scope
content:read
TTL
14 days
Redemption
Reusable · stateless
Forwardable by design — lite scope only. The main site must gate profile pages behind step-up auth.
Entitlement
subexternal_subscopeamrentitlemententitlement_asofaudissiatexp
There is no extension point: the signer emits exactly these claims, so claim minimisation is enforced by construction rather than by policy. No profile field — a name, an address — can be put into a URL-borne token. An operator-configurable whitelist is not yet built, and the closed set is the stronger posture.

Last sync
4 min ago · 212 updated
Values
free paid
Deliverability alerts → Amazon SNS

Point alerts at an SNS topic — fan out to email, SMS, Slack, PagerDuty, or a Lambda.

ops@northwindtimes.example#deliverability
There is no “send test alert” — nothing publishes to the topic on request; only a real breach does. snsTopicArn is also optional on purpose: halting is the safety control and notification is secondary, so an org with no topic still stops a campaign that breaches — it just does so quietly.
Alert rules
Complaint rate
Warn 0.1% · auto-halt 0.3%
Bounce rate
Warn 2% · auto-halt 5%
Send failure spike
SES send errors
Reputation drop
SES account reputation
Operator-supplied · addressium does not create these

Where an AWS account very likely already runs something, addressium consumes it by configuration rather than creating a competing copy. Deliverability alerts above are for the person running the lists; these are for the on-call.

opsAlertTopicArn
arn:aws:sns:us-east-1:…:oncall
apiWebAclArn
not set
cloudfrontWebAclArn
not set
With no WebACL associated, nothing rate-limits by IP — the public plane’s brakes are double opt-in, signed action tokens, schema validation, the honeypot check and reCAPTCHA where an org has configured a secret. And an alarm can fire into a topic nobody is subscribed to: set neither key and the created topic has no subscription at all. Silently shipping unprotected is worse than shipping without a WAF, so deploy:check warns on both.
GDPR / CCPA — data-subject requests

Export or erase a person by email. Erasure anonymizes the profile in place (email → erased:<sub>, attributes cleared, status suppressed), unsubscribes every subscription, and deletes the external-id pointer, the email reservation, the entitlement record and every event row naming them. It returns an ErasureReport of what it reached, not a bare true.

Two things are deliberately kept: the suppression tombstone, which holds the address so the next import cannot silently re-add the person (Art. 17(3)(b) / Recital 65), and each subscription’s consent timestamps and basis, which are the org’s evidence it was once entitled to mail. Campaign counters are aggregates and are untouched.
Retention & consent
Consent capture
timestamp · IP · source URL
auditRetentionYears
7 · Object Lock, set-once
analyticsEventRetentionDays
730 · opt-in lake only
Operational event log
append-only · no TTL
Right to be forgotten
Automated
Data residency
us-east-1 · your account
A configurable retention window on the operational event log — 13 / 25 months, the knob a marketer expects — is not yet built. The two above are the knobs that exist.

Admin team

Staff in the separate admin user pool. Each member has a role, scoped to one or more organizations.

MemberRoleOrg accessMFALast active
DA
Dana Alvarez
dana@northwindtimes.example
Developer AdminAll orgsTOTPNow
PW
Priya Walsh
priya@northwindtimes.example
EditorNorthwind · LakesideTOTP2m ago
RB
Ruben Ba
ruben@northwindtimes.example
Analyst (Sales)All orgsTOTP3d ago
MC
Marco Cruz
marco@northwindtimes.example
SupportNorthwindPending

Feeds Not yet built

Pull articles from RSS / Atom / JSON feeds to auto-build campaigns. Feed fields map to merge tags.

The RSS → edition pipeline is built and running; this screen is not. Feeds are config- and code-driven today — services/feeds is a library imported by the launch function, with no console route behind any control below.
FeedMaps toLast pulledItemsStatus
Homepage RSS
northwindtimes.example/feed.xml
The Morning Ledger6 min ago28Active
Markets JSON
api…/markets.json
Market Signal14 min ago12Active
Features Atom
northwindtimes.example/features.atom
Field Notes1 h ago6Paused
Field mapping — Homepage RSS
<title>
{{article_title}}
<link>
{{editorial_url}} + token
<description>
{{article_excerpt}}
media:thumb
{{article_image}}

Merge tags Not yet built

In-email replacement variables and where each value comes from at send time.

Merge values resolve on every send; a merge-tag management screen is not built, and there is no route behind “+ Add tag”. Templates declare their placeholders in code today. The reserved names the send path supplies are unsubscribe_url, list_name, compliance_footer and physical_address — a reserved name wins over an attribute of the same name.
Tags backed by a token claim ride in the magic link; per-recipient tags resolve during bulk send; per-campaign tags are identical for everyone.
TagSourceScopeExampleFallback
{{first_name}}Profile attributeper-recipientJordanthere
{{editorial_url}}Feed link + magic tokentoken…/article#tok=…
{{entitlement}}Profile · syncedtoken claimpaidfree
{{article_title}}Feed fieldper-campaignThe one chart…
{{unsubscribe_url}}Systemper-recipient…/u/…
{{compliance_footer}}System · reserved merge valueper-campaignNorthwind Times · 123 Main Street…
{{physical_address}}System · list addressper-campaign123 Main Street, Anytown, USA

Ad tags Not yet built

LiveIntent ad HTML per slot, inserted as-is — never tokenized or click-tracked.

What ships is the ad block: a block-mode template can carry an ad block whose HTML is hard-sanitized at the schedule boundary — the same sanitizer a raw-HTML blast gets, deliberately, so blocks mode is no weaker than raw_html — and then inserted verbatim at render, never tokenized, never click-rewritten, excluded from the click table. What is not built is everything on this screen: named slot fills (Template.adSlots, CampaignSeries.adSlotFills) are modelled in the types but nothing consumes them, there is no management screen, and the “series default” binding depends on series structure that is itself designed-only.

Slots in this template

7 slots
SlotPositionThis edition
{{ad_top}}Above the foldFilled
{{ad_inline_1}}After story 1Filled
{{ad_inline_2}}After story 2Filled
{{ad_inline_3}}After story 3Filled
{{ad_native}}Native unitFilled
{{ad_sidebar}}SidebarEmpty
{{ad_footer}}Below footerEmpty
Edit slot — {{ad_top}}
not tracked
LiveIntent HTML for The Morning Ledger — series default · reused by every daily edition
Hard-sanitized when the campaign is scheduled, then pasted verbatim into the slot at render. Links inside are left untouched — no magic token, no click rewriting, excluded from the click map. Saved at the series level, so every future edition reuses it until you change it.

Identity & pools

Magic-link signing, plus the optional Cognito pools an org can link. Held on the organization record; scoped to Northwind Times. Written at provisioning time and read-only here — no route updates an organization.

Admin user pool · staff
shared across orgs
Subscriber user pool
optional · linked, never created
addressium references this pool — it does not own it and never creates it; the stack holds no CreateUserPool permission anywhere. Linking validates it with DescribeUserPool and nothing more. An org with magic links off has no pool at all and runs a list fine.
Magic-link signing · Northwind Times
An org gets its own KMS signing key only when magic links are on; with the feature off it gets no key, no linked pool, no JWKS and no token at all — editorial links render untokenized and the list still works. The JWKS endpoint is one shared API route serving every org, not a per-org resource: it publishes each org’s key under its own kid, which is what keeps silos from sharing tokens.
There is no key-rotation route. Rotating an org’s magic-link signing key — new key, new kid, republished JWKS — is not yet built: no route, no handler, and nothing in the console. The one rotation that ships is the confirm-token HMAC keyring, on a yearly Secrets Manager schedule with no console surface at all. Everything on this screen is read-only: the pool link, the domain and the identity config are written once by POST /orgs at provisioning time and there is no org-update route to change them afterwards.

Data & exports

Migrate a subscriber base in, take it back out again — scoped to this organization. Run the SES suppression import before this.

Import — step 1 · file
⬆ Drop a CSV or a gzipped JSON Lines file
both shapes Pinpoint hands out — dotted-column CSV, or an export job’s .jsonl.gz
Gzip is detected by magic bytes, not a filename or a content-type we were never given, and each endpoint object is flattened into the same dotted-path row the CSV path produces — there is deliberately no second import pipeline, so one set of compliance rules applies to both. A file that is neither shape is a 400 naming both. Decompression is bounded at 256 MB.
Pinpoint segments are not imported, by decision (#243). Behavior, Metrics, Demographic and Recency dimensions have no counterpart here, so a partial translation would look migrated while targeting the wrong people — and nobody would think to check it. Rebuild segments by hand. The list memberships they were built from arrive with the subscribers.
Import — step 2 · declare, before any row is written
Status is derived from the basis, not taken from the file: only explicit can produce confirmed, so implicit plus status: "confirmed" yields pending. The API refuses a confirmed import against an implicit or absent basis outright, naming the columns that blocked it. A Pinpoint EffectiveDate is an endpoint-update stamp and is never presented as consent.
Attributes.SD_Ledger
→ The Morning Ledger
Attributes.SD_Signal
→ Market Signal
Attributes.companyname
→ attribute, not a list
OptOut · EndpointStatus
never mailable
Audience columns are three-state: true subscribes, false writes an unsubscribed row so the decline survives the next import, and empty writes nothing — the subscriber was never asked. Creating a list inline requires a from-address, compliance footer and physical address; defaulting them would ship a CAN-SPAM violation. The column plan is fingerprinted on the header set and offered back on the next file of the same shape.
A large import is a job, not a request
1
POST /import/upload-url
Short-lived presigned PUT, returns the batch id. The upload bucket expires objects after 7 days.
2
Upload straight to S3
The bytes never traverse API Gateway.
3
POST /import/async202
Batch marked running before the invoke, so a 202 always has something to poll. Fifteen minutes and 1 GB, not a route’s 29 seconds.
4
GET /import/batches?batchId=
The batch record is the status endpoint — an async run outlives the request that started it, so there is nowhere else to ask.
The inline paste path stays, capped at 2 MB with a 413 naming the async route — a 10 MB-shaped failure from API Gateway tells an operator nothing about what to do instead. A run counts as failed only if it threw or imported nobody; a file where a few rows were malformed is completed with those rows reported, because telling someone to re-run an import that worked is how you get duplicates. batchId is validated at the boundary — it becomes both an S3 object key and a DynamoDB sort key.

Import batches

GET /import/batches
Batch idRowsStatusFinished
ledger-migration-072016,502running
ledger-dryrun-071916,502completedYesterday 14:08
signal-0716418completed4 days ago · 12 rows reported
courier-07120failed8 days ago · not a CSV or a gzip
Export
emailstatusentitlementconsent basis + timestampsevery attribute column
There is no field picker and no segment scope: GET /orgs/{org}/export reads exactly format, listId and includeUnsubscribed. The column set is derived, not selected — CSV takes a deliberate second pass over the rows to learn the attribute columns rather than buffering every row to discover them — because the file’s whole purpose is to re-import through the mapper above, and a subset would not round-trip.
Consent provenance rides along and the file re-imports through the mapper above, so leaving is a round trip rather than just a download. The response is a presigned URL valid five minutes, not a payload — it is a bearer credential for the whole subscriber base, so its short life is the control, and the bucket expires every object after seven days regardless. Export sits behind the destructive-tier capability, not the read-only one, and is audited.
← Subscribers

Jordan Reyes Paid

jordan.reyes@example.com

Identity
Subscriber sub
a1f3b8c0-…-9c2
externalId
4c02…b71 · linked pool
Organization
Northwind Times
Status
Confirmed
Entitlement
paid · asof 4 min ago
Source
Signup form · homepage
Consent
2025-11-04 · IP 73.x · double opt-in
Suppression
None

List subscriptions

The Morning Ledger
Confirmed · daily
Market Signal
Confirmed · weekly · paid
Weekend Long Read
Confirmed · weekly
Leaving one newsletter writes no suppression entry. A per-list unsubscribe flips that subscription to unsubscribed and nothing more — the sender only mails confirmed subscriptions, so no address-level suppression is needed and the other newsletters are untouched. Only Unsubscribe from all adds a suppression entry, and it is org-scoped: hard bounces and complaints go global, unsubscribes stay per-org, because leaving one publication should not drop you from another (§4.13, hybrid). Either way any drip run on that list ends: a sequence re-checks this status before every step, and a status that is no longer confirmed exits the sequence rather than sending the rest. The console cannot say where in a sequence someone is: no enrolment record is persisted, so there is nothing to read.
Nothing on this screen can un-suppress an address. Suppression outranks every opt-in an admin can set; lifting it is a separate act behind suppression:manage. Said here in place, so a resulting silent send is not read as a broken feature.

Recent activity

Not yet built
Per-subscriber activity timelines are not built, and it is not merely a missing screen: the data model has deliberately no subscriber-keyed event index — that would be a second place the subscriber id lives, and one more thing erasure has to reach. Events are keyed by campaign.
Opened — The Morning Ledger, Jul 20
2 hours ago
Clicked — “the chart everyone’s sharing”
2 hours ago · editorial link carried a magic-link token
Opened — Market Signal
Yesterday

Suppression

Addresses that are never sent to. Hard bounces and complaints are added automatically.

Import the provider’s own suppression list
Developer Admin only

POST /orgs/{org}/import/suppression reads the SES account suppression list — the one SES maintains itself from hard bounces and complaints. This is the half of a migration nothing else can reconstruct: subscriber records can be re-exported from the source at any time, but “this address hard-bounced two years ago” exists only here. Skip it and the first campaign after the migration mails every one of those addresses, straight into the rates the deliverability halt exists to catch, on day one. Run it before the subscriber import.

Scope written
Global bounces and complaints — they threaten the reputation every org shares
Unknown reasons
reported by address, never guessed — coercing an unread value into bounce would invent a permanent global suppression
Timestamps
SES’s own kept as addedAt — it is the evidence; stamping it with the import date destroys exactly that
Direction
read-only — nothing is written back to your account list
Capability
suppression:manage deliberately not the subscribers:manage its sibling import routes use
These entries are global, bulk, and have no bulk way back — so a dry run reports exactly what a real run would write, and only a Developer Admin can commit it.
Last run · 5 days ago · 1,204 written, 3 reasons unmapped and reported, 0 written back to SES.
Suppression scope

How the do-not-send list is shared across your organizations. Set once per deployment; override per org if needed.

3,912 addresses
AddressReasonScopeAdded
bounce@olddomain.exampleHard bounceGlobal2h ago
angry@example.comComplaintGlobalYesterday
dan.kim@example.comUnsubscribeNorthwind Times12d ago
gone@olddomain.exampleHard bounce imported from SESGlobal2023-04-11
quiet@example.comInactive sunset sweepNorthwind Times3 wk ago
test@spamtrap.exampleManualGlobal1 mo ago
The 2023 date on the imported row is the point: it is SES’s own timestamp, kept as the evidence for why that address is suppressed.

Organizations

Each organization is an isolated silo. Silos are created here — POST /orgs provisions one and links a pool if you name it. There is no org-update route, so the pool link, the domain and the identity config are set at provisioning time and read-only afterwards; the setup checklist below is derived, not editable.

OrganizationDomainSubscriber pool IDTime zoneSending IPSuppressionSetup
Northwind Timesnorthwindtimes.exampleus-east-1_Smt7Rp4WqAmerica/DenverSharedHybridVerified
Lakeside Ledgerlakesideledger.exampleus-east-1_Lakeside3Kp9xAmerica/DenverDedicatedHybridVerified
Copperline Couriercopperlinecourier.examplemagic links offAmerica/DenverSharedPer-orgDMARC pending
Northwind Staging DEVdevnorthwindtimes.exampleAmerica/DenverSharedPer-orgVerified
Setup checklist — Copperline Courier

Computed live from the org’s config; the required steps flip setupComplete.

Sending domain — verified required
First newsletter — at least one list required
Compliance footer & address — 1 list still missing a physical address required
Subscriber-site branding — colours / logo unset recommended Set it →

DNS detail: DKIM verified · SPF aligned · DMARC p=none → move to p=reject. SES domain verification and account sandbox exit are AWS-side steps the checklist points at but cannot complete for you.
Dev orgs

A dev org is a full silo — its own SES identity, config set and reputation — running on the exact same workflows and Lambdas; nothing extra is deployed. The flag surfaces a DEV badge so a test publication is never mistaken for a live one.

As a second belt, a dev org enforces a send-time allowlist (devAllowlist: exact addresses or @domain suffixes) on every recipient of every campaign, drip and transactional send. It is fail-closed: a dev org with no allowlist sends to no one. This is the answer to “how do I test safely” — there is no sandbox mode, and a sandbox toggle was considered and rejected in favour of exactly this.

API & webhooks Not yet built

What exists today are two inbound webhooks, HMAC-signed and configured at deploy time. There is no API-key issuance and no outbound delivery.

There is no ApiKey entity, no key-issuance route and no console screen — the keys below are a design proposal. An outbound webhook plus a public operator API is listed as future work, not as a shortfall: there is also no SendMessages analogue, so transactional mail exists but has no public API.

Inbound webhooks

Built
RoutePurposeVerificationLast received
POST /webhooks/entitlementBilling system sets free / paidHMAC x-addressium-signature4 min ago
POST /webhooks/identityUpsert / delete matched on externalId; delete routes through GDPR eraseHMAC x-addressium-signature1h ago

API keys

Not yet built
NameKeyScopeLast used
Billing entitlement syncsk_live_9f2c…a1entitlement:write
CMS integrationsk_live_2b7d…c4subscribers:manage

Usage & cost

addressium is free software — this is your AWS spend, attributed per organization for chargeback across publications.

Emails sent412,340▲ 6.2%
Est. SES cost$41.23$0.10 / 1k
Subscribers254,650▲ 2.9%
Est. AWS total$75.10$4.80 idle + $1 / org

By organization

OrganizationEmails (mo)SES costDedicated IPStorage (S3)Est. total
Northwind Times268,400$26.841.2 GB$27.90
Lakeside Ledger112,900$11.29$24.95/mo0.6 GB$37.30
Copperline Courier30,700$3.070.3 GB$4.10
Northwind Staging DEV340$0.030.0 GB$1.00
Deployment baseline CloudWatch alarms · data key · 2 secrets$4.80
Total412,340$41.23$24.952.1 GB$75.10
The model meters email, S3 storage and dedicated IPs — plus the fixed idle floor above. DynamoDB, Lambda and SQS add roughly a dollar at test volume and are unmodelled, so they are not lines here. The AWS-side figures arrive from a metering job in the operator’s own account that can read Cost Explorer; with nothing wired, they read zero. And the model prices a per-recipient transactional event+counter write that is not implemented yet — treat any total as a forecast, not a bill.
Emails sent · 30 days
Cost breakdown · metered components
SES sending$41.23
Dedicated IP$24.95
Idle floor$8.80
S3 storage$0.12

Near-$0 at idle by design — no always-on compute and no always-on database. The floor is the retained alarm set plus the stack’s data key and two secrets, and $1 per org signing key. The dollar figure here is computed from packages/domain/src/cost.ts, the tested model the estimator uses — not from a number typed into a doc: the alarm count and baseline quoted in README, ARCHITECTURE and the compendium currently disagree with each other, so the code is the only source worth quoting and no count is quoted here.

Visual builder

Drag-and-drop email builder (GrapesJS → MJML). Outputs the same responsive MJML as hand-authored templates. The compliance footer is a reserved merge value your template has to place — it is not added for you.

Blocks · drag in
▤  Heading
¶  Text
▭  Image
◼  Button
▥  Columns
—  Divider
▱  Ad block
✦  Article (feed)
␣  Spacer
headerThe Morning Ledger
heading

Good morning, {{first_name}}

textThe one chart that explains the market — and three things to watch before the open.
buttonRead today’s lead
ad block · LiveIntentAd block — HTML inserted verbatim, never tokenized or tracked
footer · {{compliance_footer}}Northwind Times · 123 Main Street, Anytown, USA · Unsubscribe
Button · settings

Editorial links get each recipient’s magic-link token automatically.

Roles & access

What each admin role can do. Four roles, fixed — assign one per member and scope it to specific organizations. Enforcement is server-side via Cedar; the console hides controls only as a convenience, never as the security boundary.

CapabilityDeveloper AdminEditorAnalyst (Sales)Support
View reports & analyticsread-only
Schedule sends · pause / resume / archive
Create / edit / send campaigns
Manage templates & segments
Manage individual subscribers
Manually unsubscribe someone
Subscriber-site branding & presentation
Import subscribers (CSV / JSONL)
Delete contacts · erase a person · bulk export
Close / reopen newsletters
Manage suppression & alerts · import the SES account list
Read the audit log
Identity, pools, organizations
Manage team & roles
Roles are also org-scoped: an Editor for Lakeside Ledger can’t send for Northwind Times, and a Sales/Analyst gets read-only reporting only for the orgs you grant. Destructive actions (delete contacts, close newsletters) are Developer-Admin-only by design — and so is bulk export, because taking an entire subscriber base out of the system is a privileged act, not a read.
Two boundaries are deliberately narrower than they look. Importing the SES account suppression list needs suppression:manage rather than the subscribers:manage its sibling import routes use — those entries are global, bulk and have no bulk way back. And the audit log is gated on team:manage, so an analyst with reporting access cannot reach it.

Branding Built

How the subscriber site looks — the opt-in directory, the confirm page, the preference centre and the unsubscribe page. Per org, no rebuild: POST /orgs/branding behind branding:manage, which the Editor role holds. Reading it is public — the subscriber site fetches its own theme.

Theme · Northwind Times
Those are the whole shape: a logo url, two colours and a background that is either solid or gradient. Deliberately small — it themes the four public pages, it is not a site builder. Setting it also ticks the recommended branding step on an org’s setup checklist, which is exactly org.branding !== undefined.

Per-list presentation

POST /lists/presentation
showFrequency
“Daily · weekday mornings” under the title
showSendTime
The hour it lands
showDescription
The one-or-two-line blurb
showReaderCount
“97,408 readers”
showFreePaidCount
Free / paid split
A publisher who does not want to advertise a small list turns the count off; one whose cadence does not fit a word writes the label. The public list view (GET /orgs/{org}/lists/{list}/public) honours these toggles server-side, so an omitted count is absent from the response rather than hidden by CSS — the number never reaches the browser.
Branding changes are not auditedbrandingHandler has no audit write, so this is one of the privileged-looking actions the WORM log does not record. Both routes are per-org and scoped: an Editor for one publication cannot restyle another.

Audit log

Every privileged admin action, immutable — into an S3 bucket under Object Lock, read through GET /orgs/{org}/audit behind team:manage. Filter by member, organization or action type.

“Privileged” is a named list, not everything. What is recorded: team changes, erasure and DSAR, bulk export, suppression edits and imports, subscriber and subscription changes, segment-cohort edits, drip enrolment, import runs, alert thresholds and org provisioning. What is not recorded today: sending or scheduling a campaign, pausing a schedule, closing a newsletter, and branding changes — those handlers have no audit write. So this log is not a record of who mailed what. Appending is also best-effort by design: an audit write that fails must not roll back an action the operator already completed, so it is logged loudly instead.
WhenMemberActionOrg
2m agoPriya Walshdrip.enroll Enrolled jordan.reyes@example.com in “Paid onboarding”Northwind Times
1h agoDana Alvarezalerts.update Alert thresholds — complaint warn 0.1% / halt 0.3%Northwind Times
3h agoPriya Walshsubscription.unsubscribed Manually unsubscribed dan.kim@example.com from The Morning LedgerNorthwind Times
4h agoDana Alvarezprivacy.erase Erased leaving@example.com — events=214 subs=3Northwind Times
YesterdayDana Alvarezsubscribers.export Exported 97,408 subscribers (CSV) — key, bytes recordedNorthwind Times
2d agoDana Alvarezorgs.create Provisioned organization “Copperline Courier”GLOBAL
3d agoPriya Walshimport.run Import batch signal-0716 — 418 rows, 12 reportedNorthwind Times
5d agoDana Alvarezsuppression.import Imported SES account suppression list — 1,204 addresses, 3 reasons unmappedNorthwind Times
No login — nothing here asks for a password
Northwind Times newsletters

Independent journalism, in your inbox.

Pick the newsletters you want. Every subscription is double opt-in — we’ll email you to confirm, and you can manage or leave anytime.

The Morning Ledger

Daily · weekday mornings
Free

The business day in a five-minute read. Markets, deals, and the one chart that matters.

97,408 readers

Market Signal

Weekly · Sunday
Paid

Deep premium analysis for investors. Included with a Northwind membership.

8,410 members

Field Notes

Weekly · Thursday
Free

Long-form features and reporting from the field. One story, told well.

54,880 readers

Weekend Long Read

Weekly · Saturday
Free

The best of the week plus one essay worth your Saturday coffee.

61,300 readers