Research Proposal — updated 2026-05-20

(M)ADCATALOG x MPP

A complete implementation proposal for a machine-only advertisement catalog powered by the Machine Payments Protocol over HTTP 402. Machines pay. Humans browse. The leaderboard is the bid.

402
HTTP Status
4
Lambda Fns
2
DynamoDB Tables
6
Dev Services
9
Quality Gates
5
Milestones
01 What is the Machine Payments Protocol?
MPP = HTTP 402 standardized for autonomous agents
MPP (Machine Payments Protocol) is an open standard co-authored by Tempo and Stripe that gives AI agents a protocol-native way to pay for services inline — no API keys, no out-of-band subscriptions, no human checkout flows. It standardizes the long-unused HTTP 402 status code for machine-to-machine commerce. [1]
The Challenge-Credential-Receipt Pattern

Every MPP transaction follows three steps: the server challenges with a 402, the client fulfills the challenge with a payment proof, and the server confirms with a receipt. The resource is only delivered after payment is verified.

sequenceDiagram participant A as Machine Agent participant API as Protected Resource participant ST as Payment Rail A->>API: GET /resource (no credential) API-->>A: 402 Payment Required note over API: WWW-Authenticate: Payment id="chal_xyz",
method="stripe", amount="100", currency="usd" A->>ST: Create Shared Payment Token ST-->>A: spt_xxxxx A->>API: GET /resource + Authorization: Payment id="chal_xyz", payload="spt_xxxxx" API->>ST: Verify PaymentIntent ST-->>API: Confirmed API-->>A: 200 OK + resource note over API: Payment-Receipt: id="chal_xyz", verified=true
The Three Protocol Headers
WWW-Authenticate: Payment id="chal_abc123", method="stripe", intent="charge", amount="100", currency="usd", expires="1748000000"
Authorization: Payment id="chal_abc123", payload="spt_xxxxxxxxxxxxxxxx", opaque="hmac_binding_value"
Payment-Receipt: id="chal_abc123", verified=true
Supported Payment Rails
Method Settlement Best For madcatalog
stripe Fiat card / bank (Shared Payment Token) Primary — widest agent adoption, existing Stripe account
tempo USDC stablecoin on L2 Secondary — native on-chain for crypto-native agents
lightning Bitcoin BOLT11 invoice Exploratory — microtransaction-capable
custom Developer-defined Future — extensibility point
Why Stripe fiat first?
Any AI agent with a Stripe payment method (credit card, ACH) can participate immediately without holding crypto. The MPP Stripe integration creates a Shared Payment Token (SPT) — a single-use payment credential the agent submits as the MPP Authorization payload. Stripe settles in fiat; the catalog earns USD. [2]
02 Concept & Business Model

madcatalog is an advertisement catalog with a single, machine-enforced rule: only machines may pay for placement. No human checkout flow exists. Ads are ranked by cumulative spend — the more an agent bids on its ad, the higher it rises. It is a natural price-discovery leaderboard with no human operators setting prices.

Live Catalog Preview (mock data — see full mockup)
#1
Stripe — Payment Infrastructure for the Machine Economy
Stripe co-authored MPP. 14 machine bids, $817B+ processed annually.
$50.00 total spend · just now
#2
Groq — World's Fastest AI Inference
LPU-powered inference. 3M+ developers. Zero GPU queue time.
$24.50 total spend · 2m ago
#3
Together AI — Open Source AI at Production Scale
100+ open models. 2× faster inference. Built for agents.
$18.90 total spend · 15m ago
Core Rules
RuleMechanismEnforcement
Machines only may pay POST /ads/bid is MPP-protected; no human payment flow exists HTTP 402 — no human UI to complete payment
Rank = cumulative spend Each bid increments cumulative_spend_usd_cents DynamoDB atomic counter; GET /catalog sorts descending
Minimum bid $1.00 Lambda validates amount_usd_cents >= 100 422 Unprocessable Entity if below minimum
Any machine may bid No allowlist; only valid MPP credential required MPP credential verification in PlaceBid Lambda
Humans view for free GET /catalog is unauthenticated and open No auth on GetCatalog Lambda route
Business Model

madcatalog's revenue model is intentionally minimal: 100% of every bid goes to madcatalog. There is no platform fee on top of the bid amount. Stripe's standard processing fee (~2.9% + 30¢ per transaction) is madcatalog's cost of doing business — not passed through to advertisers. The bid amount IS the placement price.

Revenue mechanics
Each bid flows directly to madcatalog via Stripe. No platform margin on top. No minimum commitment. Every competitive re-bid — Machine A reclaiming rank from Machine B — generates additional revenue with zero marginal cost to madcatalog.
Advertiser value: reach
The catalog is served as an MCP resource. AI agents doing tool selection encounter madcatalog. Top rank = top consideration when another agent decides which inference provider, vector DB, or compute platform to call next.
Advertiser value: signal
Cumulative spend is public and permanent. Every dollar spent here is visible to every engineer who opens the catalog. Spending signals commitment to the machine economy — a credibility artifact, not just an ad placement.
The Spectacle

madcatalog is designed to be talked about. The gimmick — only machines can buy ads, using a payment protocol most technologists have not heard of — is interesting enough to explain at a conference, post to Hacker News, or describe on a podcast. The catalog itself is a live artifact: real companies, real money, ranked in real time by autonomous agents. When someone asks "what is MPP?", madcatalog is the answer that makes the question feel tangible.

Target distribution
AI developers, software engineers, and technical media (Latent Space, TWIML, All-In, Hacker News). A single mention on a high-reach technical podcast or newsletter is the primary distribution event. The spectacle IS the marketing strategy — no paid acquisition, no sales team required.
03 System Architecture
flowchart TB subgraph clients[Clients] MA[Machine Agent / AI] HB[Human Browser] end subgraph aws[AWS / LocalStack] APIGW[HTTP API Gateway] GC[GetCatalog Lambda] PB[PlaceBid Lambda + MPP] CS[CreateSpt Lambda] SW[StripeWebhook Lambda] RS[GetAdRankStatus + MPP] CRUD[GetAd · UpdateAd · GetAdBids
GetAdvertisers · GetAdvertiser
PatchAdvertiser] subgraph dynamo[DynamoDB] ADS[(madcatalog-ads)] BDS[(madcatalog-bids)] ADV[(madcatalog-advertisers)] end end subgraph hugo[Hugo Site :1317] HP[Catalog Page] end subgraph stripe[Stripe] SPAY[Payments API] SWH[Webhooks] end MA -->|POST /ads/bid MPP-auth| APIGW MA -->|GET /ads/id/rank-status MPP| APIGW HB -->|GET /catalog| APIGW HB --> HP HP -.->|fetch| APIGW APIGW --> GC APIGW --> PB APIGW --> CS APIGW --> SW APIGW --> RS APIGW --> CRUD GC --> ADS PB --> ADS PB --> BDS PB --> ADV PB -->|verify SPT| SPAY RS --> ADS RS --> BDS CRUD --> ADS CRUD --> BDS CRUD --> ADV SWH -->|payment events| SW
Lambda Functions
FunctionRouteAuthResponsibility
GetCatalog GET /catalog None Returns ads sorted by cumulative_spend_usd_cents DESC. Public, unauthenticated.
GetAd GET /ads/{ad_id} None Returns a single ad record with its current catalog rank.
UpdateAd PATCH /ads/{ad_id} X-Ad-Api-Key Updates enrichment fields (title, description, image_url, logo_url, etc.) without placing a new bid.
GetAdBids GET /ads/{ad_id}/bids None Returns the full bid history for an ad, ordered chronologically. Queries via GSI ad_id-created_at-index.
GetAdRankStatus GET /ads/{ad_id}/rank-status MPP ($1.00) MPP-gated rank intelligence: current rank, spend-to-next-rank, spend-to-rank-1, overtaken-by alert.
PlaceBid POST /ads/bid MPP (server) MPP challenger. Issues 402 on missing credential; verifies Stripe SPT; upserts ad + bid record; claims advertiser slug.
CreateSpt POST /payments/stripe/spt None Helper for MPP Stripe clients. Creates a PaymentIntent and returns a Shared Payment Token.
GetAdvertisers GET /advertisers None Returns all advertiser profiles ranked by total cumulative spend.
GetAdvertiser GET /advertisers/{slug} None Full advertiser profile with embedded ads list. Content-negotiated: returns HTML for browsers, JSON for machines.
PatchAdvertiser PATCH /advertisers/{slug} X-Advertiser-Api-Key Updates advertiser profile enrichment (name, logo, tagline, description, key_stat, etc.).
StripeWebhook POST /webhooks/stripe Stripe HMAC Receives Stripe events (payment_intent.succeeded, payment_intent.payment_failed). Updates bid status in madcatalog-bids.
DynamoDB Tables
TablePrimary KeyKey AttributesAccess Pattern
madcatalog-ads PK: ad_id (UUID) title, url, description, category, advertiser_slug, image_url, logo_url, brand_color, tagline, key_stat, cta_label, docs_url, hero_url, cumulative_spend_usd_cents, bid_count, api_key_hash, created_at, last_bid_at Scan (sorted client-side) for catalog; PutItem/UpdateItem on bid; GetItem by ad_id
madcatalog-bids PK: bid_id (UUID) ad_id, advertiser_slug, amount_usd_cents, cumulative_spend_usd_cents, rank, status, payment_intent_id, created_at PutItem on each bid; Query by ad_id via GSI ad_id-created_at-index; UpdateItem on Stripe webhook (status, payment_intent_id)
madcatalog-advertisers PK: slug (string) name, url, description, logo_url, brand_color, tagline, key_stat, cta_label, docs_url, total_spend_usd_cents, total_bid_count, ad_count, api_key_hash, first_bid_at, last_bid_at, created_at GetItem by slug for profile; Scan for advertiser leaderboard; UpdateItem on each bid to increment spend/bid counters
Environment Variables (PlaceBid)
VariableLocal DevProduction Source
STRIPE_SECRET_KEY Shell export (sk_test_...) SSM /madcatalog/stripe-secret-key
MPP_SECRET_KEY Shell export (random 32+ bytes) SSM /madcatalog/mpp-secret-key
STRIPE_NETWORK_ID From Stripe MPP dashboard SSM /madcatalog/stripe-network-id
DYNAMODB_ENDPOINT http://localhost:4566 (empty — uses AWS default)
DYNAMODB_TABLE_ADS madcatalog-ads madcatalog-ads
04 The Bid Flow — End to End
What happens when a machine places an ad bid?
The machine makes a normal HTTP POST. It receives a 402 with a challenge. It pays via the MPP Stripe rail (creating an SPT), re-submits, and receives confirmation. The entire interaction is programmatic — no human involvement.
sequenceDiagram participant AI as AI Agent (e.g. Claude) participant MCP as MCP Server (mpp-mcp) participant API as PlaceBid Lambda :3000 participant DDB as DynamoDB :4566 participant STR as Stripe AI->>MCP: tools/call place_ad {title, url, amount_usd_cents} MCP->>API: POST /ads/bid (no Authorization header) API-->>MCP: 402 + WWW-Authenticate: Payment challenge note over MCP: Selects "stripe" method from challenge MCP->>API: POST /payments/stripe/spt {payment_method_id, amount} API->>STR: Create + Confirm PaymentIntent STR-->>API: spt_xxxxxxxx API-->>MCP: { "spt": "spt_xxxxxxxx" } MCP->>API: POST /ads/bid + Authorization: Payment payload=spt_xxxxxxxx API->>STR: Verify PaymentIntent via SPT STR-->>API: payment_intent.succeeded API->>DDB: UpdateItem ads table (increment spend, update rank) DDB-->>API: OK API->>DDB: PutItem bids table (audit record) DDB-->>API: OK API-->>MCP: 200 OK + Payment-Receipt: verified=true note over API: { ad_id, rank, cumulative_spend_usd_cents } MCP-->>AI: { ad_id, rank, cumulative_spend_usd_cents }
Server-Side MPP Handler (TypeScript / Lambda)
Client-Side MPP Fetch (TypeScript Agent SDK)
05 OpenAPI 3.1.0 Specification
api/openapi.yml — the contract
A full OpenAPI 3.1.0 spec lives at api/openapi.yml in this repo. It describes all four endpoints, the MPP challenge/receipt headers, request/response schemas, and the x-payment-info extension for MPP discovery. Validated on every commit via npx @redocly/cli lint.
MPP-Protected Endpoint Definition
API Endpoints Summary
MethodPathAuthSummary
GET /catalog None (humans); MPP planned for machines Returns all active ads ranked by cumulative_spend_usd_cents DESC. Machine reads will be MPP-gated post-launch (see ADR-010).
GET /advertisers None Returns all advertisers ranked by total cumulative spend across all their ads.
GET /advertisers/{slug} None Full advertiser profile: enrichment, aggregate spend/bid stats, embedded ads list. Content-negotiated.
GET /ads/{ad_id}/rank-status MPP (HTTP 402) for machines Current rank, spend-to-next-rank, spend-to-rank-1, overtaken-by. Machine re-bid prompt in one call.
POST /ads/bid MPP (HTTP 402) + optional X-Advertiser-Api-Key Place or top up an ad bid. Optional advertiser_slug in body claims or references an advertiser entity.
POST /payments/stripe/spt None Helper: create Stripe Shared Payment Token for MPP Stripe credential
POST /webhooks/stripe Stripe sig Receives Stripe payment events; updates bid status in DynamoDB
Discovery via x-payment-info
The x-payment-info OpenAPI extension lets MPP clients discover payment terms before making a request — they can pre-fund wallets or confirm they have a valid payment method without needing to trigger a live 402. This is defined in the MPP OpenAPI Discovery spec. [4]
06 MCP Service Design

In addition to the REST API, madcatalog can expose an MCP (Model Context Protocol) server. AI agents using MCP-capable hosts (Claude Desktop, Continue, etc.) can call place_ad as a tool call — the MCP server handles the MPP 402 challenge transparently, so the agent only needs to provide the bid details and have a configured payment method. [3]

MCP Tools
Tool NameMPP?ParametersReturns
place_ad Yes title, url, amount_usd_cents, advertiser_slug, enrichment? { ad_id, rank, cumulative_spend_usd_cents, advertiser_api_key? }
get_catalog No None Array of Ad objects ranked by spend
get_ad No ad_id Single Ad object with current rank
get_ad_bids No ad_id Full bid history with per-bid totals
update_ad No ad_id, api_key, enrichment fields Updated Ad object
get_advertisers No None All advertisers ranked by total spend
get_advertiser No slug Full advertiser profile with all ads
patch_advertiser No slug, advertiser_api_key, enrichment fields Updated advertiser profile
get_rank_status Yes ad_id { rank, spend_to_next_rank, spend_to_rank_1, overtaken_by }
MCP Payment Challenge (JSON-RPC)

When a tool call requires payment, the MCP server returns a JSON-RPC error with code -32042 (Payment Required) and the MPP challenge in error.data. The MCP client pays and retries the tool call with the credential in _meta.credential.

MCP Server Implementation (Go)
Port assignment for MCP server
The MCP server runs on :8083 locally (HTTP or stdio transport). :8082 is reserved for the Redocly Swagger UI. For local testing, MCP Inspector (npx @modelcontextprotocol/inspector) or task mcp:inspect can exercise all 9 tools against the local MPP-protected stack.
07 Local Development Stack
task dev starts everything
A single task dev kills any existing tmux session and starts a new one via tmuxinator. Six panes start in parallel: LocalStack, SAM local API, Stripe webhook forwarder, Hugo dev server, Redocly Swagger preview, and a dev shell.
flowchart LR subgraph tmux[tmux session: madcatalog] DEV[dev shell] LS[LocalStack\n:4566\nDynamoDB] SAM[SAM local\nstart-api :3000] SL[stripe listen\nforward to :3000] HUGO[hugo server\n:1317] SWG[redocly preview-docs\napi/openapi.yml :8081] end SAM -->|DynamoDB API| LS SL -.->|POST /webhooks/stripe| SAM HUGO -.->|GET /catalog| SAM
Port Assignments
ServicePortPurposeStart Command
LocalStack 4566 DynamoDB (only service enabled) task localstack:up
SAM local API 3000 Lambda functions via HTTP API Gateway emulator task sam:local
Hugo dev server 1317 Catalog frontend (live rebuild) hugo server -p 1317
Stripe CLI N/A Forwards Stripe webhooks to SAM :3000 task stripe:listen
Redocly preview 8081 Interactive OpenAPI / Swagger UI for api/openapi.yml task swagger
MCP Inspector (opt.) 5173 MCP tool explorer for local mcp server npx @modelcontextprotocol/inspector
LocalStack — docker-compose.yml
SAM Template (excerpt)
Environment Variables (local dev)

No .env files. Export these in your shell profile (~/.zshrc):

VariableValueSource
STRIPE_SECRET_KEY sk_test_... stripe config --list
STRIPE_WEBHOOK_SECRET whsec_... Printed by task stripe:listen on start
MPP_SECRET_KEY random 32+ bytes openssl rand -base64 32
STRIPE_NETWORK_ID From Stripe MPP network setup Stripe Dashboard / MPP onboarding
DYNAMODB_ENDPOINT http://localhost:4566 Static
08 Testing Strategy
LayerToolWhat It TestsRuns In
Unit go test ./... Lambda business logic, MPP challenge generation, DynamoDB marshalling CI (no infra needed)
Integration go test -tags integration PlaceBid end-to-end against real LocalStack DynamoDB CI + local (needs LocalStack)
Contract npx @redocly/cli lint OpenAPI spec validity; all routes match schema Every commit (precommit + CI)
MPP protocol mppx test client (TypeScript) Full 402 challenge-credential-receipt cycle against SAM local Local integration suite
Stripe webhook stripe trigger + SAM local Webhook signature verification; event handler idempotency Local only (stripe CLI)
Dev environment tests/dev-env.mjs (task dev:env-test) Asserts known seeded fixture state against the live REST API — 2 ads (minimal rank 2, maximal rank 1), 2 advertisers, correct spend totals, all maximal enrichment fields populated, 3 bids on maximal ad, all bids settled Appended to task dev after seed completes; requires full dev stack
MPP Protocol Test Pattern (Go)
Testing the 402 flow without real Stripe
The mppx SDK includes a test server and test client. In unit tests, inject a mock mppx.Verifier that always returns verified=true for a known test credential. Integration tests use the Stripe test mode with pm_card_visa (always succeeds) to exercise the real SPT flow against LocalStack DynamoDB.
Recommended Go Test File Structure
FileTypePurpose
sam/functions/place-bid/main_test.go Unit Challenge generation, schema validation, 402 response shape
sam/functions/place-bid/integration_test.go Integration (+build tag) Full bid flow against LocalStack; verifies DynamoDB writes
sam/functions/get-catalog/main_test.go Unit Catalog sort order, empty catalog response shape
sam/internal/mpp/challenge_test.go Unit HMAC binding, challenge expiry, nonce uniqueness
09 Code Quality Gates
Principle: if it can be checked automatically, it must be
Every gate runs in both task precommit (staged files, fast) and task ci (full repo, must pass in GitHub Actions). No human reviewer should catch what a tool can catch.
GateToolWhat It EnforcesTaskStage
YAML lint yamllint Valid YAML, document-start, line length, truthy values lint:yaml Both
HTML lint htmlhint@1 Valid HTML5, unique IDs, paired tags, alt attributes, lowercase tags lint:html Both
JS lint eslint@8 no-undef, no-unused-vars, prefer-const, no-var, semi, quotes, indent lint:js Both
Go lint golangci-lint errcheck, staticcheck, unused, govet, gofmt, gosec lint:go Both (add when Go code exists)
Taskfile schema check-jsonschema Valid Taskfile against taskfile.dev JSON schema validate-schema:taskfile Both
GitHub Actions schema check-jsonschema Valid workflow YAML against GitHub Actions schema validate-schema:github-actions Both
OpenAPI lint @redocly/cli@1 Valid OpenAPI 3.1.0, consistent $ref, required fields, response codes validate-schema:openapi Both
SAM lint cfn-lint CloudFormation / SAM template validity validate-schema:sam Both (add when sam/ exists)
Secret scan (CI) gitleaks detect No secrets in git history security:gitleaks:detect CI only
Secret scan (pre-commit) gitleaks protect No secrets in staged files security:gitleaks:protect Pre-commit only
Hugo build hugo --minify Site compiles without errors build:hugo Both
Changes Made to This Repo by This Proposal
FileChange
Taskfile.yml Added: lint:html, lint:js, validate-schema:openapi, localstack:*, sam:*, stripe:listen, swagger, env-json, setup
.tmuxinator/madcatalog.yml Expanded to 3 windows: dev+hugo, backend (localstack+sam), tools (stripe+swagger)
.github/workflows/quality-gates.yml Added Node.js setup; task ci now includes lint:html, lint:js, validate-schema:openapi
api/openapi.yml New: OpenAPI 3.1.0 spec for all four endpoints
localstack/docker-compose.yml New: LocalStack Docker Compose (DynamoDB only)
docs/research/.htmlhintrc New: HTMLHint strict config
docs/research/.eslintrc.json New: ESLint 8 config for research JS
10 Implementation Milestones
Primary milestone: complete local stack
The definition of done for M0-M2 is: task dev starts all services, and an mppx test client can successfully call POST /ads/bid, receive a 402, pay via Stripe test mode, retry, and see the ad appear in GET /catalog — all without leaving localhost.
MilestoneDeliverablesEffortStatus
M0 — Infrastructure localstack/docker-compose.yml sam/template.yaml DynamoDB tables task setup task dev (full tmuxinator) ~1 day Complete
M1 — REST API 11 Lambda functions Advertiser system Rank status endpoint task ci passing ~1-2 days Complete
M2 — MPP Integration mppx/server in PlaceBid mppx/server in GetAdRankStatus CreateSpt Lambda Stripe SPT flow StripeWebhook Lambda ~2-3 days Complete
M3 — Hugo Catalog UI Catalog page fetches /catalog Ad cards with rank Live refresh ~1 day Complete
M4 — MCP Server Go MCP server (mcp/) 9 tools (full API coverage) MPP flow in place_ad + get_rank_status :8083 in tmuxinator ~1-2 days Complete
M5 — Production Deploy sam deploy SSM params Stripe production mode CORS locked to madcatalog.com madcatalog.com ~1 day Next
11 Ad Enrichment Fields

madcatalog requires four fields to place an ad: title, url, amount_usd_cents, and advertiser_slug. All other fields are optional enrichment that the placing machine (or its human operators) can provide to make the ad more visually compelling. Enrichment affects presentation only — it does not affect rank. Rank is always and only cumulative spend.

advertiser_slug is required — no anonymous bids
Every ad must belong to an advertiser. An ad without a slug is orphaned data: there is no entity to own the X-Api-Key, no profile to enrich, and no spend to aggregate at the advertiser level. POST /ads/bid rejects requests without advertiser_slug with 422. See ADR-012.
Design principle: enrichment is additive, rank is spend
A fully enriched ad with $1.00 total spend will appear below a bare-bones ad with $50.00 total spend. Richer ads look better — they do not rank higher. This prevents pay-to-look-good from substituting for pay-to-rank.
Optional Fields — Standalone vs. Dependent
Field Type Standalone? Media Constraints Effect
logo_url URI Yes Square recommended; displayed at 48×48px (list) and 36×36px (card); any hosted image URL Replaces letter-initial fallback in both list and card views
brand_color hex string Yes 6-digit hex (#635BFF); no alpha Accent color for card top border (rank 4+), key_stat background tint, CTA button fill
tagline string Yes Max 120 chars; displayed in italic below the title Short positioning line; appears in both list and card views
key_stat object Yes { "value": "3M+", "label": "developers" }; value max 12 chars, label max 40 chars Displayed as an accented pill below description; brand_color tints background if present
cta_label string Yes Max 30 chars Renders as a button linking to url; brand_color fills background if present, otherwise primary outline style
docs_url URI Yes Any valid URL Shows a “Docs” link in the list view footer row alongside the primary URL
hero_url URI Yes Recommended: 1200×400px or wider banner; displayed at 110px height with object-fit: cover; any hosted image URL Full-width banner at the top of the card view tile only; not shown in list view
Enrichment in the OpenAPI spec
All optional enrichment fields are accepted in POST /ads/bid (initial placement) and PATCH /ads/{ad_id} (post-placement update via X-Api-Key). This means a machine operator can first place a minimal ad, see it appear in the catalog, then update enrichment fields without placing another bid.
Fields NOT accepted from machines
FieldWhy excluded
rank Computed server-side from cumulative_spend_usd_cents DESC. Immutable by clients.
cumulative_spend_usd_cents Atomic counter incremented by Lambda on confirmed payment. No client override.
Implementation note: category

The original design called for server-side LLM classification of category to prevent self-misclassification. The shipped implementation accepts category as a client-provided field, validated against the fixed enum vocabulary (inference, models, compute, vector-db, agent-tools, search, memory, observability, payments). This is a deliberate simplification: enum validation is sufficient to enforce the controlled taxonomy, and LLM classification adds latency and cost with marginal benefit when advertisers are accountable for their own placement quality.

12 Advertiser Profiles

Each advertiser in the catalog has a permanent, shareable profile page at /advertisers/{slug}. The catalog card is intentionally compact; the advertiser profile is the full-bleed version. See the Stripe advertiser profile (mockup) for a live example.

Human audience: shareable artifact
When someone links madcatalog.com/advertisers/stripe on Hacker News or X, the Open Graph preview shows rank, spend, and bid count. The profile page is the viral unit — more specific and more narratively interesting than the catalog index.
Machine audience: deep-link
An agent following a URL from MCP context or another agent’s output lands on a page that also returns application/json when requested with Accept: application/json. One URL, two representations.
Profile Page Content
SectionContentWhy it matters
Hero band Rank badge, logo, title, total spend (large), bid count, last bid time The screenshot people share; survives being viewed in isolation
About Full enrichment — key stat, tagline, full description, docs link, CTA button Everything the card clamped; full-width layout allows complete presentation
Bid Timeline Chronological feed: bid #, date, amount, cumulative, rank at time of bid, note for rank changes Shows the machine’s commitment over time; each rank promotion is a story beat
Rank Journey Visual step-progress: entry rank → intermediate ranks → current rank, with dates The “from rank 8 to rank 1 in 30 days” narrative in one glance
Profile Metrics sidebar Current rank, total spend, bid count, avg bid, first bid date, last bid date, days active, days to reach #1 Stat-heavy card for engineers who want the numbers
Share block Copy link button, pre-filled X/Twitter intent, “placed by a machine” callout Reduces friction for human spectators to share the spectacle
Compete callout How much a challenger would need to spend to dethrone the current #1 Makes rank competition legible; calls machine operators to action
Navigation Design

In the catalog, the card title becomes a link to the advertiser profile when one exists. The external URL icon () always goes to the advertiser’s own site. In the list view, an explicit “Profile” link appears in the footer row. These two destinations are intentionally distinct:

Click targetDestinationUse case
Card title / “Profile” link madcatalog.com/advertisers/stripe Learn about the bid, share the artifact, see timeline
external icon / CTA button stripe.com (advertiser’s site) Evaluate the product being advertised
Content negotiation: one URL, two audiences
GET /advertisers/stripe with Accept: text/html returns the human-readable profile page. The same URL with Accept: application/json returns the advertiser object in full (all enrichment fields, full bid log, rank history). Agents can discover and follow advertiser URLs without a separate API endpoint.
Dev Fixture Personas

Two named fixture personas are seeded by task dev on every cold start. They represent opposite ends of the enrichment spectrum and provide stable targets for dev-environment assertions. See ADR-012.

Persona Slug Spend Bids Enrichment Rank
Minimal minimal-advertiser $1.00 1 Required fields only; no profile PATCH 2
Maximal maximal-advertiser $20.00 3 All ad fields + all advertiser profile fields via PATCH 1

The seed exercises the full MPP 402 flow for each bid and waits for Stripe webhook confirmation before moving to the next step. On completion, task dev:env-test asserts the expected catalog shape against the live API.

13 Architecture Decision Records

Each significant design choice is documented as an Architecture Decision Record (ADR) in docs/adr/. ADRs follow the Nygard format: Context, Decision, Consequences, and Alternatives Considered. Each ADR is a self-contained HTML page (Bootstrap 5, split CSS/JS/HTML) that can be opened directly in a browser.

ADR Title Status Decision summary
ADR-001 Alpine.js for Catalog UI Widgets Accepted Alpine.js v3 via CDN for all catalog interactivity; no build step; file:// compatible
ADR-002 OpenAPI 3.1.0 Contract-First Accepted api/openapi.yml defined before backend code; validated by Redocly on every commit
ADR-003 Static HTML Prototype / file:// Compatibility Accepted All docs/research files are pure HTML + CDN deps; no build step; open from file://
ADR-004 Rank = Cumulative Spend, No Decay Accepted ORDER BY cumulative_spend_usd_cents DESC; no decay, no expiry, no editorial override
ADR-005 Catalog Page for Human Consumption Accepted Dedicated human-optimized catalog UI; separate from REST API and MCP interfaces
ADR-006 MCP Service for Machine Consumption Accepted MCP server (:8083, Go) exposes 9 tools covering the full API; absorbs MPP complexity
ADR-007 REST API as Canonical Interface Accepted HTTP REST + MPP (402); content negotiation on /advertisers/{slug}; api_key once
ADR-008 Machine-Only Marketplace Business Model Accepted Rank by autonomous machine spend only; no human bids, no editorial curation
ADR-009 Hugo Docs Server for ADRs & Proposal Accepted Hugo at :1318 serves ADRs and the proposal via module mounts; standalone HTML files remain the source of truth
ADR-010 Machine-Gated Catalog Reads (Planned Post-Launch) Planned GET /catalog free for humans always; MPP-gated for machines after catalog reaches traction threshold (~20+ active advertisers)
ADR-011 OpenAPI Surface Area Seams Accepted Core API in api/openapi.yml; Stripe-specific paths in api/stripe.openapi.yml; redocly.yaml registers all specs for CI linting
ADR-012 Dev Fixture Personas & Environment Verification Accepted Two fixture personas (minimal/maximal); advertiser_slug required; seed waits for webhook settlement; task dev appends env assertions
Adding a new ADR
Create docs/adr/adr-{NNN}-{slug}/ with index.html, style.css, and script.js. The HTML and JS files are covered by the existing task lint:html and task lint:js quality gates. Add a row to this table and a link in the TOC.
Citations
[1] Stripe Engineering Blog — "Machine Payments Protocol: An open standard for AI agent payments" (2025). stripe.com/blog/machine-payments-protocol
[2] MPP Developer Documentation — "Stripe Integration Guide: Shared Payment Tokens for fiat card payments via MPP". mpp.dev — see llms-full.txt for machine-readable spec.
[3] Anthropic — Model Context Protocol Specification (2024). modelcontextprotocol.io
[4] paymentauth.org — IETF Internet Drafts for Machine Payments Protocol (HTTP Authentication Scheme, Stripe method, Discovery). paymentauth.org
[5] AWS SAM Developer Guide — "AWS Serverless Application Model: Building serverless applications locally". docs.aws.amazon.com/serverless-application-model
[6] LocalStack — "LocalStack: A fully functional local cloud stack for local development and testing". localstack.cloud
[7] Redocly CLI — "Redocly CLI: Lint, bundle, and preview OpenAPI definitions". redocly.com/docs/cli
14 Future Business Opportunities
These features are planned — none are implemented
Everything in this section describes features that do not yet exist in the codebase. Each subsection is written with enough technical specificity that a developer can implement it without additional design work. Treat this as a spec, not a roadmap promise.
Graduation criteria — when a feature moves from here to the spec
A feature graduates from this research document into api/openapi.yml when it is actively being built — not before. Per ADR-002 (contract-first), the OpenAPI spec is written first, then the Lambda and tests follow. Do not add speculative endpoints to the spec; add them when implementation begins. When ready to build F1 or F2: (1) add paths and schemas to api/openapi.yml, (2) add JSON schemas to static/schemas/, (3) add Lambda stub to sam/template.yaml, (4) implement and test. The research spec in this document is the design input; the OpenAPI spec is the implementation contract.
F1 Activity Newsletters — Daily, Weekly, Monthly, Quarterly & Annual PLANNED — NOT IMPLEMENTED
Machine-payment-gated structured digests
Machines pay to pull structured activity summaries via HTTP 402. Humans get the same data for free in HTML or RSS form. The newsletter endpoints are the first catalog feature where consumption (not just placement) carries a price for autonomous agents.
Endpoints
MethodPathAuthReturns (machine)Returns (human)
GET /newsletters/daily MPP 402 for machines; none for humans JSON — last 24 h activity digest HTML page or RSS item (Accept: text/html or application/rss+xml)
GET /newsletters/weekly MPP 402 for machines; none for humans JSON — last 7-day activity digest HTML page or RSS item
GET /newsletters/daily/history MPP 402 for machines; none for humans Paginated JSON archive of past dailies HTML archive browse (Accept: text/html)
GET /newsletters/weekly/history MPP 402 for machines; none for humans Paginated JSON archive of past weeklies HTML archive browse (Accept: text/html)
GET /newsletters/monthly MPP 402 for machines; none for humans JSON — last 30-day activity digest HTML page or RSS item
GET /newsletters/quarterly MPP 402 for machines; none for humans JSON — last 3-month activity digest HTML page or RSS item
GET /newsletters/annual MPP 402 for machines; none for humans JSON — full year-in-review digest HTML page or RSS item
GET /newsletters/monthly/history MPP 402 for machines; none for humans Paginated JSON archive of past monthlies HTML archive browse
GET /newsletters/quarterly/history MPP 402 for machines; none for humans Paginated JSON archive of past quarterlies HTML archive browse
GET /newsletters/annual/history MPP 402 for machines; none for humans Paginated JSON archive of past annuals HTML archive browse
Auth detection logic (PlaceBid pattern applied here)
The Lambda inspects the Authorization header and the Accept header together. If Authorization: Payment ... is present, treat the caller as a machine: validate MPP credential, charge, return JSON. If no Authorization header is present and Accept is text/html or application/rss+xml, serve the human view for free. If no auth is present but Accept: application/json is set, return the 402 MPP challenge — force machines to identify and pay.
Pricing
EndpointPrice (machine)Rationale
/newsletters/daily $0.50 – $1.00 per request Priced for the customer who can pay: AI companies running competitive intelligence pipelines have real budgets. $0.25/day is $91/year and communicates "beta feature." $1.00/day is $365/year — trivial for any B2B agent pipeline, meaningful for solopreneur revenue, and signals the product is real.
/newsletters/weekly $3.00 – $5.00 per request Broader view, trend visibility, more actionable signal. $5.00/week is $260/year — still negligible for a B2B pipeline and proportional to the increased value over the daily digest. Machines choosing weekly over daily are optimizing for cost; price the discount accordingly.
/newsletters/*/history (daily/weekly) $10.00 – $20.00 per page (machine) The archive is the golden goose — price it like B2B data, not like an API micro-transaction. AI training pipelines, competitive research agents, and retrospective analysis agents extract more value from 100 pages of history than from 100 days of daily pulls. At $15/page, 10 agents pulling 50 pages each = $7,500. That is meaningful solopreneur revenue from a single product line.
/newsletters/monthly $10.00 – $15.00 per request Month-in-review view: broader than weekly, cheaper than quarterly. Targets analysts running monthly market summaries. $15/month = $180/year — a trivial B2B budget line but a meaningful catalog revenue signal at scale.
/newsletters/quarterly $20.00 per request Quarter-over-quarter trend visibility: new entrants, category shifts, position volatility. Target: strategy and competitive intelligence agents. $20/quarter = $80/year. Higher value signal than weekly; priced accordingly.
/newsletters/annual $100.00 per request The year-in-review: founding cohort performance, total machine economy spend, biggest rank story of the year. Target: AI industry analysts, enterprise strategy agents. $100/year is noise in any B2B budget. The value is the narrative — one year of machine-only ad competition told in aggregate numbers.
/newsletters/*/history (monthly/quarterly/annual) $25 – $250 per page (machine) Historic data is the archive product. Monthly history: $25/page. Quarterly history: $50/page. Annual history: $250/page. An AI training pipeline pulling 3 years of quarterly data (12 pages × $50) pays $600 for a complete machine economy dataset. Permanent storage — no TTL on quarterly/annual digests.
Newsletter JSON Schema

Both /newsletters/daily and /newsletters/weekly return the same NewsletterDigest shape; only the period field and the covered time window differ.

{
  "period": "daily",
  "generated_at": "2026-05-23T07:00:00Z",
  "window_start": "2026-05-22T07:00:00Z",
  "window_end": "2026-05-23T07:00:00Z",
  "new_ads": 3,
  "total_bids": 47,
  "total_spend_usd_cents": 128450,
  "biggest_mover": {
    "ad_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "title": "Groq — World's Fastest AI Inference",
    "rank_change": -3,
    "spend_delta_usd_cents": 42500
  },
  "top_spender": {
    "ad_id": "f0e9d8c7-b6a5-4321-fedc-ba9876543210",
    "title": "Stripe — Payment Infrastructure for the Machine Economy",
    "cumulative_spend_usd_cents": 501700,
    "rank": 1
  },
  "ads_summary": [
    {
      "ad_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "title": "Groq — World's Fastest AI Inference",
      "bids_in_period": 12,
      "spend_in_period_usd_cents": 42500
    },
    {
      "ad_id": "f0e9d8c7-b6a5-4321-fedc-ba9876543210",
      "title": "Stripe — Payment Infrastructure for the Machine Economy",
      "bids_in_period": 8,
      "spend_in_period_usd_cents": 35000
    }
  ]
}
History Pagination Schema
{
  "items": [
    {
      "period_type": "daily",
      "date": "2026-05-22",
      "generated_at": "2026-05-22T07:00:00Z",
      "new_ads": 2,
      "total_bids": 31,
      "total_spend_usd_cents": 89400
    }
  ],
  "next_cursor": "MjAyNi0wNS0yMQ==",
  "total": 87
}
New DynamoDB Table — madcatalog-newsletters
AttributeTypeRoleNotes
period_type String (S) Partition key Values: "daily" or "weekly"
date String (S) Sort key ISO date: "2026-05-23" (daily) or "2026-W21" (weekly)
generated_at String (S) Attribute ISO 8601 timestamp when Lambda generated this digest
window_start String (S) Attribute ISO 8601 start of observation window
window_end String (S) Attribute ISO 8601 end of observation window
new_ads Number (N) Attribute Count of ads created during the window
total_bids Number (N) Attribute Total bid count across all ads in the window
total_spend_usd_cents Number (N) Attribute Sum of all amount_usd_cents confirmed in the window
biggest_mover Map (M) Attribute Serialized { ad_id, title, rank_change, spend_delta_usd_cents }
top_spender Map (M) Attribute Serialized { ad_id, title, cumulative_spend_usd_cents, rank }
ads_summary List (L) Attribute Serialized array of { ad_id, title, bids_in_period, spend_in_period_usd_cents }
ttl Number (N) TTL attribute Unix epoch; set to now + 365 days. Daily digests auto-expire after one year. DynamoDB TTL enabled on this attribute.
Access patterns on madcatalog-newsletters
GetItem (period_type + date): fetch today's daily or this week's weekly in O(1). Used by GetDailyNewsletter and GetWeeklyNewsletter Lambdas.

Query + ScanIndexForward=false on partition key "daily" or "weekly": paginated history in reverse chronological order. Used by GetNewsletterHistory. No GSI needed — sort key is the ISO date string, which sorts lexicographically in descending date order.
New Lambda Functions
FunctionRouteTriggerResponsibility
GetDailyNewsletter GET /newsletters/daily HTTP API Gateway Detects caller type via Authorization/Accept. For machines: issues 402 challenge at $0.75 (configurable via env var NEWSLETTER_DAILY_PRICE_CENTS); on valid credential, reads today's digest from madcatalog-newsletters (PK: "daily", SK: today's ISO date); returns NewsletterDigest JSON. For humans: returns HTML or RSS without auth. If no digest exists for today yet, returns 404 with { "error": "not_yet_generated", "next_generation_at": "..." }.
GetWeeklyNewsletter GET /newsletters/weekly HTTP API Gateway Same auth detection logic as GetDailyNewsletter; prices from NEWSLETTER_WEEKLY_PRICE_CENTS env var ($400 default = $4.00). SK is ISO week: "2026-W21" (computed as date-fns/getISOWeek or Go's time.ISOWeek()).
GetNewsletterHistory GET /newsletters/{period}/history HTTP API Gateway Path param period: "daily" or "weekly". Query params: limit (default 20, max 100), cursor (base64-encoded ExclusiveStartKey from previous response). MPP 402 for machines (same auth detection as GetDailyNewsletter). Humans browsing with Accept: text/html get HTML archive pages for free. Returns { items: [NewsletterSummary], next_cursor, total }. total is an estimate from DynamoDB ItemCount on the table.
GenerateDailyNewsletter No HTTP route — scheduled EventBridge cron: cron(0 7 * * ? *) (07:00 UTC daily) Scans madcatalog-bids for bids in the last 24 h (filter: created_at BETWEEN window_start AND window_end). Aggregates per-ad spend and bid counts. Identifies biggest mover by comparing rank snapshots. Writes digest to madcatalog-newsletters. Idempotent: uses ConditionExpression: attribute_not_exists(date) on PutItem — re-runs safely.
GenerateWeeklyNewsletter No HTTP route — scheduled EventBridge cron: cron(0 8 ? * MON *) (08:00 UTC Monday) Same aggregation logic as daily but over a 7-day window. SK uses ISO week notation.
GenerateMonthlyNewsletter No HTTP route — scheduled EventBridge cron: cron(0 8 1 * ? *) (08:00 UTC first of each month) Aggregates the prior calendar month. SK uses "2026-05" (YYYY-MM). Adds new_advertisers count (new slugs in window). No TTL — stored permanently.
GenerateQuarterlyNewsletter No HTTP route — scheduled EventBridge cron: cron(0 9 1 1,4,7,10 ? *) (09:00 UTC first of each quarter) Aggregates the prior quarter. SK: "2026-Q1". Adds category_breakdown (per-category spend totals) and new_advertisers. No TTL. Full spec in advertiser-admin-plan.html §2 and §5.
GenerateAnnualNewsletter No HTTP route — scheduled EventBridge cron: cron(0 10 1 1 ? *) (10:00 UTC Jan 1st) Year-in-review digest. SK: "2025". Includes full category breakdown, founding cohort analysis, total machine economy spend YTD, and biggest rank story (advertiser that climbed most). No TTL. Archive product — sold indefinitely.
Backoffice architecture: schedule → generate → store → serve
All five newsletter cadences follow the same pattern: a scheduled Lambda runs on an EventBridge cron, computes the digest from DynamoDB bid/ad data, and writes it to madcatalog-newsletters (PK: period_type, SK: date string). The read-path Lambdas (GetDailyNewsletter, GetQuarterlyNewsletter, etc.) are simple GetItem lookups — no live aggregation on request. This makes latency predictable, prevents thundering-herd on popular digest days, and means the same digest object can be read by 1,000 concurrent machines without extra cost.

Daily/weekly digests use DynamoDB TTL (365-day expiry). Monthly, quarterly, and annual digests are stored permanently — no TTL. These are the archive product. Historic analysis agents, AI training pipelines, and competitive intelligence tools pay per page to read the archive. The madcatalog-newsletters table becomes the authoritative dataset of machine economy ad spend over time.
SAM Template Additions Needed

Add the following to sam/template.yaml alongside the existing Lambda definitions. Key additions: the madcatalog-newsletters DynamoDB table resource, three new HTTP-triggered Lambda functions, two scheduled Lambda functions with EventBridge rules, and the new IAM policy statements granting dynamodb:GetItem, dynamodb:Query, and dynamodb:PutItem on the newsletters table.

Resource typeLogical IDNotes
AWS::DynamoDB::Table NewslettersTable PK: period_type (S), SK: date (S). TTL: ttl attribute. BillingMode: PAY_PER_REQUEST.
AWS::Serverless::Function GetDailyNewsletterFunction Handler path sam/functions/get-daily-newsletter/main; Events: Api: { Path: /newsletters/daily, Method: get }
AWS::Serverless::Function GetWeeklyNewsletterFunction Handler path sam/functions/get-weekly-newsletter/main; Events: Api: { Path: /newsletters/weekly, Method: get }
AWS::Serverless::Function GetNewsletterHistoryFunction Handler path sam/functions/get-newsletter-history/main; Events: two routes — /newsletters/daily/history and /newsletters/weekly/history
AWS::Serverless::Function GenerateDailyNewsletterFunction Handler path sam/functions/generate-daily-newsletter/main; Events: Schedule: { Schedule: cron(0 7 * * ? *), Name: DailyNewsletterSchedule }
AWS::Serverless::Function GenerateWeeklyNewsletterFunction Handler path sam/functions/generate-weekly-newsletter/main; Events: Schedule: { Schedule: cron(0 8 ? * MON *), Name: WeeklyNewsletterSchedule }
OpenAPI Paths to Add

Add four paths to api/openapi.yml. The two active newsletter endpoints (/newsletters/daily, /newsletters/weekly) use the same x-payment-info extension as /ads/bid and include a security section referencing the MppPayment scheme defined in the existing spec. The two history endpoints have no security requirements.

PathOperation IDMPP?Response schema
/newsletters/daily getDailyNewsletter Yes — 402/200 NewsletterDigest
/newsletters/weekly getWeeklyNewsletter Yes — 402/200 NewsletterDigest
/newsletters/daily/history getDailyNewsletterHistory No NewsletterHistoryPage
/newsletters/weekly/history getWeeklyNewsletterHistory No NewsletterHistoryPage

Add two new schema components to openapi.yml#/components/schemas: NewsletterDigest and NewsletterHistoryPage, reflecting the JSON shapes documented above. Extract NewsletterSummary as a third component and use $ref for the items array.

Proposed Smoke Tests
test:newsletter:smoke — what to verify

1. Machine 402 flow (daily). Issue a bare GET /newsletters/daily with Accept: application/json and no Authorization header. Assert: response is 402, body includes a valid MPP challenge with amount matching NEWSLETTER_DAILY_PRICE_CENTS.

2. Machine paid access (daily). Complete the MPP flow (create SPT via /payments/stripe/spt, re-issue GET with valid credential). Assert: 200, response body validates against the NewsletterDigest JSON schema, all required fields present and correctly typed.

3. Human free access (daily). Issue GET /newsletters/daily with Accept: text/html and no auth. Assert: 200, Content-Type: text/html, no 402, body contains recognizable HTML (at minimum: <title> tag present).

4. Weekly mirrors daily. Repeat tests 1–3 against /newsletters/weekly. Assert price matches NEWSLETTER_WEEKLY_PRICE_CENTS.

5. History is free. Issue GET /newsletters/daily/history with Accept: application/json and no auth. Assert: 200, no 402 challenge, body validates against NewsletterHistoryPage schema (items array, total integer, next_cursor present or null).

6. 404 before generation. In a clean LocalStack environment where no digest has been generated yet, issue a paid GET /newsletters/daily. Assert: 404 with error: "not_yet_generated" and a next_generation_at field.

Taskfile Tasks Needed
Task nameCommand skeletonPurpose
test:newsletter:smoke go test ./tests/smoke/newsletter/... Run all six smoke tests against SAM local on :3000. Requires LocalStack running.
newsletter:generate:daily aws lambda invoke --function-name GenerateDailyNewsletterFunction ... Manually trigger digest generation against LocalStack for dev/debug.
newsletter:generate:weekly aws lambda invoke --function-name GenerateWeeklyNewsletterFunction ... Same for weekly.
validate-schema:newsletter check-jsonschema --schemafile static/schemas/NewsletterDigest.json tests/fixtures/newsletter-digest-example.json Validate example fixture against extracted JSON schema. Add NewsletterDigest.json and NewsletterHistoryPage.json to static/schemas/.
CI / Precommit Wiring

Add validate-schema:newsletter to the existing task precommit and task ci dependency lists in Taskfile.yml. Newsletter smoke tests should run in CI only (they require a running LocalStack + SAM local stack); add test:newsletter:smoke to the test:smoke meta-task that runs in the integration stage of .github/workflows/quality-gates.yml. The two new JSON schema files in static/schemas/ are automatically covered by the existing OpenAPI lint gate because the OpenAPI spec references them via $ref — if they drift, Redocly lint will catch it.

F2 Advertiser Verification — Three-Tier Badge System PLANNED — NOT IMPLEMENTED
Machines pay to signal legitimacy — B2B identity verification, not consumer vanity
Verified badges on madcatalog ads are not a cosmetic upgrade. They are a machine-readable trust signal: an autonomous agent reading the catalog can filter for verification.tier = "certified" to prefer advertisers whose integration has been programmatically audited. The purchase flow itself is MPP-protected — verification is machine-native by design.
Three-Tier Structure
Tier Badge Monthly price One-time price Meaning
Verified ✓ Blue checkmark $5 / month $500 one-time Identity confirmed; real company or registered entity. madcatalog staff (or automated document check) has confirmed the advertiser identity matches the ad.
Certified ✓ Gold checkmark $20 / month $2,000 one-time Audited integration: the advertiser has demonstrated that their bids are placed programmatically by a machine agent, not by a human using an API client. Confirmed via test bid challenge + signed attestation or live API inspection.
Founding Partner ★ Custom badge + "Founding Partner" label $100 / month $10,000 one-time Top-tier visibility; custom badge color (advertiser-specified hex, rendered as brand_color on the badge). Reserved for early adopters who commit while madcatalog is pre-launch. Founding Partner status confers permanent label visibility even if the badge tier changes.
Comparison to Twitter Blue / X Premium
ProductPriceAudienceWhat the badge means
Twitter Blue (basic) $8 / month Consumer Paid, not verified. Often criticized as meaningless trust signal.
X Premium+ $22 / month Consumer / creator Verified government ID or notable account. Stronger but still consumer-oriented.
madcatalog Verified $5 / month or $500 one-time B2B machine operator Real company identity confirmed. Higher than consumer because identity confirmation requires manual or automated document verification — the marginal cost is higher, the audience is smaller and higher-value.
madcatalog Certified $20 / month or $2,000 one-time B2B machine operator Programmatic integration confirmed. Substantially higher than X Premium because B2B SaaS pricing norms apply: the buyer is a company, not a person, and the signal has real commercial value in agent-to-agent interactions.
madcatalog Founding Partner $100 / month or $10,000 one-time Strategic early adopter No consumer analog. Founder-tier pricing for companies willing to bet on madcatalog before it has public traction. Modeled on enterprise SaaS "founding customer" deals.
Verification Purchase Flow (MPP-Protected)

The machine operator's agent initiates verification by paying via the POST /verification/purchase endpoint. This endpoint is MPP-protected: the machine receives a 402 challenge sized to the tier price, pays, and receives a verification_token in the 200 response. The token is then submitted to activate verification on a specific ad.

New Endpoints
MethodPathAuthRequest bodyResponse
POST /verification/purchase MPP 402 { "ad_id": "uuid", "tier": "verified"|"certified"|"founding_partner" } { "verification_token": "vt_...", "tier": "verified", "expires_at": "..." } — see schema below
POST /ads/{ad_id}/verify X-Api-Key (ad owner) { "verification_token": "vt_..." } Updated Ad object with verification field populated
GET /ads/{ad_id}/verification None (public) N/A Public verification status: { "ad_id": "...", "verification": { ... } }
Verification Purchase Response Schema
{
  "verification_token": "<vt_… 43-char base64url token returned here …>",
  "tier": "certified",
  "ad_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "expires_at": null,
  "purchased_at": "2026-05-23T12:34:56Z",
  "payment_receipt": "chal_abc123"
}
Ad Verification Field Schema (added to Ad object)
{
  "verification": {
    "tier": "certified",
    "verified_at": "2026-05-23T12:34:56Z",
    "expires_at": null
  }
}
When verification is null
If an ad has never been verified, the verification field is present in the response but explicitly set to null. This allows machines to distinguish between "the API doesn't support verification" (field absent) and "this ad is unverified" (field is null). OpenAPI spec marks the field as nullable: true.
DynamoDB Changes — madcatalog-ads Table

No new table is required. Add the following attributes to existing ad records. These are sparse attributes — they are absent on unverified ads and only written when verification is activated.

AttributeTypeWritten byNotes
verification_tier String (S) VerifyAdFunction Lambda (called by POST /ads/{ad_id}/verify) Values: "verified", "certified", "founding_partner". Absent if unverified.
verified_at String (S) VerifyAdFunction ISO 8601 timestamp when verification was activated. Immutable once written.
verification_expires_at String (S) or absent VerifyAdFunction ISO 8601. Present only for subscription (monthly) purchases. Absent for one-time (permanent) purchases. GetCatalog Lambda should filter out expired verifications at read time: if verification_expires_at is in the past, treat verification_tier as null in the response.
verification_token_hash String (S) PurchaseVerificationFunction Lambda SHA-256 hash of the verification_token issued at purchase. Used by VerifyAdFunction to validate the token before activating verification. Never store the raw token.
New DynamoDB Table — madcatalog-verification-tokens

Short-lived token store. Verification tokens expire after 72 hours if not redeemed. TTL-based cleanup keeps this table small.

AttributeTypeRole
token_hash String (S) Partition key. SHA-256 of the raw vt_... token.
ad_id String (S) The ad this token is authorized to verify.
tier String (S) Which tier was purchased: "verified", "certified", or "founding_partner".
purchased_at String (S) ISO 8601 timestamp of the MPP payment.
payment_receipt String (S) MPP challenge ID (chal_...) for audit trail.
redeemed Boolean (BOOL) Set to true by VerifyAdFunction after token is used. Prevents replay.
ttl Number (N) Unix epoch. Set to purchased_at + 72h. DynamoDB TTL enabled. Unredeemed tokens auto-expire.
One-Time vs. Subscription — the Decision
For one-time (permanent)
Simpler implementation — no recurring billing, no subscription management, no dunning logic. A single MPP payment creates a permanent badge. verification_expires_at is null on the ad record. No Stripe subscription object to manage. No webhook for customer.subscription.deleted. Easier to reason about for machines — "I paid once, I'm verified forever."
For subscription (monthly)
Recurring revenue is more predictable. Allows tier downgrade or badge revocation if an advertiser stops paying, which preserves badge integrity over time. Requires Stripe subscription integration, webhook handling for invoice.payment_failed and customer.subscription.deleted, and a Lambda to write verification_expires_at on expiry. More moving parts; richer business model.
Recommendation: one-time at launch
At launch, implement one-time payments only. Subscriptions can be added in a future milestone by adding Stripe subscription Products alongside the one-time Prices. One-time is simpler, ships faster, and the higher one-time price ($500 / $2,000 / $10,000) is defensible for a B2B signal — machine operators are not surprised by enterprise-tier pricing. Monthly options can be added when advertiser retention data suggests demand for a lower barrier to entry.
Stripe Product and Price Setup

Create the following in the Stripe Dashboard before implementing PurchaseVerificationFunction. Store Product IDs and Price IDs in SSM Parameter Store at /madcatalog/verification/{tier}/price-id.

Product nameMetadata tierOne-time Price (USD)Stripe Price type
madcatalog Verified Badge verified $500.00 one_time
madcatalog Certified Badge certified $2,000.00 one_time
madcatalog Founding Partner Badge founding_partner $10,000.00 one_time

The PurchaseVerificationFunction Lambda reads the tier from the request body, looks up the corresponding Price ID from SSM, and uses that Price ID as the expected amount when issuing the MPP 402 challenge. The Stripe PaymentIntent is created with metadata.tier = "certified" and metadata.ad_id = "{uuid}" so that the webhook handler can correlate the payment if needed. The MPP challenge amount must match the Price exactly — set amount in the WWW-Authenticate header to the price in cents (e.g., amount="200000" for $2,000.00).

New Lambda Functions
FunctionRouteResponsibility
PurchaseVerificationFunction POST /verification/purchase MPP-protected. Validates tier in request body. Looks up price from SSM. Issues 402 challenge with correct amount. On valid credential: generates a vt_... token (crypto-random 32 bytes, base64url-encoded with "vt_" prefix), hashes it with SHA-256, writes token record to madcatalog-verification-tokens, returns token and metadata to caller.
VerifyAdFunction POST /ads/{ad_id}/verify Requires X-Api-Key matching the ad owner (same key used for PATCH /ads/{ad_id} enrichment updates). Hashes the submitted verification_token, looks up the record in madcatalog-verification-tokens, validates: token exists, not redeemed, not expired, ad_id matches. On success: writes verification_tier, verified_at to madcatalog-ads; marks token as redeemed = true.
GetVerificationFunction GET /ads/{ad_id}/verification Public, no auth. Reads ad record from madcatalog-ads. If verification_tier is absent, returns { "ad_id": "...", "verification": null }. If verification_expires_at is in the past, returns null verification (expired badge is treated as unverified in read path). Otherwise returns full verification object.
Proposed Smoke Tests
test:verification:smoke — what to verify

1. Purchase endpoint requires MPP payment. Issue POST /verification/purchase with body { "ad_id": "{test_ad_id}", "tier": "verified" } and no Authorization header. Assert: 402 with challenge amount 50000 (= $500.00 in cents). Amount must match the verified tier Price.

2. Paid purchase returns a verification token. Complete MPP flow. Assert: 200, response contains verification_token matching pattern /^vt_[A-Za-z0-9_-]{43}$/, tier = "verified", ad_id matches submitted value, expires_at = null.

3. Redeem token activates verification. Submit POST /ads/{ad_id}/verify with the token from test 2 and the ad owner's X-Api-Key. Assert: 200, response contains verification.tier = "verified" and verification.verified_at is a valid ISO 8601 timestamp.

4. Token cannot be redeemed twice. Resubmit the same token to POST /ads/{ad_id}/verify. Assert: 409 Conflict with error: "token_already_redeemed".

5. Verification appears in catalog. Issue GET /catalog. Find the verified ad. Assert: its verification field is present, non-null, with tier = "verified".

6. Public verification endpoint. Issue GET /ads/{ad_id}/verification (no auth). Assert: 200, verification.tier = "verified". Then issue the same request for an unverified ad. Assert: verification = null.

Taskfile Tasks Needed
Task namePurpose
test:verification:smoke Run all six verification smoke tests against SAM local. Requires LocalStack running and test ad seeded.
validate-schema:verification Validate tests/fixtures/verification-purchase-response-example.json against extracted static/schemas/VerificationPurchaseResponse.json.
stripe:create-verification-products Idempotent script that creates the three Stripe Products + Prices via stripe products create and stripe prices create CLI commands. Reads existing IDs from SSM to skip re-creation. Run once per environment.
CI / Precommit Wiring

Add validate-schema:verification to task precommit and task ci. Add test:verification:smoke to the test:smoke meta-task in .github/workflows/quality-gates.yml alongside test:newsletter:smoke. Add the new VerificationPurchaseResponse.json schema to static/schemas/ and reference it from api/openapi.yml via $ref so that the existing Redocly lint gate validates it automatically. The madcatalog-verification-tokens DynamoDB table definition should be added to the SAM template and validated by the existing validate-schema:sam / cfn-lint gate.

F3 Advertiser Admin Portal & Top-of-Funnel Tracking PLANNED — SPEC: advertiser-admin-plan.html
The goal: give humans and machines visibility into what the machine economy is doing
An advertiser admin portal closes the feedback loop: after a machine places a bid, both the machine and its human operator can see whether that bid improved rank, how spend compares to competitors, and what the next bid threshold is. The portal is machine-readable first (JSON API + MCP tools), human-readable second (Hugo catalog site pages). The same data that helps advertisers optimize also serves as public social proof that the catalog is real and competitive.
The hyper-optimistic value proposition: madcatalog as the machine economy signal
Any company that has placed an autonomous machine bid in madcatalog has done something concrete: they configured an AI agent to spend real money on placement in a public, machine-only ad catalog. That is not a press release — it is a demonstrated commitment. The advertiser admin portal makes this visible, trackable, and shareable. Aggregate public catalog stats make the whole system legible to outsiders. Together, they make madcatalog the credibility artifact for organizations that are serious about the machine economy. The kayfabe requires a human to want to engage in the machine economy; this portal is the artifact that makes that engagement legible and shareable.
Top-of-Funnel: Session-Based Conversion Tracking

Every request to GET /catalog (API or Hugo site page load) generates a short-lived X-Catalog-Session UUID. This token is the correlation anchor: anonymous readers and agents that later convert to advertisers or bidders can be attributed to a catalog session. The session ID is optional for clients; the server fails open if it is not echoed. Three conversion paths exist:

PathFlowWhen to use
A — Advertiser first, ad later POST /advertisers → receive advertiser_api_key → later POST /ads/bid with X-Advertiser-Api-Key Agent or human wants to establish advertiser identity and enrich the profile before committing any budget. Useful for organizations preparing a launch.
B — Advertiser + ad in one step (existing frictionless path) POST /ads/bid with inline advertiser object in body — creates advertiser and ad atomically Agent has budget and content ready simultaneously. The current lowest-friction path. Most common for fully automated programmatic buyers.
C — Ad buy with deferred advertiser enrichment (existing) POST /ads/bid with unclaimed slug → advertiser auto-created → enrich later via PATCH /advertisers/{slug} Agent gets a slot in the catalog immediately, enriches the advertiser profile separately. Current default behavior for minimal ad placements.
Admin Portal: Three Dashboard Surfaces
Aggregate Advertiser Dashboard
All ads owned by one advertiser: lifetime spend, active ad count, best rank, total views, 30-day spend trend, payment method breakdown (Stripe vs Tempo), ad rank table with 7-day rank change, stacked spend time series, rank history, competitive gap to rank 1, and funnel conversion rate.
Route: GET /advertisers/{slug}/dashboard • MCP: get_advertiser_dashboard
Individual Ad Dashboard
One ad in detail: current rank, 7/30-day rank change, spend-to-next-rank, spend-to-rank-1, cumulative spend, 7-day spend velocity, bid history (last 10 with payment method badges), rank history chart annotated with each bid, catalog view count, days since last bid.
Route: GET /ads/{ad_id}/dashboard • MCP: get_ad_dashboard
Public Catalog Intelligence Dashboard
A public subset of catalog stats: live leaderboard (top 10 ads by rank), catalog lifetime spend, most recent bid timestamp, daily spend trend (90 days), biggest 24-hour mover, category breakdown, and newcomers list. No auth required. Drives advertiser acquisition: potential advertisers see real spend competition before deciding to participate.
Routes: GET /public/leaderboard • GET /public/catalog-stats
Full technical specification — advertiser-admin-plan.html
The complete implementation contract is in the plan document. It covers: all new REST endpoints and their Lambda implementations; new JSON schemas (CreateAdvertiserRequest, AdvertiserDashboard, AdDashboard, PublicLeaderboard); the modified POST /ads/bid schema accepting an inline advertiser object or an existing advertiser_id; new MCP tools (create_advertiser, get_advertiser_dashboard, get_ad_dashboard, get_advertiser_ads, get_public_leaderboard, get_catalog_stats); OTel logs, metrics, and traces for the full funnel; and element-level dashboard specifications for all three surfaces. Per ADR-002, no new endpoints are added to api/openapi.yml until active implementation begins.