Complete guide to subscription management — Stripe integration, webhook processing, tier management, LRS add-on lifecycle, payment failure handling, refund processing, and cache invalidation.
The Trinity Beast subscription lifecycle is managed by two Lambda functions working in tandem:
trinity-beast-receipt — The primary handler. Processes checkout completions (subscriptions, donations, LRS add-ons, webhook subscriptions) and Stripe webhook events (tier changes, cancellations, payment failures, recoveries, refunds).trinity-beast-email-sender — A brainless SQS consumer that picks up pre-built emails from the queue and delivers them via SES. No logic, no DB, no Valkey — just sends.Zero Direct Database Access. The receipt Lambda does NOT connect to Aurora directly. It is NOT in the VPC. All database operations go through the LPO server's admin API over HTTPS (api.cpmp-site.org/admin/sql). Six PostgreSQL functions handle all writes: record_subscription, record_donation, record_lrs_addon, record_webhook_subscription, record_refund, and record_donation_refund. This eliminates the $32/month NAT gateway cost and the 10-second cold-start timeout that plagued direct DB connections from a non-VPC Lambda.
Dedicated Secret. The receipt Lambda uses its own secret: trinity-beast-receipt-secrets (contains only Stripe keys). It has zero access to database credentials, Bedrock keys, or any other infrastructure secret. Blast radius is limited to Stripe test-mode operations.
Async Email Pipeline. Emails are never sent inline. The receipt Lambda builds the complete localized email from pre-translated frames in Valkey, then enqueues the finished message to SQS (trinity-beast-email-queue). The trinity-beast-email-sender Lambda picks it up within 3–6 seconds and delivers via SES. If SQS itself is unavailable (extremely rare), the receipt Lambda falls back to direct SES delivery. Emails are durable — SQS retains messages for 4 days with automatic retry.
Zero Bedrock for Emails. All email content (subject, heading, labels, footer, impact messages) is assembled from pre-translated frames stored in Valkey (email:frames — 12 languages × 45 keys). No AI translation at send time. A Japanese donation receipt assembles entirely from Japanese frame labels — instant, deterministic, zero cost.
Idempotency: Both checkout and webhook paths are idempotent. Checkout sessions are deduplicated via ElastiCache (1-hour TTL). Webhook events are deduplicated by Stripe event ID in ElastiCache (24-hour TTL). Duplicate calls return cached responses without re-processing.
| Property | trinity-beast-receipt | trinity-beast-email-sender |
|---|---|---|
| Runtime | Go (provided.al2023) | Go (provided.al2023) |
| Memory | 1770 MB | 1770 MB |
| Timeout | 180 seconds | 60 seconds |
| VPC | Not in VPC | Not in VPC |
| Secret | trinity-beast-receipt-secrets | None (SES via IAM role) |
| Trigger | API Gateway (checkout + webhook) | SQS (trinity-beast-email-queue) |
| SQS Config | — | Batch 6, 3s window, ReportBatchItemFailures |
| Cold Start | ~650 ms | ~104 ms |
| Dependencies | Stripe, Valkey, SQS, Admin API | SES only |
| Tier | Monthly Queries | Rate Limit (QPS) | Burst Limit | Min Wait (sec) | LRS Included |
|---|---|---|---|---|---|
| Free | 1,000 | 1 | 5 | 1.0 |
10 reports/month |
| Pro | 50,000 | 10 | 20 | 0.1 |
10 reports/month (unlimited with add-on) |
| Enterprise | 500,000 |
50 | 100 |
0.02 |
10 reports/month (unlimited with add-on) |
| Unlimited | Unlimited | 100 |
200 |
0.01 |
Unlimited (included) |
| Lifetime | Unlimited | 100 |
200 |
0.01 |
Unlimited (included) |
| AWS Partner | Unlimited | No limit | No limit | 0 | Unlimited (included) |
Webhook Associates receive prices pushed directly to their endpoints — no polling required. Every tier includes both UDP and HTTPS delivery and LRS reporting, and selects from the full prewarmed catalog across all 6 exchanges up to the tier's asset ceiling.
| Tier | Assets | Push Interval | Delivery | Price |
|---|---|---|---|---|
| Starter | 9 | 60 seconds | UDP + HTTPS | $30/month |
| Standard | 30 | 15 seconds | UDP + HTTPS | $90/month |
| Professional | 75 | 6 seconds | UDP + HTTPS | $210/month |
| Enterprise | 150 (all) |
3 seconds | UDP + HTTPS | $420/month |
Token Bucket Rate Limiting: Each API key has a QPS limit enforced by a token bucket algorithm. The bucket refills at rate_limit_qps tokens per second, with a maximum burst of burst_limit tokens. The minimum_wait_seconds is the minimum time between requests when the bucket is empty.
AWS Partner Tier: The exchanges we depend on — Coinbase, Bitstamp, Kraken, Gate.io, Crypto.com, and OKX — share their price feeds with The Trinity Beast at no cost. We pass that generosity forward to the AWS community. If your AWS application needs live crypto prices, partner keys provide unlimited access with no rate limiting, no monthly caps, and no billing. Partners connect via AWS PrivateLink directly to containers — bypassing the ALB and public internet entirely. We receive freely, we give freely.
When a customer completes a Stripe Checkout session on the subscription page, the thank-you page renders instantly with translated defaults (Phase 1), then calls the Lambda for personalization (Phase 2). The Lambda processes the session, records everything via PostgreSQL functions, and enqueues a localized receipt email.
sequenceDiagram
participant C as Customer
participant S as Stripe Checkout
participant TY as Thank-You Page
participant L as Lambda (receipt)
participant API as LPO Admin API
participant DB as Aurora (PG functions)
participant EC as ElastiCache (Valkey)
participant SQS as SQS Email Queue
participant ES as Lambda (email-sender)
participant SES as SES
C->>S: Select tier & pay
S->>TY: Redirect with session_id
Note over TY: Phase 1: Instant translated defaults
TY->>L: POST {session_id, type: "subscription"}
L->>EC: Check session dedup
EC-->>L: Not found (first call)
L->>S: Get checkout session (expand: payment_link)
S-->>L: Session details + Payment Link metadata
L->>API: SELECT * FROM record_subscription(...)
API->>DB: Execute PG function
DB-->>API: {user_id, api_key, txn_id}
API-->>L: Result rows
L->>EC: Read email:frames (cached 5min)
L->>L: Assemble localized email from frames
L->>SQS: Enqueue pre-built email
L->>API: /admin/invalidate-key
L->>EC: Cache response (1hr dedup)
L-->>TY: {success, api_key, transaction_id}
Note over TY: Phase 2: Personalize with name/key
SQS-->>ES: Batch pickup (3-6s)
ES->>SES: Send email
SES-->>C: Receipt email arrives
receipt:session:{id} with 1-hour TTL prevents double-processing if the thank-you page calls twice (page refresh, back button).AddExpand("payment_link") to access Payment Link metadata. Reads: customer email, name, amount, payment status, customer ID, and metadata (tier from Payment Link metadata, locale from client_reference_id).metadata.locale → client_reference_id (passed from cpmp-lang localStorage via the checkout URL) → Stripe session locale → fallback "en".record_subscription(email, name, tier, amount, stripe_customer_id, stripe_subscription_id, lang, charge_id, metadata) via the admin API. The PG function handles user upsert, API key generation with tier-specific limits, Stripe ID linking, transaction recording, and LRS auto-enable for Unlimited/Lifetime tiers — all in a single atomic operation.email:frames, 5-minute local cache). Builds the complete HTML email (subject, heading, labels, values, footer) in the customer's language. Zero Bedrock calls.trinity-beast-email-queue). Takes ~20 ms. Falls back to direct SES if SQS fails./admin/invalidate-key on both api.cpmp-site.org and lrs.cpmp-site.org so the new key is immediately active.The thank-you page does not wait for the Lambda to render content. It uses a two-phase approach:
The LRS (Listener Reporting Service) add-on upgrades a subscriber from 10 reports/month to unlimited reports. It's a separate Stripe subscription linked to the same customer.
record_lrs_addon(email, name, amount, stripe_customer_id, stripe_subscription_id, lang, charge_id) via the admin API. The PG function finds the active API key by email, validates eligibility, sets lrs_enabled = true, links the LRS subscription ID, and records the transaction — all atomically./admin/invalidate-key on both api.cpmp-site.org and lrs.cpmp-site.org so the LRS upgrade takes effect immediately.Tier routing: The Stripe Payment Link metadata carries tier: lrs_addon. The Lambda reads this from the expanded Payment Link metadata on the checkout session and routes to the LRS handler. No special metadata key detection needed.
Donations follow a focused flow — no API key generation, no tier assignment. The Lambda records the donation via a PostgreSQL function and sends a personalized, localized receipt email with impact-specific messaging.
impact_type from Payment Link metadata (e.g., freedom, water, wheelchair, medical, bible, audio_bible, provisions, education, sewing, general).record_donation(email, name, amount, impact_type, lang, charge_id, stripe_customer_id) via the admin API. The PG function upserts the user, records the transaction with impact_type, and returns the transaction ID.impact:messages key, 5-minute local cache). Each of the 10 impact types has a unique, pre-translated message in all 12 languages describing what the donation specifically funds.| Type | Category | Payment Link |
|---|---|---|
freedom | Brick kiln liberation | Give → Save a Soul |
water | Clean water wells | Give → Clean Water |
wheelchair | Wheelchair provision | Give → Wheelchairs |
medical | Medical camps | Give → Medical |
bible | Bible distribution | Give → Word of Life |
audio_bible | Audio Bible tablets | Give → Audio Bible |
provisions | Food & essential supplies | Give → Provisions |
education | School sponsorship | Give → Education |
sewing | Sewing machine training | Give → Sewing |
general | Greatest need | Give → General Support |
100% of donation revenue funds freedom from brick kiln debt bondage and community development in Pakistan through Cross Power Ministries of Pakistan (CPMP).
When a donation charge is refunded, the Lambda identifies it as a donation (no API key found for the customer) and calls record_donation_refund(charge_id, refund_amount, refund_id). A localized refund confirmation email is sent to the donor. No API key revocation occurs (donors don't have API keys).
Stripe sends webhook events to https://receipt.cpmp-site.org/webhook (Route 53 → API Gateway → Lambda). Events are verified using the Stripe webhook signing secret and deduplicated by event ID in ElastiCache.
flowchart TD
S[Stripe Event] --> V{Verify Signature}
V -->|Invalid| R400[400 Invalid]
V -->|Valid| D{Duplicate Check}
D -->|Already processed| R200D[200 Already processed]
D -->|New event| Route{Event Type}
Route -->|customer.subscription.updated| SU[handleSubscriptionUpdated]
Route -->|customer.subscription.deleted| SD[handleSubscriptionDeleted]
Route -->|invoice.payment_failed| PF[handlePaymentFailed]
Route -->|invoice.paid| PR[handlePaymentRecovered]
Route -->|charge.refunded| RF[handleChargeRefunded]
Route -->|Other| Skip[Log & skip]
SU --> Mark[Mark processed in ElastiCache]
SD --> Mark
PF --> Mark
PR --> Mark
RF --> Mark
Mark --> R200[200 OK]
style S fill:#1e3a5f,stroke:#0f172a,color:#ffffff,font-weight:bold
style V fill:#7c3aed,stroke:#0f172a,color:#ffffff,font-weight:bold
style D fill:#7c3aed,stroke:#0f172a,color:#ffffff,font-weight:bold
style Route fill:#b45309,stroke:#0f172a,color:#ffffff,font-weight:bold
style SU fill:#065f46,stroke:#0f172a,color:#ffffff,font-weight:bold
style SD fill:#991b1b,stroke:#0f172a,color:#ffffff,font-weight:bold
style PF fill:#991b1b,stroke:#0f172a,color:#ffffff,font-weight:bold
style PR fill:#065f46,stroke:#0f172a,color:#ffffff,font-weight:bold
style RF fill:#991b1b,stroke:#0f172a,color:#ffffff,font-weight:bold
style Skip fill:#475569,stroke:#0f172a,color:#ffffff
style Mark fill:#1e3a5f,stroke:#0f172a,color:#ffffff,font-weight:bold
style R200 fill:#065f46,stroke:#0f172a,color:#ffffff,font-weight:bold
style R200D fill:#475569,stroke:#0f172a,color:#ffffff
style R400 fill:#991b1b,stroke:#0f172a,color:#ffffff,font-weight:bold
| Stripe Event | Handler | Action |
|---|---|---|
customer.subscription.updated |
handleSubscriptionUpdated |
Tier change (upgrade/downgrade) or status change (past_due → active) |
customer.subscription.deleted |
handleSubscriptionDeleted |
Cancellation — downgrade to free, disable LRS, clear Stripe IDs |
invoice.payment_failed |
handlePaymentFailed |
Set status to past_due, record payment_failed_at timestamp |
invoice.paid |
handlePaymentRecovered |
Restore status to active, clear payment_failed_at |
charge.refunded |
handleChargeRefunded |
Revoke API key, set status to refunded, record refund transaction, invalidate cache |
Error Handling: All webhook handlers return HTTP 200 to Stripe even on processing errors. This prevents Stripe from retrying and creating duplicate events. Errors are logged for manual review.
The Multi-Product Constraint. The Trinity Beast offers two distinct subscription product lines: LPO (pull API — Free, Pro, Enterprise, Unlimited, Lifetime) and Webhook Push (real-time delivery — Starter, Standard, Professional, Enterprise). Stripe's native subscription management assumes a single product hierarchy — its built-in plan switching, proration engine, and Customer Portal work within one product's price list. It cannot enforce cross-product boundaries, cannot prevent an LPO subscriber from "switching" to a Webhook tier, and cannot handle the two-product model where a single customer may have both an LPO subscription and a Webhook subscription simultaneously. We had to build the orchestration layer ourselves.
What Stripe handles vs. what we handle:
All plan changes are initiated from the Account Dashboard (/dashboard → Change Plan panel). The system determines which path to take based on the customer's current state:
| Scenario | Path | Mechanism | Handler |
|---|---|---|---|
| Free → Paid | Stripe Checkout (subscription mode) | No existing Stripe subscription exists — cannot prorate nothing. A new Checkout Session is created with the target tier's stripe_price_id. On completion, the receipt Lambda creates a new subscription in Aurora. |
createUpgradeCheckout() |
| Paid → Paid (Pro ↔ Enterprise ↔ Unlimited) |
Stripe Subscription Update API | Existing subscription is PATCHed with the new price ID and proration_behavior: create_prorations. Stripe calculates the proration automatically. Fires customer.subscription.updated webhook → receipt Lambda applies new limits. |
stripeUpdateSubscription() |
| Any → Lifetime | Stripe Checkout (payment mode, one-time) | A one-time Checkout Session is created for the net amount ($3,000 minus credit from the current tier's monthly price). On completion, the receipt Lambda creates a Lifetime key, cancels the existing subscription, and revokes the old key. | GoLifetimeHandler |
Free-tier users have no Stripe subscription to update. The dashboard detects stripe_subscription_id = NULL and creates a new Stripe Checkout Session in subscription mode:
POST /dashboard/api/change-plan with {"target_tier": "pro"}.createUpgradeCheckout().checkout_url — the frontend redirects the browser to Stripe./dashboard?upgrade=success.Existing account detection: The receipt Lambda checks if the email already has an active LPO key. If it does (which happens on this path — they have a free key), the Lambda detects the tier mismatch and handles it as an upgrade. The existing free key is superseded by the new paid key.
When a subscriber already has a Stripe subscription (any paid tier), the system uses the Stripe Subscription Update API for seamless proration:
POST /dashboard/api/change-plan with the target tier.tier_catalog with a valid stripe_price_idPATCH /v1/subscriptions/{id} with the new price, tier metadata, and proration_behavior: create_prorations.customer.subscription.updated → receipt Lambda applies the new tier limits in Aurora.Proration is automatic. Stripe calculates the prorated credit for unused time on the old plan and charges the prorated amount for the new plan on the same billing cycle. Upgrades result in an immediate additional charge (prorated difference). Downgrades result in a credit applied to the next invoice. The subscriber never needs to take action — the billing adjusts seamlessly.
Lifetime is a $3,000 one-time purchase that permanently unlocks unlimited access. The system credits the customer's current tier monthly price as a thank-you for their existing commitment:
GET /dashboard/api/lifetime-quote to display the credit and net amount.price_cents from tier_catalog (e.g., Enterprise = $100 credit).POST /dashboard/api/go-lifetime.payment mode (one-time) with unit_amount = net_cents.tier=lifetime, upgrade_from=enterprise, credit_cents=10000, source=dashboard-go-lifetime.stripe_lrs_subscription_id), that subscription is cancelled too — Lifetime includes LRS free, so the addon becomes redundant the moment Lifetime is active.Why credit from tier_catalog and not from the last transaction? Transaction amounts can be stale — a customer who switched tiers mid-month may have prorated charges that don't represent their plan's actual cost. The tier_catalog price is the canonical, current monthly cost for their plan. Simple and predictable: Enterprise = $100 credit, Unlimited = $300 credit, Pro = $30 credit.
Setting lrs_enabled = true by tier is not the same as stopping the bill for a separate addon subscription that the tier just made unnecessary. Both this Go Lifetime path and the ordinary Change Plan path (upgrading to Unlimited) call cancelSubscriptionWithProratedRefund() (trinity-beast-receipt-lambda/cmd/handler/main.go) against StripeLRSSubscriptionID whenever the destination tier already includes LRS. This does two things, not one:
current_period_start/current_period_end — a clean cancellation still leaves the customer having pre-paid for time on the addon they will never use, and that time is owed back.The same function is applied to the main plan's own subscription in the Go Lifetime path — a bare cancel with no refund credit only ever accounted for the current period's discount against the $3,000 price, not for unused time already paid for within that period.
Found live, not in review. This gap surfaced on 2026-08-04 when Cory upgraded his own account to Lifetime and found his separately-billed LRS addon subscription still active and billing afterward. The standing rule this establishes: any handler that flips an entitlement flag to true by tier, where a separately-billed subscription for that same capability might already exist, must also resolve that subscription — cancel it, and refund unused time. Setting the flag alone leaves a redundant charge running silently.
A customer cannot switch between product lines through the plan change flow:
The handler detects webhook tiers by the webhook_ prefix on tier names. A customer who wants both products creates a separate subscription for each — they appear as separate product cards on the dashboard, billed independently.
Existing subscribers who visit the public subscription page (subscribe-listener.html or webhook.html) must not create duplicate subscriptions. A three-layer defense prevents this:
| Layer | Mechanism | When It Fires |
|---|---|---|
| 1. Server-side interstitial | The /checkout endpoint (served on api.cpmp-site.org) renders a lightweight HTML page that checks localStorage for an active dashboard session token (cpmp_user.token). If found, redirects to /dashboard?panel=change-plan. If not, proceeds to Stripe. |
Every paid subscription/webhook CTA click |
| 2. Receipt Lambda detection | If a checkout completion arrives for an email that already has an active key in the same product family, the Lambda detects the duplication. It sends an explanatory email with options (use dashboard to change plan, or use a different email for a separate account). | Post-payment, if layer 1 was bypassed |
| 3. Auto-refund | If the duplicate checkout had a payment (paid tier), the Lambda automatically refunds the payment_intent via the Stripe API. The customer is never charged for a subscription they cannot use. |
Immediately after layer 2 detects duplication |
Why server-side? The subscription page lives on cpmp-site.org but the dashboard session is stored in localStorage on api.cpmp-site.org. Cross-origin localStorage access is impossible. The interstitial page is served on api.cpmp-site.org (same origin as the dashboard) so it CAN read the session token. This is why the guard must be server-side — a client-side check on cpmp-site.org would always see an empty localStorage.
customer.subscription.updated)When Stripe fires a customer.subscription.updated event (from a dashboard-initiated plan change or from the Stripe Customer Portal), the receipt Lambda processes it:
metadata.tier from the subscription — this is the new tier (set by the dashboard handler when it updated the subscription).stripe_customer_id.query_limit, rate_limit_qps, burst_limit, minimum_wait_seconds) from rate_limit_template.webhook_subscriptions with new interval_seconds and max_assets.lrs_enabled = truelrs_enabled = false (unless they have a separate LRS add-on: stripe_lrs_subscription_id != '')If metadata.tier is unchanged but the subscription status changed (e.g., past_due → active), the handler updates only subscription_status.
Every tier change (upgrade or downgrade) sends a branded dark-theme confirmation email to the subscriber. The email includes:
The comparison data is read from tier_catalog in Aurora (via admin API) at send time. Improvements in the new tier are highlighted in green. The email is fully assembled from pre-translated frame labels in the subscriber's preferred_lang — zero Bedrock cost, instant assembly, deterministic output. Enqueued to SQS for async delivery.
| Tier | Can Switch? | Reason |
|---|---|---|
| Lifetime | No | Permanent unlimited access. They paid forever — they get forever. No downgrade path exists. |
| Partner | No | Managed separately via AWS PrivateLink agreement. Not a billing relationship. |
| Free | Upgrade only | Requires Checkout (no existing subscription to prorate). Cannot "downgrade" to free — that's a cancellation (Section 8). |
Every query that resolves the customer's "current" LPO or Webhook subscription explicitly excludes translation keys:
AND COALESCE(k.service_type, 'prices') != 'translation'
AND COALESCE(k.is_translation, false) = false
Translation is a completely independent product line with its own Stripe customer, its own API key, and its own billing lifecycle. It never participates in LPO/Webhook plan switching, credit calculations, or proration. This separation is enforced in every handler: ChangePlanHandler, AvailablePlansHandler, LifetimeQuoteHandler, GoLifetimeHandler, and resolveStripeCustomerID.
When a subscription is cancelled (via Customer Portal or Stripe dashboard), Stripe sends a customer.subscription.deleted event.
free with free-tier limits.subscription_status set to canceled.lrs_enabled set to false.stripe_subscription_id and stripe_lrs_subscription_id cleared.subscription.id to stripe_lrs_subscription_id.lrs_enabled set to false.stripe_lrs_subscription_id cleared.Not all tier transitions are self-service. Some require admin intervention — typically when a customer contacts support to cancel, request a refund, or when the admin needs to correct a tier that was set incorrectly.
# Full command
bash scripts/kcc.sh admin-tier-reset <email> <target_tier> [--refund] [--refund-amount N.NN] [--key-id uuid]
# Example: Lifetime cancellation with full refund
bash scripts/kcc.sh admin-tier-reset customer@email.com free --refund --key-id bb1fdafb-...
The command performs these steps in order:
--refund: Issues Stripe refund of the most recent charge (via POST /admin/refund). The charge.refunded webhook event fires, and the receipt Lambda sends the customer a branded refund confirmation email automatically.tier_change_history (source: admin-tier-reset).rate_limit_template values for the target tier — query_limit, rate_limit_qps, burst_limit, burst_tokens, minimum_wait_seconds.is_rate_limited, is_billing_exempt, subscription_status.current_usage to 0 (fresh start on new tier).stripe_customer_id — Dashboard login and future purchases continue to work.user_id — Account association stays intact.These are intentionally decoupled. A refund returns money. A subscription cancellation stops future billing. They can happen independently:
admin-tier-reset with --refund for the most recent charge.--refund-amount for a partial refund without any tier change (set target tier = current tier).Every admin-initiated tier change is fully auditable:
tier_change_history — Records previous_tier, new_tier, source = 'admin-tier-reset', and timestamp.transactions — Records refund with type = 'admin_refund' and negative amount.initiated_by: admin, key_id, and reason.-- Query the admin tier change history for a customer
SELECT previous_tier, new_tier, source, changed_at, notes
FROM tier_change_history
WHERE api_key_id = '<uuid>'
ORDER BY changed_at DESC;
stateDiagram-v2
[*] --> Active: Subscription created
Active --> PastDue: invoice.payment_failed
PastDue --> Active: invoice.paid (recovered)
PastDue --> Blocked: Grace period expired
Blocked --> Active: invoice.paid (recovered)
Active --> Canceled: customer.subscription.deleted
PastDue --> Canceled: customer.subscription.deleted
Canceled --> [*]: Downgraded to free
classDef active fill:#065f46,stroke:#0f172a,color:#ffffff,font-weight:bold
classDef pastdue fill:#b45309,stroke:#0f172a,color:#ffffff,font-weight:bold
classDef blocked fill:#991b1b,stroke:#0f172a,color:#ffffff,font-weight:bold
classDef canceled fill:#475569,stroke:#0f172a,color:#ffffff,font-weight:bold
class Active active
class PastDue pastdue
class Blocked blocked
class Canceled canceled
invoice.payment_failed)subscription_status set to past_due.payment_failed_at set to current timestamp (only on first failure — uses COALESCE to preserve the original date).The LPO server checks the grace period on every price request for past_due subscribers:
payment_grace_period_days application parameter (default: 7 days).time.Since(payment_failed_at) < grace_period, the subscriber continues to have full access.invoice.paid)past_due.subscription_status restored to active.payment_failed_at cleared (set to NULL).When a charge is refunded via the Stripe Dashboard or mobile app, Stripe sends a charge.refunded webhook event. The Lambda handles two scenarios: subscription refunds (API key revocation) and donation refunds (record-only).
charge.refunded — triggered when you process a refund in the Stripe Dashboard or mobile app.customer ID to find the API key via lookupByStripeCustomer (admin API query).record_refund(api_key_id, charge_id, refund_amount, refund_id, customer_id, tier) via the admin API. The PG function revokes the API key, sets status to 'refunded', and records the refund transaction atomically.invalidateAPIKeyCache on all LPO/LRS servers so the revoked key stops working immediately.lookupByStripeCustomer returns no result (the customer is a donor, not a subscriber).record_donation_refund(charge_id, refund_amount, refund_id) via the admin API. Records the refund against the original donation transaction.Policy: All giving is non-refundable as stated on the site. Refunds are processed only in extenuating circumstances at the discretion of the administrator. The automated handler ensures that when a refund does occur, the system responds immediately — no manual cleanup required.
Partial vs. Full Refunds: The handler fires on any refund event regardless of amount. Both partial and full refunds result in API key revocation for subscription refunds. If a partial refund should not revoke the key, the administrator should manually re-enable it in Aurora after the refund is processed.
Every lifecycle event that changes API key data triggers cache invalidation to ensure changes take effect immediately across all containers.
The Lambda calls GET /admin/invalidate-key?key={api_key} on both endpoints:
https://api.cpmp-site.org/admin/invalidate-key — LPO containers (BeastMain, BeastMirror, BeastLRS)https://lrs.cpmp-site.org/admin/invalidate-key — LRS containerEach endpoint removes the API key from:
apikey:{key} hashThe next request for that API key triggers a fresh read from Aurora, which now has the updated tier, limits, LRS status, and subscription status.
Why public endpoints? The Lambda is not in the VPC. Using the public ALB endpoints avoids the $32/month NAT gateway cost. The admin key header (X-Admin-Key) authenticates the request. This same pattern applies to ALL admin API calls from the Lambda (SQL queries, cache invalidation).
Every email in the subscription lifecycle is assembled from pre-translated frames and delivered asynchronously via SQS. Zero Bedrock calls, zero SES waits in the receipt Lambda's response path.
Receipt Lambda → builds email from Valkey frames → SQS (trinity-beast-email-queue) → Email-Sender Lambda → SES → Customer inbox
email:frames)A single JSON blob in Valkey containing 12 languages × 45 keys. Every email label (subject, heading, subheading, field labels, footer, link text) is pre-translated and stored. The receipt Lambda reads this on startup (5-minute local cache) and assembles emails by combining frame labels with dynamic values (name, amount, API key, date).
| Frame Key | Example (English) | Used In |
|---|---|---|
subscription_subject | Subscription Confirmed | Subscription receipt |
donation_heading | Thank You for Your Gift | Donation receipt |
label_api_key | API Key | Subscription/webhook receipts |
refund_subject | Refund Processed | Refund confirmation |
plan_switch_heading | Plan Change Confirmed | Tier upgrade/downgrade |
footer_mission | 100% of revenue funds freedom... | All emails |
impact:messages)A separate Valkey key containing 12 languages × 10 impact types. Each impact type has a unique, descriptive message explaining what the donor's gift specifically provides. Source of truth: s3://trinity-beast-website-east2/data/impact-messages.json.
The receipt Lambda enqueues a complete, ready-to-send message. The email-sender Lambda does zero processing — it just delivers what it receives:
{
"to": "subscriber@example.com",
"subject": "サブスクリプション確認",
"html_body": "<html>...complete email...</html>",
"lang": "ja",
"timestamp": "2026-07-24T09:15:00Z"
}
Both Valkey keys are refreshed nightly by the BeastReconciler sync job:
email:frames ← s3://trinity-beast-website-east2/data/email-frames.json (via syncEmailFrames())impact:messages ← s3://trinity-beast-website-east2/data/impact-messages.json (via syncImpactMessages())Edit the JSON on S3 → push to Valkey manually (or wait for nightly sync) → live without redeploy.
If SQS itself is unavailable, the receipt Lambda falls back to direct SES delivery (sendEmailDirect). The customer always receives their receipt — the pipeline is belt and suspenders.
email:tpl:*Not every lifecycle email originates from the receipt Lambda. Refund confirmations issued through /admin/refund, reactivation notices, and dashboard magic links are built by the LPO server, which uses a separate template registry: six per-template Valkey hashes under email:tpl:*, read through a shared EmailLoader with a 5-minute in-memory cache and English fallback.
| Lifecycle Email | Origin | Template Source |
|---|---|---|
| Subscription / donation / LRS / webhook receipt | Receipt Lambda | email:frames |
| Plan switch confirmation | Receipt Lambda | email:frames |
| Stripe-webhook refund confirmation | Receipt Lambda | email:frames |
| Admin-issued refund confirmation | LPO server | email:tpl:refund (22 keys × 12 langs) |
| Reactivation confirmation | LPO server | email:tpl:reactivation (8 keys × 12 langs) |
| Dashboard magic link | LPO server | email:tpl:magic-link (7 keys × 12 langs) |
| TBTS welcome | LPO server | email:tpl:tbts-welcome (20 keys × 12 langs) |
Both stores are refreshed nightly from S3 by the BeastReconciler and can be pushed on demand with bash scripts/kcc.sh push-email-templates. Copy edits go live within five minutes without a redeploy. Full detail lives in the Multi-Lingual Communications guide.
Why two stores. The receipt Lambda is short-lived and must enqueue an email in milliseconds, so it loads one 45-key blob and caches it for the container's lifetime. The LPO server is long-running with many distinct email types, so per-template hashes let each send fetch only the keys it needs. Both keep their original hardcoded Go strings compiled into the binary as a last-resort fallback — a Valkey outage degrades localization, never delivery.
All database writes from the receipt Lambda go through dedicated PostgreSQL functions. Each function encapsulates a complete business operation (user upsert + key creation + transaction recording) in a single atomic call. The Lambda calls these via adminQuery(ctx, "SELECT * FROM function_name($1,$2,...)") over the LPO admin API.
| Function | Purpose | Returns |
|---|---|---|
record_subscription |
New LPO subscription: upsert user, generate API key with tier limits, link Stripe IDs, record transaction. Auto-enables LRS for Unlimited/Lifetime. | user_id, api_key, api_key_id, txn_id |
record_donation |
Donation: upsert user, record transaction with impact_type, store preferred_lang. |
user_id, txn_id |
record_lrs_addon |
LRS add-on: find API key by email, validate eligibility, set lrs_enabled=true, link LRS subscription ID, record transaction. |
api_key_id, txn_id |
record_webhook_subscription |
New webhook subscription: upsert user, generate API key with webhook tier limits, create webhook config, link Stripe IDs, record transaction. | user_id, api_key, api_key_id, txn_id |
record_refund |
Subscription refund: revoke API key, set subscription_status='refunded', record refund transaction. |
txn_id |
record_donation_refund |
Donation refund: record refund against original donation transaction by charge_id. |
txn_id |
Why functions over raw SQL? Each function is a single atomic operation — no partial states, no race conditions, no multi-statement transactions over HTTP. The Lambda makes one admin API call, gets back a clean result set. If the function fails, nothing is committed. If the network drops after the function succeeds, idempotency protection prevents re-processing.
Idempotency within functions: record_subscription uses NULLIF(charge_id, '') to handle Stripe subscription sessions where payment_intent is null (subscriptions charge via invoices, not payment intents). This prevents unique constraint violations on stripe_charge_id.
| Column | Type | Purpose |
|---|---|---|
stripe_customer_id | TEXT | Stripe customer ID for webhook lookups |
stripe_subscription_id | TEXT | Main LPO subscription ID |
stripe_lrs_subscription_id | TEXT | Separate LRS add-on subscription ID |
subscription_status | TEXT | active, past_due, canceled (default: active) |
tier_effective_date | TIMESTAMPTZ | When the current tier took effect |
payment_failed_at | TIMESTAMPTZ | First payment failure timestamp (NULL when healthy) |
lrs_enabled | BOOLEAN | Whether unlimited LRS reports are enabled |
tier | TEXT | free, pro, enterprise, unlimited, lifetime, partner |
query_limit | INTEGER | Monthly query limit for the tier |
rate_limit_qps | INTEGER | Queries per second limit |
burst_limit | INTEGER | Token bucket burst capacity |
burst_tokens | NUMERIC | Current token bucket balance |
minimum_wait_seconds | NUMERIC | Minimum time between requests when throttled |
| Index | Purpose |
|---|---|
idx_api_keys_stripe_customer_id | Fast lookup by Stripe customer ID (partial: WHERE stripe_customer_id IS NOT NULL) |
idx_api_keys_stripe_subscription_id | Fast lookup by Stripe subscription ID (partial: WHERE stripe_subscription_id IS NOT NULL) |
Several api_keys columns are legitimately NULL for a freshly created key: name when the caller supplied no label, and last_used and last_success until the key makes its first request. Go's lib/pq driver cannot scan a SQL NULL into a non-pointer string or time.Time. The scan returns an error, and because API key validation treats any lookup error as a validation failure, a perfectly valid key returns 401 Invalid API key.
This failure mode is deceptive. The key exists in Aurora. It is not revoked. Its tier, quota, and subscription status are all correct. Querying the row directly returns exactly what you expect. But every request with it is rejected, and the log line says nothing about NULL — only that the key was invalid. The symptom points at authentication; the cause is type marshalling three layers down.
Every nullable column read on the API key validation path must be made scan-safe at one of two levels:
| Column Type | Technique | Applied As |
|---|---|---|
| Nullable text | COALESCE at the SQL level | COALESCE(name, ''), COALESCE(response_format, 'tbc'), COALESCE(api_lang, 'en'), COALESCE(service_type, 'prices') |
| Nullable timestamp | sql.NullTime scan target, hydrated after scan | last_used, last_success, tier_effective_date, payment_failed_at |
Timestamps use sql.NullTime rather than COALESCE because the distinction matters downstream: a key that has never been used is semantically different from one used at the Unix epoch. The Valid flag preserves that difference; a COALESCE default would erase it.
// Nullable timestamps scanned safely, then hydrated
var lastUsed, lastSuccess sql.NullTime
err := row.Scan(
&data.ID, &data.UserID, &data.Name, // Name is COALESCE'd in SQL
&data.Tier, &data.QueryLimit, &data.CurrentUsage,
&lastUsed, &data.CreatedAt, &data.Revoked,
// ...
&lastSuccess,
)
if lastUsed.Valid {
data.LastUsed = lastUsed.Time
}
if lastSuccess.Valid {
data.LastSuccess = lastSuccess.Time
}
Applies to both query paths. API key lookup runs through an inline query and a prepared statement in internal/database/dbx/queries.go. Both must carry identical COALESCE wrapping and identical scan targets. A fix applied to only one path produces an intermittent bug that depends on whether the prepared-statement registry was warm — far harder to diagnose than a consistent failure.
The same discipline applies when adding any new nullable column to api_keys. If the validation query selects it, wrap it or scan it as a null type before deploying — otherwise every key created after the migration authenticates correctly until the column is populated, and fails afterward.
Each subscription receipt email includes a link to the Stripe Customer Portal, generated dynamically by the Lambda using billingportal.Session. The portal allows subscribers to:
Portal URL: Generated per-customer at receipt time. Each URL is a one-time session link that expires. The portal is hosted entirely by Stripe — no custom UI needed.
Every customer has access to a unified Account Dashboard at https://api.cpmp-site.org/dashboard. The dashboard is a single-page application with a flat sidebar — every customer sees the same panels regardless of what products they subscribe to. Empty states with CTAs enable product discovery through visibility.
localStorage (cpmp_site.token). Validated on every page load against the server./dashboard/api/account endpoint returns a products array classifying all the customer's API keys by service type (LPO, Translation, Webhook, Partner).Panels for inactive products show friendly descriptions with secondary CTA buttons linking to the relevant product page. This replaces the old "No API key" error messages. Every product is always visible — the sidebar is a discovery surface, not a gated fortress.
Full Documentation: See Account Dashboards for complete panel specifications, API endpoints, localStorage schema, and admin features.