Workflows
The Pool uses a no-account, email-based pledge management system. Backers save a payment method through Stripe in The Pool’s on-site payment step, manage pledges via order-scoped magic links, and are only charged if the campaign is funded.
Key Differentiators
- No accounts — Email + payment info only (no registration)
- Magic link management — Cancel, modify, or update payment method via an order-scoped email link
- All-or-nothing — Cards saved now, charged only if goal is met
- Optional platform tip — 0% to 15% The Pool tip (default 5%) added to totals but excluded from campaign progress
- Worker-owned email — All supporter email comes from Resend
- Film-focused — Designed for creative crowdfunding
Campaign State Machine
upcoming → live → post
| State | UX | Actions |
|---|---|---|
upcoming |
Buttons disabled, “Coming soon” | Countdown to launch, optional one-time launch reminder signup |
live |
Pledge buttons active | Cards saved via The Pool’s on-site Stripe payment step |
post |
Campaign closed | Charges processed (if funded) |
System Components
| Component | Role |
|---|---|
| First-party cart | Browser-owned cart UI and checkout review state |
| Stripe | Checkout Sessions in setup mode (custom on-site payment step) + PaymentIntents (charge later) |
| Cloudflare Worker | Backend: checkout, webhooks, pledge storage (KV), combined live reads, stats, auto-settle scheduler |
| Jekyll | Static pages + campaign markdown |
| Admin dashboard | Private browser workspace for settings, campaigns, add-ons, reports, analytics, supporters, marketing links, and users |
Pledge Lifecycle
1. BROWSE → Visitor views campaign, adds tier to the first-party cart, adjusts optional tip
2. REVIEW → First-party cart drawer shows pledge review, tip state, and immediate pricing
3. START → Worker canonicalizes the cart via `/checkout-intent/start`, reserves scarce tiers when needed, and creates a setup-mode Stripe Checkout Session
4. SAVE CARD → The existing checkout sidecar keeps the visitor on-site, mounts secure Stripe payment UI, and saves the payment method (no charge)
5. CONFIRM → Stripe confirms the setup, then Worker persists one pledge per campaign in KV, sends campaign-specific supporter email(s), and refreshes live campaign reads before success UX completes
6. MANAGE → Backer uses magic link to cancel/modify/update card
7. DEADLINE → Worker scheduler checks campaigns after midnight in the platform timezone
8. CHARGE → If funded + deadline passed: aggregate by email within each campaign, charge once per supporter per campaign, and store actual Stripe fee/net data when Stripe returns balance transaction details
9. COMPLETE → Update pledge_status to 'charged' or 'payment_failed'
Pledge Storage (Cloudflare KV)
Pledges are stored in Cloudflare KV. Key patterns:
| Key | Contents |
|---|---|
pledge:{orderId} |
Full pledge data (email, amount, tier, Stripe IDs, status, history) |
email:{email} |
Array of order IDs for that email |
stats:{campaignSlug} |
Aggregated totals (pledgedAmount, pledgeCount, tierCounts, supportItems) |
tier-inventory:{campaignSlug} |
Claim counts for limited tiers |
campaign-pledges:{campaignSlug} |
Campaign-scoped pledge index for reports, settlement, rebuilds, and admin reads |
pending-extras:{orderId} |
Temporary storage for support items/custom amount during checkout |
pending-tiers:{orderId} |
Temporary storage for additional tiers when Stripe metadata would be too large |
checkout-intent:{orderId} |
Canonicalized checkout payload used to fan bundled checkout into campaign-scoped pledges |
launch-reminder:{campaignSlug}:{emailHash} |
Upcoming-campaign reminder signup and opt-in metadata |
launch-reminder-suppressed:{campaignSlug}:{emailHash} |
Campaign-scoped reminder unsubscribe marker |
launch-reminder-sent:{campaignSlug}:{emailHash} |
Launch reminder send idempotency marker |
launch-reminder-dispatch:{campaignSlug} |
Bounded dispatch job cursor for a campaign that just became live |
launch-reminder-dispatch-queue:v1 |
Queue-state marker that lets idle launch reminder scheduled ticks skip dispatch list scans |
supporter-email-retry:{orderId} |
Queued supporter confirmation email retry payload |
supporter-email-retry-queue:v1 |
Queue-state marker with the next due supporter email retry time |
add-on-inventory-sold:v1 |
Sold-count projection for platform add-on inventory |
admin-users:v1 |
Runtime dashboard users saved from Settings -> Users |
admin-marketing-referrals:{campaignSlug} |
Saved referral code metadata for the dashboard Marketing tab |
Scarce-tier reservations and committed claim state now live in the per-campaign Durable Object coordinator rather than KV. tier-inventory:{campaignSlug} remains the public projection used by /inventory/:slug and /live/:slug.
Pledge record:
{
"orderId": "pledge-1234567890-abc123",
"email": "[email protected]",
"campaignSlug": "hand-relations",
"tierId": "producer-credit",
"tierQty": 1,
"additionalTiers": [{ "id": "frame-slot", "qty": 2 }],
"supportItems": [{ "id": "location-scouting", "amount": 50 }],
"customAmount": 25,
"tipPercent": 5,
"tipAmount": 250,
"subtotal": 5000,
"tax": 394,
"shipping": 300,
"amount": 5944,
"shippingAddress": { "name": "Jane Doe", "address1": "123 Main St", "city": "Albuquerque", "province": "NM", "postalCode": "87101", "country": "US" },
"stripeCustomerId": "cus_xxx",
"stripePaymentMethodId": "pm_xxx",
"pledgeStatus": "active",
"charged": false,
"history": [
{ "type": "created", "subtotal": 5000, "tax": 394, "shipping": 300, "tipPercent": 5, "tipAmount": 250, "amount": 5944, "tierId": "producer-credit", "tierQty": 1, "customAmount": 25, "at": "2026-01-15T12:00:00Z" }
]
}
Support items and custom amounts:
supportItems— Array of{ id, amount }for production phase contributionscustomAmount— Dollar amount for “no reward” custom support additionsadditionalTiers— Array of{ id, qty }for multi-tier pledges (whensingle_tier_only: false)tipPercent/tipAmount— Optional The Pool platform tip stored separately from campaign subtotal- Bundled multi-campaign checkouts are persisted as separate pledge records, one per campaign
History entries: Each history entry tracks a pledge event with full context:
type—created,modified, orcancelledsubtotal/subtotalDelta— Pre-tax amount (or delta for modifications)tipAmount/tipAmountDelta— Platform tip amount (or delta)tipPercent— Selected tip percentage after this eventtax/taxDelta— Tax amount (or delta)amount/amountDelta— Total with tax + shipping + tip (or delta)shipping/shippingDelta— Stored shipping amount (or delta, including live-quote, fallback, or free-shipping changes)tierId,tierQty,additionalTiers— Tier state after this eventcustomAmount— Custom support amount (if present)at— ISO timestamp
Status values: active, cancelled, charged, payment_failed
Charged pledges can also carry Stripe financial metadata:
stripePaymentIntentIdstripeChargeIdstripeBalanceTransactionIdstripeFinancials.sourcestripeFinancials.grossAmountstripeFinancials.feeAmountstripeFinancials.netAmount
Dashboard Analytics prefers those actual fee/net values for charged pledges and falls back to estimates only for active pledges or older charged rows that have not been backfilled.
Magic Link Tokens
Stateless HMAC-signed tokens (no database needed):
Payload:
{
"orderId": "pool-intent-abc123",
"email": "[email protected]",
"campaignSlug": "hand-relations",
"exp": 1754000000
}
Token format: base64url(payload).base64url(HMAC-SHA256(payload, secret))
Verification:
- Decode and verify signature
- Check expiry
- Resolve the authorized
orderId - Fetch pledge from KV and cross-check email + campaign
Each token only authorizes its own order. A valid link no longer grants email-wide access to every pledge on the same address, and a valid token without a real backing pledge now fails closed instead of returning a synthetic placeholder.
Worker API Routes
POST /checkout-intent/start
Create a setup-mode Stripe Checkout Session from the first-party cart state for the on-site payment step.
Request:
{
"campaignSlug": "hand-relations",
"items": [
{ "id": "hand-relations__producer-credit", "quantity": 1 }
],
"tipPercent": 5
}
Response:
- custom mode:
{ checkoutUiMode, sessionId, clientSecret, publishableKey, orderId } - hosted fallback:
{ checkoutUiMode: "hosted", url }
If custom checkout is selected but the current environment does not have a Stripe publishable key, the Worker uses the hosted fallback response instead of failing the checkout start.
Data flow:
- Cart.js passes the selected tip percent plus the current first-party cart items
- Worker reconstructs the cart shape from first-party items and canonical campaign rules
- Worker validates campaign state, single-tier rules, threshold gates, and scarce-tier availability
- For limited tiers, Worker reserves scarce inventory through the per-campaign coordinator, then stores any overflow tier/support-item metadata in temp KV (
pending-tiers:*,pending-extras:*) and creates a setup-mode Stripe Checkout Session - In custom UI mode, the existing second checkout sidecar mounts secure Stripe payment UI on-site; physical checkouts also capture shipping details during that step
- Worker treats webhook persistence as the source of truth, with a first-party recovery path available for local or delayed-completion cases so the sidecar does not claim success before the pledge is actually persisted
- On persistence, Worker fetches any temp metadata, extracts shipping details from Stripe, computes
subtotal + tax + shipping + tip, persists one pledge per campaign, and confirms any held limited-tier reservations through the per-campaign Durable Object coordinator - After persistence succeeds, the client invalidates campaign live-stat caches and writes a short-lived refresh marker so restored tabs and follow-up page loads fetch fresh totals
Limited-tier availability decisions now come from the coordinator’s reservation-aware state on write paths, while /inventory/:slug and /live/:slug continue reading the public KV projection only.
The Worker does not trust client-submitted tier names, quantities, support-item amounts, or amountCents. /checkout-intent/start now reserves scarce inventory before the payment step completes, and persistence confirms those reservations. Older campaigns do not need a migration job because claimed inventory can rebuild from pledge truth, and successful persistence can still fall back to a fresh coordinator claim if no preexisting reservation exists.
Content Rendering Safety
- Long-form campaign text is sanitized before Markdown rendering and then post-processed to neutralize unsafe link schemes.
- Structured embeds are only rendered when their
srcresolves to an exact approved provider origin/path. - Campaign-content audits still protect
_campaigns/*.md, but the render layer enforces the same rules so forks and future content sources do not rely on audits alone.
POST /webhooks/stripe
Handle checkout.session.completed:
- Extract
payment_methodandcustomerfrom SetupIntent - Fetch
supportItems,customAmount, and additional tiers from temp KV when needed - Store one pledge per campaign in KV with status
active(includes support items, custom amount, shipping fee, tip, and shipping address) - Update live stats (pledgedAmount, tierCounts, supportItems)
- Confirm held limited-tier reservations, or claim through the serialized coordinator if the pledge predates reservation-aware checkout start
- Generate magic link token
- Send campaign-specific supporter confirmation email(s)
Webhook idempotency is committed only after successful pledge persistence so transient failures can retry safely.
GET /pledges?token=...
Read the pledge collection available to a magic link session.
Current behavior: a token returns only its own authorized order.
GET /pledge?token=...
Read pledge details for magic link management page.
If the token is valid but its pledge record no longer exists, this route returns 404 instead of synthesizing a placeholder pledge.
Response:
{
"campaignSlug": "hand-relations",
"orderId": "xxx",
"email": "[email protected]",
"amount": 5000,
"tierId": "producer-credit",
"pledgeStatus": "active",
"canModify": true,
"canCancel": true,
"canUpdatePaymentMethod": true,
"deadlinePassed": false
}
Status values: active, cancelled, charged, payment_failed
Flag logic:
canModify/canCancel:trueonly ifpledgeStatus === 'active'AND!chargedAND deadline not passedcanUpdatePaymentMethod:trueif!charged(allowed even after deadline for failed payment recovery)deadlinePassed:trueif campaign deadline has passed in the platform timezone
POST /pledge/cancel
Cancel an active pledge.
Request: { token }
Validation:
- Rejects if pledge is charged
- Rejects if campaign deadline has passed
Actions:
- Mark pledge as cancelled in KV, update stats, release tier inventory
- Send cancellation confirmation email
- If no remaining active pledges for this email/campaign → clear
email:{email}mapping from KV (revokes community access)
POST /pledge/modify
Change tier or amount.
Request: { token, orderId, ...changes }
Validation:
- Rejects if pledge is charged
- Rejects if campaign deadline has passed (via
isCampaignLivecheck) - Rejects if
orderIddoes not match the token’s authorized order - Rebuilds totals from stored pledge state plus campaign definitions instead of trusting client money fields
Action: Update pledge in KV, adjust stats delta, swap tier inventory
POST /pledge/payment-method/start
Update saved payment method.
Request: { token }
Response:
- custom mode:
{ checkoutUiMode, sessionId, clientSecret, publishableKey } - hosted fallback:
{ checkoutUiMode: "hosted", url }
Data flow:
- Manage Pledge validates the magic-link token and active pledge state
- Worker creates a setup-mode Stripe Checkout Session for payment-method refresh
- In custom mode, the existing Update Card modal mounts Stripe’s secure payment UI on-site
- Worker keeps webhook persistence as the source of truth, with the same guarded completion-recovery path available for delayed local webhook delivery
- On success, the pledge record updates to the newly saved payment method and
payment_failedretries can charge again immediately
GET /stats/:campaignSlug
Get live pledge statistics for a campaign.
GET /live/:campaignSlug
Get the combined public live snapshot for a campaign.
Response shape:
{
"stats": { "pledgedAmount": 1200, "pledgeCount": 3 },
"inventory": {
"tiers": {
"frame-slot": { "limit": 1000, "claimed": 2, "remaining": 998 }
}
}
}
Campaign pages and the Manage Pledge UI prefer this endpoint so cold loads burn one Worker request instead of separate stats and inventory reads. The browser then caches the result in localStorage for the configured TTL.
Response:
{
"campaignSlug": "hand-relations",
"pledgedAmount": 380000,
"pledgeCount": 42,
"tierCounts": { "producer-credit": 10, "frame-slot": 32 },
"goalAmount": 25000,
"percentFunded": 15,
"updatedAt": "2025-01-15T12:00:00Z"
}
POST /stats/:campaignSlug/recalculate
Recalculate stats from all pledges in KV (admin only).
Headers: Authorization: Bearer ADMIN_SECRET
POST /admin/rebuild
Trigger a GitHub Pages rebuild (for state transitions).
Headers: Authorization: Bearer ADMIN_SECRET
Request: { "reason": "campaign-state-change" } (optional)
POST /admin/broadcast/announcement
Send a custom announcement email with optional CTA link to all campaign supporters.
Headers: Authorization: Bearer ADMIN_SECRET
Request:
{
"campaignSlug": "worst-movie-ever",
"subject": "Submissions close March 6th!",
"heading": "Last call for submissions!",
"body": "The deadline is this Thursday at midnight in the platform timezone.",
"ctaLabel": "Submit Your Reward",
"ctaUrl": "https://example.com/submit",
"dryRun": true
}
Response: { success, campaignSlug, subject, sent, failed, errors }
Fields:
subject(required) — Email subject line body; delivery formats it as{Subject} | {Campaign Title}heading(optional) — Email heading (defaults to subject if omitted)body(required) — Message body textctaLabel+ctaUrl(optional) — Adds a prominent button linking to the URLdryRun(optional) — Returns recipient list without sending
Browser Admin Dashboard
The private dashboard is available at /admin/ and /es/admin/. It uses magic-link sign-in and a cookie-backed Worker session; browser code never receives ADMIN_SECRET.
Primary flows:
- Dashboard summary, analytics, reports, supporters, content loads, and content previews are read-only browsing flows.
- Campaign content/settings and platform settings/add-ons publish through Worker validation and GitHub-backed commits.
- Settings -> Users saves directly to Worker KV at
admin-users:v1. - Saved referral codes in Marketing save to campaign-scoped KV.
- Reports previews pledge/fulfillment rows and downloads CSVs; it does not send email and does not mark reports as sent.
- Analytics uses stored actual Stripe fee/net data when available and exposes a super-admin backfill for older charged pledges.
- Content-editor media uploads stage files locally, upload on publish, and commit source-preserved assets through the GitHub-backed path; image/video uploads then request the
Optimize dashboard mediaworkflow withscope=changedfor image compression, responsive WebP variants (320w,480w,640w,960w,1600w), and video derivatives. Publish also deletes same-campaign dashboard-owned media that disappeared from content blocks or removed diary entries and is not referenced elsewhere in the campaign. - Secrets & credentials reports configured/missing status only; it does not expose or store secret values.
Report preview/download endpoints used by the dashboard:
curl "http://localhost:8787/admin/reports/campaign-runner/preview?campaignSlug=hand-relations&reportType=pledge"
curl "http://localhost:8787/admin/reports/campaign-runner.csv?campaignSlug=hand-relations&reportType=fulfillment"
For authenticated browser use these endpoints require the dashboard session cookie and CSRF/origin protections where applicable. Script-driven admin endpoints that still use Authorization: Bearer ADMIN_SECRET remain separate from the browser dashboard contract.
Stripe financials backfill for super admins:
curl -X POST "http://localhost:8787/admin/analytics/stripe-financials/backfill" \
-H 'Content-Type: application/json' \
-H 'x-pool-admin-csrf: <dashboard-csrf-token>' \
--cookie "pool_admin_session=<session-cookie>" \
-d '{"campaignSlug":"hand-relations","dryRun":true}'
The backfill uses campaign-pledges:{slug} indexes and grouped PaymentIntent lookups, not KV namespace scans.
POST /admin/recover-checkout
Recover a missed Stripe webhook by manually creating a pledge from a completed checkout session.
Headers: Authorization: Bearer ADMIN_SECRET
Request: { sessionId: "cs_test_..." } or { orderId: "pledge-..." }
Response:
{
"success": true,
"message": "Pledge recovered from Stripe checkout session",
"pledge": { ... },
"stripeSessionId": "cs_test_..."
}
Use case: When local development misses a webhook (Worker wasn’t running, Stripe CLI not forwarding, etc.), use this to recover:
curl -X POST http://localhost:8787/admin/recover-checkout \
-H 'Authorization: Bearer YOUR_ADMIN_SECRET' \
-H 'Content-Type: application/json' \
-d '{"sessionId": "cs_test_abc123..."}'
Front-End Pages
/campaigns/:slug/
Campaign detail with tier buttons → first-party cart drawer
/campaigns/:slug/pledge-success/
Post-persistence success page with confirmation + manage link
/campaigns/:slug/pledge-cancel/
User left the payment step before completion (not the pledge itself)
/manage/
Magic link landing page for pledge management:
- Reads
?t=...token - Fetches pledge details from Worker
- Shows pledge cards with state-dependent UI
- Groups projects into Active and Closed sections
- Sorts active cards with the most recent campaigns first
- Displays full breakdown: subtotal, optional The Pool tip, configured sales tax, and stored shipping amount for the pledge, plus total
- Reads pricing labels and rates from shared config so cart UI, Worker totals, emails, and reports stay aligned for forks
Pledge card states:
| Status | UI Treatment |
|---|---|
active |
Full edit controls (tier selection, support items, cancel button) |
active + deadline passed |
Locked badge + locked notice, read-only pledge controls, “Update Card” only |
charged |
Muted card, “✓ Successfully charged on {date}” notice |
payment_failed |
Warning notice with “Update Payment Method” button |
cancelled |
“This pledge has been cancelled” notice |
Shipping in modify flow: When a supporter changes tiers or physical support items, the manage page dynamically recalculates shipping. Physical selections can use USPS-backed live quotes, configured fallback rates, free-shipping overrides, and limited domestic signature-option upgrades. The confirmation modal shows the updated shipping and total before the user confirms.
Tip in modify flow: The manage page exposes the same 0% to 15% tip slider. During live campaigns, supporters can adjust it and see subtotal / tip / tax / shipping / total update immediately. Once the deadline passes, the tip slider becomes read-only along with the rest of the pledge controls.
Dev mode: Add ?dev to URL for mock pledge data testing
/community/:slug/
Supporter-only community page:
- Always verifies with Worker API (doesn’t trust cookies alone)
- On success: Sets a non-sensitive
supporter_{slug}cookie for UX optimization and stores the raw bearer token only insessionStorage - On failure (cancelled pledge, expired token): Clears session token state, shows access denied CTA
- Shows voting/polling decisions exclusive to backers
/votesAPI returns 403 for cancelled pledges (double-checks access)/votesonly accepts campaign-defined decision IDs and campaign-defined option values- Closed decisions stay readable but reject new votes
- Votes are keyed by email (not orderId) — supporters with multiple pledges still get one vote per decision
Charging Flow (Worker Cron)
The Worker has a minute-level scheduled trigger. Daily lifecycle work is gated to a small midnight window in the configured platform timezone and claimed once per local date:
# wrangler.toml
[triggers]
crons = ["* * * * *"]
What it does:
- Records an hourly heartbeat (
cron:lastRunin KV) so the minute-level scheduler does not burn the free KV write budget - Lists all campaigns with
goal_deadlineandgoal_amount - Drains queued launch reminder dispatch jobs in bounded batches only when queue state says work is pending
- Queues one launch reminder dispatch job when an upcoming campaign becomes live
- For each campaign where deadline has passed in the platform timezone, goal is met, and
campaign-charged:{slug}is not set:- Dispatches batched settlement via
POST /admin/settle-dispatch/:slug
- Dispatches batched settlement via
- Triggers GitHub Pages rebuild if any campaign state transitions detected
The scheduler is intentionally free-tier-aware. Launch reminder dispatch and supporter confirmation email retry queues each keep a small queue-state key. When a queue is known idle, scheduled runs skip the corresponding KV namespace list operation and rely on an hourly idle recheck for compatibility with manually inserted jobs. When real work is queued, the write path marks that queue pending immediately so the next scheduled run can process it without waiting for the compatibility recheck.
Settlement dispatch (self-chaining batches):
The settle-dispatch endpoint handles the actual charging in batches to stay within CF Worker’s 50 subrequest limit:
- Reads the campaign pledge index (
campaign-pledges:{slug}in KV) - Initializes a settlement job (
settlement-job:{slug}) tracking progress - Processes 6 pledges per batch via
POST /admin/settle-batch - Self-invokes for the next batch until all pledges are processed
- Each batch is a separate Worker invocation with its own subrequest budget
- Aggregates pledges by email — each supporter gets ONE charge
- On completion, sets
campaign-charged:{slug}only when no active pledge still needs attention
Campaign pledge index:
A per-campaign array of order IDs (campaign-pledges:{slug}) is maintained automatically:
- Added on pledge creation (webhook) and recovery (
/admin/recover-checkout) - Removed on pledge cancellation
- Can be rebuilt:
POST /admin/campaign-index/rebuild/:slug - Stats and inventory recalculation now also repair stale indexes if the stored array no longer matches the active pledge records
- Drift can now be checked without mutation via
POST /stats/:slug/checkorPOST /admin/projections/check
Key behaviors:
- Cancelled pledges are never charged
- Multiple pledges from same email = one aggregated charge (subtotals + shipping + tax + tip summed)
- Uses the most recently updated payment method for each supporter
- Already-charged pledges are safely skipped (idempotent)
- Can be triggered manually via
POST /admin/settle-dispatch/:slug - Legacy monolithic settle still available:
POST /admin/settle/:slug(use settle-dispatch for large campaigns) - Cron heartbeat: check via
GET /admin/cron/status
Payment Failure & Retry
When a charge fails during settlement:
- Pledge marked
payment_failedwith error message stored - Email sent with “Update Payment Method” button linking to manage page
- Supporter updates card via
/pledge/payment-method/start - Auto-retry charge happens immediately after successful payment method update
- If retry succeeds: pledge marked
charged, success email sent - If retry fails again: pledge stays
payment_failed, can retry again
This allows supporters to fix expired/declined cards without manual admin intervention.
Email Architecture
| Provider | Purpose |
|---|---|
| Resend | All supporter emails (confirmation, milestones, diary updates, announcements, charge success, payment failed) |
The Worker handles all pledge-related email via Resend.
Resend Integration (Worker)
The Worker sends supporter emails after Stripe webhook confirms the setup-mode session. The sender domain must be authorized for the configured Resend API key; for this deployment, pledge confirmations use The Pool <[email protected]> because site.example.com is the authorized sending domain.
// In Worker: POST /webhooks/stripe handler
async function sendSupporterEmail(env, { email, campaignSlug, campaignTitle, amount, token }) {
const manageUrl = `${env.SITE_BASE}/manage/?t=${token}`;
const communityUrl = `${env.SITE_BASE}/community/${campaignSlug}/?t=${token}`;
await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${env.RESEND_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
from: env.PLEDGES_EMAIL_FROM,
to: email,
subject: `Pledge confirmed | ${campaignTitle}`,
html: `
<h1>Thanks for backing ${campaignTitle}!</h1>
<p><strong>Pledge amount:</strong> $${(amount / 100).toFixed(2)}</p>
<p><strong>Remember:</strong> Your card is saved but won't be charged unless this campaign reaches its goal.</p>
<hr>
<h2>Your Supporter Access</h2>
<p>No account needed — these links are your keys:</p>
<p><a href="${manageUrl}">Manage Your Pledge</a> — Cancel, modify, or update payment method</p>
<p><a href="${communityUrl}">Supporter Community</a> — Vote on creative decisions</p>
<hr>
<p style="color:#666;font-size:12px;">Save this email! You'll need these links to manage your pledge.</p>
`
})
});
}
Email Templates
All emails show exact amounts with 2 decimal places (no rounding).
Pledge Confirmation (sent after the setup-mode Stripe session completes successfully)
-
Subject: “Pledge confirmed {Campaign Title}” - Contains: Full breakdown (subtotal, optional The Pool tip, tax, shipping if physical, total), pledge items, manage link, community link
- Includes: Instagram CTA (if campaign has Instagram URL)
- Community link shown only if campaign has active decisions
Pledge Modified (sent when supporter changes their pledge)
-
Subject: “Pledge updated {Campaign Title}” - Contains: Previous subtotal, new subtotal, change amount (+/-), optional The Pool tip, tax, shipping (if physical), new total, updated pledge items
- Includes: Instagram CTA (if campaign has Instagram URL)
- Community link shown only if campaign has active decisions
Charge Success (sent when pledge is charged at settlement)
-
Subject: “Payment confirmed {Campaign Title}” - Contains: Full breakdown (subtotal + tip + tax + shipping + total charged), pledge items
- Community link shown only if campaign has active decisions
- Note: No Instagram CTA (campaign is over)
Payment Failed (sent when off-session charge fails)
-
Subject: “Update payment method {Campaign Title}” - Contains: Full breakdown (subtotal + tip + tax + shipping + amount due), pledge items, manage link to update card
- Note: No Instagram CTA (campaign is over)
Pledge Cancelled (sent when supporter cancels their pledge)
-
Subject: “Pledge cancelled {Campaign Title}” - Contains: Breakdown including optional tip, confirmation card wasn’t charged, link to view campaign (can re-pledge)
- Note: Supporter is removed from future campaign email updates
Diary Update (sent when new diary entry is added to campaign)
-
Subject: “{Diary Title} {Campaign Title}” - Contains: Diary title, plain-text excerpt (200 chars + ellipsis), “Read Full Update” button linking to campaign diary
- Includes: Supporter access links (community + manage), Instagram CTA (if campaign has Instagram URL)
- Note: Excerpts strip markdown formatting; the full content is on the campaign page
Announcement (sent via admin broadcast with optional CTA link)
-
Subject: “{Subject} {Campaign Title}” - Contains: Custom heading, message body, optional highlighted CTA button (custom label + URL)
- Includes: Supporter access links (community + manage), Instagram CTA (if campaign has Instagram URL)
- Endpoint:
POST /admin/broadcast/announcement
Launch Reminder (sent once when an upcoming campaign becomes live)
-
Subject: “Now live {Campaign Title}” - Contains: Campaign title, localized launch copy, campaign CTA, and unsubscribe link
- Uses: Signup
preferredLang, existing Resend sender configuration, suppression markers, and sent markers - Note: Reminder signup is separate from pledging and can be cancelled from the reminder email
Security Considerations
- Magic links expire (90 days)
- Tokens verified against KV pledge record (email + campaign match)
- Pledge mutations blocked once pledge is charged
- All secrets in Cloudflare Worker environment variables
- Stripe webhook signatures verified
- Sensitive checkout and payment-method bootstrap responses are
private, no-store - First-party checkout and payment-method POSTs enforce trusted
SITE_BASEorigins - Browser-stored checkout drafts and in-flight identifiers are session-scoped or time-limited
- All deadlines evaluated in the platform timezone
- Launch reminder signups require explicit campaign/email opt-in, rate limiting, and Turnstile verification when configured
- Launch reminder unsubscribe links use scoped signed tokens and suppress only that campaign/email reminder
- Community/voting access revoked immediately when pledge is cancelled
/votesAPI checks pledge status on every request (not just token validity)
Race Condition Handling
/pledge/canceland/pledge/modifyreject if pledgecharged: true/pledge/canceland/pledge/modifyreject if campaign deadline has passed in the platform timezone- Cron checks
pledgeStatus === 'active'and!chargedbefore charging pledgeStatusandchargedflags prevent double-charging- Aggregation by email ensures one charge per supporter per campaign even with multiple pledge rows
- Manage page shows deadline-passed notice, locked badge, and read-only pledge controls once deadline passes
- Payment method updates remain available after deadline (for failed payment recovery)
Stretch Goals
- Defined in campaign front matter:
stretch_goals[] - Auto-unlock when
pledged_amount >= threshold - Display as
achievedorlocked - Optional: gate tiers with
requires_threshold