Technical specification for F3: Advertiser Admin Portal & Top-of-Funnel Tracking. This document covers OpenAPI changes, schema definitions, MCP tools, and OTel instrumentation. It is the implementation contract; the proposal section is the design rationale.
GET /catalog. Later — same session, different
session, maybe minutes later — that agent places a bid. We want to know: did the catalog
read convert to a bid? Without a correlation token, we can't answer that. With one, we
get a coarse but useful funnel signal.
| Stage | Action | Token flow |
|---|---|---|
| Catalog read | GET /catalog or website page load |
Server generates a short-lived X-Catalog-Session UUID (v4) and returns it in the response header. TTL: 60 minutes. Client MAY echo it back on subsequent requests. |
| Bid challenge | POST /ads/bid (no auth → 402) |
Client echoes X-Catalog-Session in the request header. Server logs the session ID alongside the challenge ID. No enforcement — entirely optional, fail-open. |
| Bid placement | POST /ads/bid (with credential → 200) |
Client echoes same X-Catalog-Session. Server includes it in the bid record (session_id sparse attribute) and in OTel trace attributes. |
| Advertiser creation | POST /advertisers (new) |
Same echo pattern. Links advertiser creation to the originating catalog session. |
| Method | Path | Auth | Summary | Lambda |
|---|---|---|---|---|
| POST | /advertisers |
None (creates new) | Create an advertiser profile without placing an ad. Returns advertiser_slug + advertiser_api_key. Idempotent on slug (returns 409 if slug already claimed). |
CreateAdvertiserFunction |
| GET | /advertisers/{slug}/dashboard |
X-Advertiser-Api-Key |
Aggregate performance dashboard for all ads owned by this advertiser. Returns structured JSON (see §10). Machine-readable; the catalog site renders the human view. | GetAdvertiserDashboardFunction |
| GET | /advertisers/{slug}/ads |
X-Advertiser-Api-Key |
Paginated list of all ads associated with this advertiser slug. Includes current rank, cumulative spend, and view count per ad. | GetAdvertiserAdsFunction (or extend GetAdvertisersFunction) |
| GET | /ads/{ad_id}/dashboard |
X-Api-Key (ad owner) or X-Advertiser-Api-Key |
Individual ad performance dashboard. Returns rank history, spend trend, bid history, and competitive intelligence (spend to next rank). See §11. | GetAdDashboardFunction |
| GET | /public/leaderboard |
None (public) | Public rank leaderboard. Returns top N ads by cumulative spend with sanitized fields (title, rank, spend, rank_change). No PII, no advertiser_slug. Rate-limited. | GetPublicLeaderboardFunction |
| GET | /public/catalog-stats |
None (public) | Aggregate catalog health stats: total ads, total spend, total bids, newest ad created_at, most recent bid. Cached 60s. Powers the public dashboard. | GetCatalogStatsFunction |
| GET | /newsletters/quarterly |
MPP 402 for machines; none for humans (HTML/RSS) | Most recent quarterly digest. $20 per machine request. Covers 3-month trends: top movers, new entrants, category shifts, total volume. | GetQuarterlyNewsletterFunction |
| GET | /newsletters/annual |
MPP 402 for machines; none for humans (HTML/RSS) | Most recent annual digest. $100 per machine request. Year-in-review: founding cohort performance, emerging categories, machine economy volume story. | GetAnnualNewsletterFunction |
| GET | /newsletters/quarterly/history |
MPP 402 for machines ($50/page); free HTML for humans | Paginated archive of all quarterly digests. No TTL — stored permanently for historic analysis and AI training use cases. | GetNewsletterHistoryFunction (extend existing) |
| GET | /newsletters/annual/history |
MPP 402 for machines ($250/page); free HTML for humans | Paginated archive of all annual digests. Permanent archive. AI training pipelines pulling the full catalog year history pay per page. | GetNewsletterHistoryFunction (extend existing) |
| GET | /newsletters/monthly |
MPP 402 for machines ($10); none for humans (HTML/RSS) | Most recent monthly digest. Covers prior calendar month: top movers, new ads, category spend totals, new advertisers count. Permanent storage — no TTL. | GetMonthlyNewsletterFunction |
| GET | /newsletters/monthly/history |
MPP 402 for machines ($25/page); free HTML for humans | Paginated archive of all monthly digests. Permanent archive. Sort key "2026-05" (YYYY-MM). Useful for year-over-year machine economy trend analysis. |
GetNewsletterHistoryFunction (extend existing) |
POST /ads/bid — Accept Advertiser Creation OR Advertiser ID MODIFIED
Today POST /ads/bid auto-creates a new advertiser if the slug is unclaimed,
or requires X-Advertiser-Api-Key if the slug is already claimed. The request
body does not accept structured advertiser metadata. Three cases must be cleanly supported:
| Case | Request shape | Behavior |
|---|---|---|
| New advertiser + new ad (existing today) | advertiser_slug only (unclaimed slug, no api key header) |
Auto-creates advertiser, creates ad, returns both api_key and advertiser_api_key. Backward-compatible — no change needed for this path. |
| Existing advertiser + new ad (existing today) | advertiser_slug + X-Advertiser-Api-Key header |
Validates API key, creates ad under existing advertiser. Backward-compatible. |
| New advertiser with enrichment (new) | advertiser_slug + advertiser object (see schema §5) in request body, no api key header |
Auto-creates advertiser with the provided enrichment fields (display_name, website_url, contact_email, description). Creates ad. Returns both keys. |
POST /ads/bid without the new advertiser
object field continue to work exactly as before. The advertiser field is
optional. No breaking change.
GET /catalog — Return Session ID MODIFIED
Add X-Catalog-Session response header containing a new UUID v4 generated per
request. This is the top-of-funnel session token. No DynamoDB write required — the session
ID is included in OTel log attributes on the response and used for correlation only.
No change to the response body schema.
GET /ads/{ad_id}/bids — Include Session ID MODIFIED
Add session_id to each bid object in the response. Value is the
X-Catalog-Session echoed by the bidder at placement time, or null
if not provided. Allows advertisers to see which of their bids originated from a tracked
catalog session.
CreateAdvertiserRequestPOST /advertisers| Field | Type | Required | Constraints |
|---|---|---|---|
slug | string | Yes | 3–64 chars, lowercase alphanumeric + hyphens, no leading/trailing hyphen. Must be globally unique. |
display_name | string | No | Max 120 chars. Human-readable name (e.g., "Stripe, Inc."). Shown on public advertiser profile page. |
website_url | string (URI) | No | Must be HTTPS. Must not be a private/loopback address. Subject to the same HEAD-fetch reachability check as ad URLs. |
contact_email | string (email) | No | RFC 5322 email. Not publicly exposed. Used for newsletter delivery and admin contact. Stored as PII — do not log. |
description | string | No | Max 500 chars. Short business description. Rendered on public advertiser profile page. |
CreateAdvertiserResponse| Field | Type | Notes |
|---|---|---|
slug | string | Echo of the requested slug. |
advertiser_api_key | string | UUID v4. Plain text, returned once only. Store this — it cannot be recovered. Used in X-Advertiser-Api-Key header for all subsequent admin operations. |
display_name | string | null | Echo of submitted value, or null. |
created_at | string (ISO 8601) | Timestamp of creation. |
AdvertiserDashboardGET /advertisers/{slug}/dashboard| Field | Type | Description |
|---|---|---|
slug | string | Advertiser slug. |
display_name | string | null | Advertiser display name. |
ad_count | integer | Total ads currently in catalog. |
total_spend_usd_cents | integer | All-time cumulative spend across all ads. |
total_bid_count | integer | All-time total bids placed by this advertiser. |
spend_last_30d_usd_cents | integer | Sum of confirmed bid amounts in the last 30 days. |
best_rank | integer | null | Current minimum rank across all active ads. Null if no ads. |
avg_rank | number | null | Mean rank across all active ads. Lower = better. |
total_view_count | integer | Sum of view_count across all ads — total times any of this advertiser's ads appeared in a catalog response. |
payment_methods | object | { "stripe": N, "tempo": M } — count of bids by payment method across all ads. |
ads | array of AdSummary | Lightweight summary of each ad: ad_id, title, current_rank, cumulative_spend_usd_cents, bid_count, view_count. |
generated_at | string (ISO 8601) | Timestamp of when this response was computed. |
AdDashboardGET /ads/{ad_id}/dashboard| Field | Type | Description |
|---|---|---|
ad_id | string | Ad identifier. |
title | string | Ad title. |
current_rank | integer | Current position in the catalog (1 = top). |
rank_change_7d | integer | Change in rank over the last 7 days. Negative = improved (moved up). Requires rank history snapshot storage — see §7. |
rank_change_30d | integer | Change in rank over the last 30 days. |
cumulative_spend_usd_cents | integer | All-time confirmed spend for this ad. |
spend_last_7d_usd_cents | integer | Spend from bids confirmed in last 7 days. |
spend_last_30d_usd_cents | integer | Spend from bids confirmed in last 30 days. |
bid_count | integer | Total confirmed bids for this ad. |
view_count | integer | Total times this ad appeared in a catalog response. |
spend_to_next_rank_usd_cents | integer | null | Minimum additional spend needed to surpass the next-higher-ranked ad. Null if already rank 1. |
last_bid_at | string | null | ISO 8601 timestamp of most recent confirmed bid. |
last_bid_payment_method | string | null | "stripe" or "tempo". |
recent_bids | array | Last 10 bids: bid_id, amount_usd_cents, payment_method, status, created_at. |
generated_at | string (ISO 8601) | Response computation timestamp. |
PublicLeaderboardGET /public/leaderboard| Field | Type | Description |
|---|---|---|
ads | array of LeaderboardEntry | Top N ads sorted by rank. Each entry: rank, title, cumulative_spend_usd_cents, rank_change_24h, category. Excludes advertiser_slug, url, contact info. |
total_ads | integer | Total ads in catalog. |
generated_at | string | Cached response timestamp. |
PlaceBidRequest — Add optional advertiser object MODIFIED| New field | Type | Required | Description |
|---|---|---|---|
advertiser |
object (optional) | No |
Inline advertiser creation fields. Accepted only when advertiser_slug is not yet claimed
and no X-Advertiser-Api-Key header is present (new advertiser path).
Schema: { display_name?, website_url?, contact_email?, description? }.
Ignored if slug already exists (existing advertiser path uses the header instead).
|
Bid (response object) — Add session_id MODIFIED| New field | Type | Description |
|---|---|---|
session_id |
string | null |
The X-Catalog-Session token echoed by the bidder at placement time, or null.
Returned by GET /ads/{ad_id}/bids. Allows advertisers to correlate
bids with catalog sessions. Not included in the POST /ads/bid 200 response —
only exposed through the bids history endpoint.
|
Ad (catalog response) — Add payment_method to last bid info MODIFIED
Add last_bid_payment_method (string | null) to the Ad object
returned by GET /catalog and GET /ads/{ad_id}. Value is
"stripe" or "tempo" based on the most recent confirmed bid.
Null for ads with no confirmed bids. This is publicly readable — machine agents can
use this to understand which payment rails are active in the catalog.
NewsletterDigest — Expand for quarterly/annual MODIFIED
Add period values "quarterly" and "annual" to the
existing NewsletterDigest enum. Add two new fields:
| New field | Type | Description |
|---|---|---|
category_breakdown | array | null | Per-category spend totals for the period. Shape: [{ category, total_spend_usd_cents, bid_count, ad_count }]. Present in quarterly and annual only. |
new_advertisers | integer | null | Count of distinct new advertiser_slugs that placed their first bid in the period. Present in quarterly and annual only. |
| Tool name | Description | Auth required | Backing endpoint |
|---|---|---|---|
create_advertiser |
Create an advertiser profile (slug + optional metadata) without placing an ad. Returns advertiser_api_key. Use when an agent wants to establish identity before placing any bids, or when a human operator is setting up an advertiser account programmatically. |
None (creates new account) | POST /advertisers |
get_advertiser_dashboard |
Retrieve aggregate performance metrics for all ads owned by an advertiser: total spend, bid counts, best rank, view count, payment method breakdown, and a summary table of all active ads. | advertiser_api_key argument |
GET /advertisers/{slug}/dashboard |
get_ad_dashboard |
Retrieve detailed performance metrics for a single ad: current rank, rank change over 7/30 days, spend trends, bid history (last 10), spend to next rank, and competitive gap to rank 1. | api_key or advertiser_api_key argument |
GET /ads/{ad_id}/dashboard |
get_advertiser_ads |
List all ads associated with an advertiser slug, with their current rank, spend, and view count. Paginated. Useful for an agent managing multiple ads to enumerate what it owns before deciding where to allocate budget. | advertiser_api_key argument |
GET /advertisers/{slug}/ads |
get_public_leaderboard |
Read the public rank leaderboard (no auth required). Returns top N ads with rank, title, cumulative spend, and 24-hour rank change. Use for competitive research, to understand the bid landscape before deciding on a bid amount. | None | GET /public/leaderboard |
get_catalog_stats |
Get aggregate catalog health metrics (total ads, total spend, total bids, most recent activity). No auth required. Use for a quick market-health check before committing to a bid. | None | GET /public/catalog-stats |
mcp/internal/mcp/tools.go: JSON object with named string/integer fields,
described in the inputSchema property. Auth keys (advertiser_api_key,
api_key) are passed as tool arguments — never in environment variables.
Claude agents invoking these tools can store the keys in conversation context after
a create_advertiser or place_ad call.
console.log(JSON.stringify({...})) bridged to the OTel Logger API
via the existing bridge in sam/shared/otel.mjs. Logs flow to Loki via the
OTel Collector. Do not add new logging frameworks — extend the existing bridge.
| Event name | Lambda | Key fields | Purpose |
|---|---|---|---|
session.created | GetCatalogFunction | session_id, source: "catalog"|"website" | Top-of-funnel: anonymous catalog read that generated a session token. |
session.bid_challenge | PlaceBidFunction | session_id (if echoed), challenge_id, advertiser_slug | Session converted to a bid challenge — mid-funnel signal. |
session.bid_placed | PlaceBidFunction | session_id (if echoed), bid_id, ad_id, payment_method, amount_usd_cents | Session converted to a confirmed bid — bottom-of-funnel conversion. |
advertiser.created | CreateAdvertiserFunction or PlaceBidFunction | slug, session_id, via: "direct"|"bid_inline", has_enrichment: bool | New advertiser profile created — critical conversion event. |
advertiser.dashboard.queried | GetAdvertiserDashboardFunction | slug, ad_count, duration_ms | Advertiser is actively monitoring their account — engagement signal. |
ad.dashboard.queried | GetAdDashboardFunction | ad_id, slug, current_rank | Individual ad being monitored — bid optimization intent signal. |
leaderboard.queried | GetPublicLeaderboardFunction | total_ads, requester_type: "agent"|"human" (inferred from Accept header) | Competitive research signal — who is looking at the landscape. |
newsletter.quarterly.generated | GenerateQuarterlyNewsletterFunction | period, total_spend_usd_cents, new_advertisers, duration_ms | Quarterly digest generation completed successfully. |
newsletter.annual.generated | GenerateAnnualNewsletterFunction | year, total_spend_usd_cents, total_advertisers, duration_ms | Annual digest generation completed successfully. |
All metrics use the meter exported from sam/shared/otel.mjs.
Counters and histograms flow to Prometheus via the OTel Collector (namespace:
madcatalog_). Follow the existing payment.succeeded pattern.
| Metric name (OTel) | Type | Labels | Description |
|---|---|---|---|
funnel.session.created | Counter | source (catalog|website) | Top-of-funnel catalog sessions created. |
funnel.session.converted | Counter | to_stage (bid_challenge|bid_placed|advertiser_created) | Sessions that advanced to a downstream stage. Denominator for conversion rate panels. |
advertiser.created | Counter | via (direct|bid_inline), has_enrichment (true|false) | New advertiser profiles created. Tracks advertiser acquisition velocity. |
dashboard.advertiser.query_duration | Histogram (ms) | ad_count (bucket of ads per advertiser) | Latency of aggregate dashboard computation. High ad counts are slow — track the long tail. |
dashboard.ad.query_duration | Histogram (ms) | Latency of individual ad dashboard computation. | |
leaderboard.query | Counter | requester_type (agent|human) | Public leaderboard query volume. Tracks organic machine discovery. |
newsletter.generation.duration | Histogram (ms) | period (daily|weekly|quarterly|annual) | How long each generation Lambda takes. Alert if quarterly exceeds 25s (Lambda timeout risk). |
newsletter.generation.failed | Counter | period, reason | Failed generation runs (DynamoDB error, idempotency skip, timeout). Alert on non-zero. |
bid.payment_method | Counter | method (stripe|tempo), is_new_advertiser | Bids broken down by payment method. Tracks Tempo adoption rate over time. |
All Lambdas already produce a root span via withTracing() in
sam/shared/otel.mjs. The additions below are span attributes
and child spans that enrich existing traces, not new instrumentation
frameworks.
| Lambda / context | Addition | Type | Value |
|---|---|---|---|
GetCatalogFunction |
catalog.session_id |
Root span attribute | The newly generated X-Catalog-Session UUID. Links catalog reads to downstream bid traces when the client echoes the ID. |
PlaceBidFunction |
catalog.session_id |
Root span attribute | Echo of X-Catalog-Session header if present. Enables TraceQL queries like { .catalog.session_id = "abc" } to trace a session from catalog read through bid placement in Tempo. |
PlaceBidFunction |
bid.payment_method |
Root span attribute | "stripe" or "tempo". Already available from resolvePaymentMethod() — add to the span attributes. |
PlaceBidFunction |
advertiser.is_new |
Root span attribute | Boolean. True when this bid creates a new advertiser. Allows filtering traces for "new advertiser acquisition" events. |
GetAdvertiserDashboardFunction |
DynamoDB child spans | Child spans | Separate spans for: dynamo.get_advertiser, dynamo.scan_ads, dynamo.query_bids. Tag each with table name and item count. Dashboard latency is dominated by DynamoDB — need visibility into which query is slow. |
GenerateQuarterlyNewsletter / GenerateAnnualNewsletter |
Generation span | Child spans | Child spans for: dynamo.scan_bids_window (with window size attribute), dynamo.scan_ads, dynamo.put_digest. Newsletter generation is a batch operation — trace the DynamoDB work to catch scan timeouts early. |
catalog.session_id as a baggage item in GetCatalogFunction
so it propagates automatically via W3C TraceContext/Baggage headers if the catalog
site or agent passes the trace context. This enables TraceQL queries in Grafana Tempo
to find all traces associated with a session ID across the catalog read → bid challenge
→ bid placement flow. Implement via
propagation.getBaggage(context.active())?.setEntry("session_id", id)
in the OTel API.
GET /advertisers/{slug}/dashboard), catalog site (Alpine.js advertiser page), and Grafana (parameterized by slug variable)GET /catalog response. Impression count proxy.| ADR | Relevance to this plan |
|---|---|
| ADR-002 — OpenAPI Contract-First | All new endpoints in §2–3 must be added to api/openapi.yml before any Lambda implementation begins. The OpenAPI spec is the implementation contract; this document is the design input. |
| ADR-006 — MCP Service for Machine Consumers | New MCP tools in §6 follow the same tool registration pattern as existing tools in mcp/internal/mcp/tools.go. No new MCP frameworks — extend the existing server. |
| ADR-007 — REST API Design | New admin endpoints follow the same Lambda-per-route pattern, SAM template conventions, and JSON error response format established in ADR-007. |
| ADR-014 — OpenTelemetry Unified Observability | Governs all instrumentation in §7–9. OTel is the only logging/metrics/tracing mechanism. The console.log-to-OTel bridge in sam/shared/otel.mjs handles log routing to Loki. The meter export handles metrics to Prometheus. The withTracing() wrapper handles root spans. No additional APM agents or sidecar collectors. |
X-Advertiser-Api-Key pattern, or (c) the advertiser admin API
introduces a fundamentally different access control model (e.g., OAuth2, JWT).
None of those apply to the current scope.