External Services
External service credentials required by the provider-operated platform today. Architecture work may reduce this surface with native Ceph-backed storage and provider-operated Anycast DNS; it is not a customer whole-stack hosting roadmap.
Credential Delivery
Section titled “Credential Delivery”The CLI automatically delivers credentials to each host during provisioning. Environment variables are assembled from:
- CLI-derived infrastructure and service defaults from manifest config (
DATABASE_HOST,DATABASE_PORT,KAFKA_BROKERS,CLICKHOUSE_ADDR,CLUSTER_ID,NODE_ID) - Runtime provisioning values such as the shared
SERVICE_TOKEN, enrollment tokens, and internal TLS paths - Shared env_files from the manifest’s top-level
env_filesfield - Per-service env_file from each service’s
env_filefield - Inline config from the service, interface, or observability
configmap - CLI fill-ins for values that are still unset, such as GeoIP paths and production runtime defaults
- CLI-composed values such as
DATABASE_URL,COOKIE_DOMAIN, andBRAND_DOMAINwhen the operator did not provide them explicitly
The merged result is written to /etc/frameworks/{service}.env on each host.
Credential Categories
Section titled “Credential Categories”| Category | Examples | Who Sets It |
|---|---|---|
| Operator-provided | DATABASE_PASSWORD, DATABASE_RUNTIME_PASSWORD, CLICKHOUSE_PASSWORD, Stripe keys, SMTP | You — in shared env files |
| Shared platform secrets | JWT_SECRET, SERVICE_TOKEN, FIELD_ENCRYPTION_KEY, TELEMETRY_TOKEN_SECRET | You — in shared env files for non-dev deployments |
| CLI-derived | DATABASE_URL, KAFKA_BROKERS, COOKIE_DOMAIN | CLI — from manifest |
For dev-profile manifests, the CLI can replace missing or placeholder shared platform secrets with crypto/rand values for that provision run. Non-dev provisioning validates that shared platform secrets are present in your manifest env_files; keep them in an encrypted env file so re-provisioning uses stable values.
Infrastructure Credential Flow
Section titled “Infrastructure Credential Flow”During frameworks cluster provision, the CLI also provisions database users:
- Postgres/YugabyteDB: Creates a per-database owner/migration role using
DATABASE_PASSWORDplus a least-privilege runtime role (by default<owner>_runtime) using the independentDATABASE_RUNTIME_PASSWORD. Runtime password precedence is the per-database override, owner alias, named Postgres instance override, matching cluster default, then shared default; provisioning and service DSNs use the same resolver. Provisioning rejects equal owner/runtime credentials. The runtime role can connect and perform application DML in its service schema, but cannot create schema objects or own them. Addruntime_roleto the database manifest entry only after provisioning andframeworks cluster doctor --deepsucceed; omitting it keeps services on the owner role. - ClickHouse: Deploys
users.xmlwith aframeworksuser (password fromCLICKHOUSE_PASSWORD) and areadonly_user(rate-limited, password fromCLICKHOUSE_READONLY_PASSWORD) - Operator analytics (optional): When
ANALYTICS_RO_PASSWORDandCLICKHOUSE_ANALYTICS_PASSWORDare both set, provisioning additionally creates the read-onlyframeworks_analytics_roPostgres role (quartermaster/commodore/purser grants, applied bycluster seed), the ClickHouse named collections that reach it, and a dedicatedframeworks_analyticsClickHouse user. Useframeworks_analyticsas the Metabase datasource credential for the tenant-activity cards (synced byframeworks cluster metabase sync) — the runtimeframeworksuser is deliberately denied the Postgres reach-through.
This ensures runtime services can connect with the same credentials the CLI provisioned.
Setup:
-
Create
gitops/config/production.envfor non-secret operator config -
Copy the secret template:
cp config/env/secrets.env.example gitops/secrets/production.env -
Set at minimum in
gitops/secrets/production.env: distinctDATABASE_PASSWORDandDATABASE_RUNTIME_PASSWORDvalues,CLICKHOUSE_PASSWORD,SERVICE_TOKEN,JWT_SECRET,PASSWORD_RESET_SECRET,FIELD_ENCRYPTION_KEY,USAGE_HASH_SECRET, and a shared 64-hex-characterTELEMETRY_TOKEN_SECRETWhen rotating
JWT_SECRETbefore the v0.3.0 field migration has completed, first add the outgoing value to the JSON arrayFIELD_ENCRYPTION_LEGACY_SECRETS. It is read-only and may contain a legacy secret shorter than current field keys. -
Set your public URLs and other non-secret operator inputs in
gitops/config/production.env -
Reference both files in your cluster manifest:
env_files:- config/production.env- secrets/production.env -
Run
frameworks cluster provision— the CLI creates database users, generates missing secrets, and pushes merged env vars to all hosts
Encrypting secrets with SOPS + age
Section titled “Encrypting secrets with SOPS + age”The CLI supports SOPS-encrypted env files using age keys. This encrypts secrets at rest in your gitops repo — decryption happens transparently during provisioning.
One-time setup:
# Install toolsbrew install sops age # macOS# or: apt install age && go install github.com/getsops/sops/v3/cmd/sops@latest
# Generate an age keypairage-keygen -o ~/.config/sops/age/keys.txt# Note the public key (age1...)Create .sops.yaml in your gitops repo root:
creation_rules: - path_regex: secrets/.*\.env$ age: age1yourpublickeyhere - path_regex: clusters/.*/hosts\.enc\.yaml$ age: age1yourpublickeyhereThe second rule encrypts host inventory files (hosts.enc.yaml) that contain server IPs and SSH targets. See hosts_file below.
Encrypt your secrets file:
sops -e -i secrets/production.envgit add secrets/production.env .sops.yamlgit commit -m "Encrypt production secrets with SOPS/age"Provisioning decrypts automatically — the CLI detects SOPS-encrypted files and decrypts using the age key at ~/.config/sops/age/keys.txt (or SOPS_AGE_KEY_FILE):
frameworks cluster provision --github-repo org/gitops --cluster production
# Or explicitly specify the key file:frameworks cluster provision --github-repo org/gitops --cluster production --age-key /path/to/keys.txtLocal development with an encrypted config/env/secrets.env:
make decrypt # Decrypt in-place (requires sops CLI)make env # Generate .env as usualmake encrypt # Re-encrypt before committingSeparating host IPs from architecture (hosts_file)
Section titled “Separating host IPs from architecture (hosts_file)”For public gitops repos, you can keep your cluster architecture in plaintext while encrypting server IPs and SSH targets in a separate SOPS-encrypted file. Set hosts_file in your cluster manifest:
# cluster.yaml — plaintext architecture (safe to publish)version: v1type: clusterenv_files: - config/production.env - secrets/production.envhosts_file: clusters/production/hosts.enc.yaml
hosts: central-eu-1: cluster: core roles: [infrastructure, services] # No external_ip or user here — they come from hosts_fileThe hosts file contains connection details:
# clusters/production/hosts.enc.yaml — SOPS-encryptedhosts: central-eu-1: external_ip: "203.0.113.10" user: rootedge_nodes: edge-eu-1: external_ip: "203.0.113.20" user: root # optional; defaults to rootEncrypt it: sops -e -i clusters/production/hosts.enc.yaml
During provisioning, the CLI decrypts the hosts file, merges IPs into the manifest, and composes edge SSH targets as user@external_ip. Cluster manifests may still put external_ip inline under hosts:; edge manifests keep connection details in hosts_file or use inline ssh.
Setting Up DNS
Section titled “Setting Up DNS”Purpose: Domain management, geographic routing, load balancing
Providers:
- Cloudflare — required. Authoritative for the root domain and root service records (api, app, control-plane hostnames).
- Bunny DNS — optional, highly recommended for any deployment with more than a handful of edges. Owns cluster-scoped media and edge zones (
{cluster_slug}.{base}, edge node records, the tenant aliascdn.{base}zone) whenBUNNY_API_KEYis set. Recommended because Cloudflare’s geo-load-balancing pools cap at 20 origins per pool — too small for an edge fleet that scales horizontally. Bunny’s smart records handle arbitrary geo-routed origin sets. Deployments with fewer than ~20 edges total can operate on Cloudflare alone; once you grow past that threshold, add Bunny.
Managed By: Navigator (api_dns) - automates DNS record management based on node inventory
Keep DNS behavior, routing patterns, and certificate mechanics in DNS and Cluster Routing. This page only lists the credential inputs Navigator needs:
CLOUDFLARE_API_TOKEN="your-token"CLOUDFLARE_ZONE_ID="your-zone-id"CLOUDFLARE_ACCOUNT_ID="your-account-id"BUNNY_API_KEY="your-bunny-api-key" # optional media-zone delegationSet root_domain in the manifest. Navigator uses manifest/service inventory to
decide which records to reconcile.
SSL/TLS Certificates
Section titled “SSL/TLS Certificates”Purpose: HTTPS for public endpoints, platform-managed subdomains, and per-tenant wildcard bundles under cdn.{base}
Providers: Both Let’s Encrypt and Google Trust Services. LE is primary; Google Trust is the fallback issuer required for production-scale tenant alias issuance.
- Let’s Encrypt handles root, global service pools, and cluster wildcard certificates — well within LE rate limits at typical volume.
- Google Trust Services is required as a fallback issuer for per-tenant wildcard bundles under
{tenant}.cdn.{base}. Each paying tenant gets its own wildcard cert, so issuance volume scales with paid-tenant count; LE alone hits per-registered-domain rate limits as paid tenants grow. Configure External Account Binding (EAB) credentials viaNAVIGATOR_GOOGLE_TRUST_EAB_KID/NAVIGATOR_GOOGLE_TRUST_EAB_HMAC_KEY. Navigator’s CA order (NAVIGATOR_ACME_CA_ORDER) defaults toletsencrypt,google-trustwhen EAB credentials are present.
Managed By: Navigator (api_dns) — DNS-01 ACME issuance + renewal, multi-CA fallback when one issuer rate-limits, per-domain issuer pinning so renewals stay on the same CA.
See DNS and Cluster Routing for certificate routing and provider ownership. Set NAVIGATOR_CERT_ALLOWED_SUFFIXES when a deployment should restrict certificate requests beyond the manifest root_domain.
Object Storage (S3)
Section titled “Object Storage (S3)”Purpose: Durable storage for recordings, clips, DVR segments, VOD assets, and thumbnails
Providers: Cloudflare R2 and Hetzner Object Storage are the supported defaults. Any S3-compatible backend works (the AWS SDK v2 client + BaseEndpoint + UsePathStyle = true cover MinIO, Wasabi, Backblaze B2, AWS S3 itself).
Consumed By:
- Foghorn (
api_balancing) — mints presigned PUT/GET URLs for clip / DVR / VOD lifecycle, writes manifests directly - Chandler (
api_assets) — serves artifacts and caches via local LRU; per-cluster S3 lookup via Quartermaster (seeinfrastructure_clusters.s3_*)
Why a two-provider split
Section titled “Why a two-provider split”Multiregion deployments map each tenant’s artifacts to the bucket whose pricing model best fits that tenant’s expected access pattern via tenants.home_region + infrastructure_clusters.s3_*.
- Hetzner Object Storage (EU) is cheapest at-rest and on-egress in EU. Use for EU-home tenants when most viewers are same-region; storage savings dominate egress for that pattern.
- Cloudflare R2 (US/global) has $0 egress with no AUP traps. Use where cross-region viewer fanout would otherwise compound egress bills, or where the bucket lives far from a meaningful portion of its readers. Standard storage class is the right default; Infrequent Access trades retrieval fees for storage savings and is wrong for live-streaming origins.
Single-region deployments can use one provider for both EU and US clusters — the two-provider split is an optimization, not a requirement.
What You Need
Section titled “What You Need”- Bucket per cluster (one Hetzner bucket for the EU cluster, one R2 bucket for the US cluster in the standard multiregion shape)
- Access key + secret per provider, stored in SOPS-encrypted env files
- CORS policy on each bucket (see below) — required for browser-direct VOD multipart uploads
Bucket setup
Section titled “Bucket setup”Cloudflare R2:
- Create bucket via Cloudflare dashboard. Set Location hint explicitly (
ENAMfor Eastern North America,WEURfor Western Europe, etc.) — leaving Automatic places the bucket near the creating browser’s region, which can be wrong. - Generate API token under R2 → Manage R2 API Tokens scoped to the bucket with Object Read & Write.
- Enable Local Uploads — terminates customer browser PUTs at the nearest Cloudflare PoP and routes internally to the bucket’s home region. Improves upload latency for geographically distributed customers; no downside for same-region writers.
- Leave default-disabled: Custom Domains, Public Development URL, R2 Data Catalog, Bucket Lock Rules, Event Notifications, On Demand Migration.
- Keep the default 7-day multipart-abort lifecycle rule. Tenant retention is FrameWorks-owned (
api_balancing/internal/jobs/retention.go); bucket-level lifecycle would create a second authority over deletes.
Hetzner Object Storage:
-
Create bucket via Hetzner Cloud Console under the right project + region (
nbg1Nuremberg,fsn1Falkenstein,hel1Helsinki). -
Generate an access key + secret via the bucket UI.
-
Required: set an
AbortIncompleteMultipartUploadlifecycle rule (e.g. 7 days). Unlike R2 — which aborts incomplete multipart uploads automatically after 7 days — Hetzner keeps interrupted upload fragments indefinitely without this rule. Foghorn’s in-process abort on a failed upload is best-effort and a crash betweenCreateMultipartUploadand the durable row can leave an upload with no database record to retry from, so the bucket lifecycle is the required final backstop against orphaned fragments. Set it with any S3-compatible tool, e.g.:Terminal window aws s3api put-bucket-lifecycle-configuration --endpoint-url https://<region>.your-objectstorage.com --bucket <bucket> \--lifecycle-configuration '{"Rules":[{"ID":"abort-incomplete-multipart","Status":"Enabled","Filter":{"Prefix":""},"AbortIncompleteMultipartUpload":{"DaysAfterInitiation":7}}]}'This is the ONLY bucket-level lifecycle FrameWorks requires: it reaps abandoned upload parts only, never completed objects (tenant retention stays FrameWorks-owned).
CORS policy (required for VOD uploads)
Section titled “CORS policy (required for VOD uploads)”VOD upload uses browser-direct multipart presigned PUTs — the webapp fetch()s straight to the S3 endpoint and reads the ETag response header per part. Without CORS on the bucket the browser’s preflight OPTIONS request fails and uploads never start.
The presigned URL is the auth boundary, not the Origin header — "AllowedOrigins": ["*"] is the correct shape, used by every major upload-backend deployment.
The CORS policy is the same shape for both providers. Set via aws s3api put-bucket-cors once per bucket:
cat > /tmp/cors.json <<'EOF'{ "CORSRules": [ { "AllowedOrigins": ["*"], "AllowedMethods": ["PUT", "GET", "HEAD"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 3600 } ]}EOF
# Hetzneraws s3api put-bucket-cors \ --bucket <hetzner-bucket> \ --endpoint-url https://<region>.your-objectstorage.com \ --region us-east-1 \ --cors-configuration file:///tmp/cors.json
# Cloudflare R2aws s3api put-bucket-cors \ --bucket <r2-bucket> \ --endpoint-url https://<account-id>.r2.cloudflarestorage.com \ --region auto \ --cors-configuration file:///tmp/cors.jsonVerify with an anonymous preflight curl (preflight precedes authentication, so no credentials needed):
curl -i -X OPTIONS --max-time 10 \ -H "Origin: https://example.com" \ -H "Access-Control-Request-Method: PUT" \ -H "Access-Control-Request-Headers: Content-Type" \ "https://<region>.your-objectstorage.com/<bucket>"Expected: 200 OK with access-control-allow-origin, access-control-allow-methods: PUT, access-control-expose-headers: ETag. Hetzner Ceph echoes the specific matched origin instead of literally * in the response — spec-correct CORS behavior, not a deviation.
Environment variables per cluster
Section titled “Environment variables per cluster”Same variable names on every host that runs Foghorn or Chandler; the values differ per cluster:
STORAGE_S3_ENDPOINT="https://<region>.your-objectstorage.com" # Hetzner# orSTORAGE_S3_ENDPOINT="https://<account-id>.r2.cloudflarestorage.com" # R2
STORAGE_S3_BUCKET="<bucket-name>"STORAGE_S3_REGION="us-east-1" # Hetzner (signing-only; ignored for routing)# orSTORAGE_S3_REGION="auto" # R2 conventionSTORAGE_S3_ACCESS_KEY="<from provider>"STORAGE_S3_SECRET_KEY="<from provider>"STORAGE_S3_PREFIX="" # optional key prefix inside the bucketIn multiregion deployments, EU and US clusters use different providers and therefore different credentials. The CLI assembles env per host so each box receives the credentials for the cluster it serves — see Credential Delivery above. Cluster-bound services pull STORAGE_S3_* from cluster-scoped env_files referenced by the manifest’s service definitions.
When CLUSTER_ID is set, Chandler takes the full descriptor tuple (s3_bucket / s3_endpoint / s3_region / s3_prefix) exclusively from Quartermaster’s infrastructure_clusters row — the sole authority. There is no env descriptor fallback: without a usable cluster-row descriptor Chandler starts but is not ready (/ready → 503). Only the access/secret credentials come from env; they are never stored in infrastructure_clusters. See Media storage for the full authority chain.
Known gotchas
Section titled “Known gotchas”- aws-cli 2.34.x throws
TypeError: argument of type 'NoneType' is not a container or iterableon the error path against ceph-RGW backends (Hetzner runs on Ceph). It’s a cosmetic awscli bug parsing empty<Message></Message>tags; the actual HTTP exchange succeeds. Success-path operations work fine; error responses show the bug. Read--debugoutput for the real HTTP response, or usemc(MinIO Client) which handles ceph cleanly. - Hetzner echoes specific origin, R2 echoes
*: whenAllowedOriginsis["*"], Hetzner Ceph returnsAccess-Control-Allow-Origin: <requesting origin>while R2 returnsAccess-Control-Allow-Origin: *. Both are spec-compliant; don’t assert one shape in integration tests. - Cloudflare R2 location hint is permanent. Bucket name and location hint are set once at creation; you can’t migrate later without copying objects to a new bucket. Pick the right region the first time.
GeoIP Database
Section titled “GeoIP Database”Purpose: IP geolocation for geographic routing, node placement, and analytics
Provider: MaxMind (GeoLite2 or GeoIP2), DB-IP Lite, or IP2Location LITE
What You Need:
- Provider account (MaxMind requires a free license key for GeoLite2)
- MMDB file provisioned to target hosts
Consumed By:
- Foghorn (
api_balancing) — geo-aware viewer routing decisions - Quartermaster (
api_tenants) — geolocates nodes at bootstrap for federation map placement
If no MMDB is configured, GeoIP is disabled and services fall back to non-geographic behavior.
CLI Provisioning (recommended):
# Download from MaxMind and distribute to all Foghorn + Quartermaster hostsframeworks cluster sync-geoip --license-key YOUR_MAXMIND_KEY
# Use a local MMDB file insteadframeworks cluster sync-geoip --source file --file /path/to/GeoLite2-City.mmdb
# Target specific services, restart after uploadframeworks cluster sync-geoip --license-key KEY --services foghorn,quartermaster --restartThe CLI reads your cluster manifest to find the hosts running the target services, uploads the MMDB via SSH, and optionally restarts those workloads. You can also make GeoIP part of normal cluster provisioning with a geoip: block in the manifest so frameworks cluster provision stages the MMDB before Foghorn and Quartermaster start.
Manual Provisioning:
- Download a City-level MMDB file from your provider
- Place it on each Foghorn and Quartermaster host at
/var/lib/GeoIP/GeoLite2-City.mmdb - Set environment variables on the target services:
Terminal window GEOIP_MMDB_PATH="/var/lib/GeoIP/GeoLite2-City.mmdb"
Cache Tuning (optional):
The shared pkg/geoip reader caches lookups. Defaults are sensible for most deployments:
GEOIP_CACHE_TTL=300s # Positive cache TTL (default: 300s)GEOIP_CACHE_SWR=120s # Stale-while-revalidate window (default: 120s)GEOIP_CACHE_NEG_TTL=60s # Negative (miss) cache TTL (default: 60s)GEOIP_CACHE_MAX=50000 # Max cached entriesConfiguring SMTP
Section titled “Configuring SMTP”Purpose: Transactional email — password reset, email verification, billing/invoice delivery, forms inbox
Providers: Fastmail, SendGrid, AWS SES, or any SMTP provider
Required: Yes. SMTP is required for production deployments. Without it:
- Forgotten-password recovery is impossible — users locked out of accounts have no path back in.
- Email verification at signup can’t complete — new account creation breaks.
- Billing invoices and payment-failure notifications never reach customers — they discover failed payments only by losing service.
- Forms API (contact submissions, support intake) hard-fails on send.
Auth flows degrade with warnings logged if SMTP is missing, but a degraded auth flow is not a viable production posture. Treat SMTP as required.
Purser never emails a live draft. Finalization stores the permanent invoice and
line-item snapshot, then enqueues purser.invoice_email_outbox in the same
transaction. Horizontally scaled Purser workers lease and retry delivery. Check
purser.payment_report_invoice_email_outbox_stuck for rows that repeatedly
fail; missing SMTP or missing permanent line items defer the email instead of
sending a partial summary.
What You Need:
- SMTP credentials (host, port, username, password)
- Verified sender domain (SPF / DKIM configured at the DNS layer for deliverability)
Manual Steps:
- Choose email provider
- Generate SMTP credentials or app password
- Set environment variables:
Terminal window export SMTP_HOST="smtp.provider.com"export SMTP_PORT="587"export SMTP_USER="your-username"export SMTP_PASSWORD="your-password"export FROM_NAME="FrameWorks" # optional
Used By:
- Auth (email verification + password resets)
- Billing (invoice + payment-failure notifications)
- Forms API (contact form submissions — hard-fails without SMTP)
Enabling Payments
Section titled “Enabling Payments”Purpose: Billing, subscriptions, payment handling
Providers: Stripe (primary), Mollie (optional)
What You Need:
- Stripe account and secret key (
STRIPE_SECRET_KEY) - Stripe webhook signing secret (
STRIPE_WEBHOOK_SECRET) - (Optional) Mollie API key (
MOLLIE_API_KEY) - Public application URL (
WEBAPP_PUBLIC_URL); Mollie also requiresGATEWAY_PUBLIC_URLso it can send authoritative payment callbacks PAYMENT_CARD_PROVIDER=stripe|molliewhen both providers are configured
What’s Automated:
- Durable webhook ingress followed by asynchronous, retrying reconciliation
- Subscription management and tier activation
- Product/price catalog sync on startup
- Payment processing, refunds, and disputes
- Invoice delivery and 1/7/14/30-day overdue reminders through a retrying email outbox
Manual Steps:
- Create Stripe account at https://stripe.com
- Get the secret key from Developers → API keys (
sk_live_…) - Add a webhook endpoint at Developers → Webhooks:
- URL:
https://yourdomain.com/webhooks/billing/stripe - Event destination scope: “Your account” (FrameWorks is not a Stripe Connect platform)
- API version:
2026-05-27.dahlia— must match the bundledstripe-goSDK. The webhook payloads are parsed against this version; a mismatched endpoint version can silently mis-parse fields. - Subscribe these events:
checkout.session.completed,checkout.session.async_payment_succeeded,checkout.session.async_payment_failed,checkout.session.expiredcustomer.subscription.created,.updated,.deleted,.paused,.resumedinvoice.paid,invoice.payment_failed,invoice.payment_action_requiredpayment_intent.succeeded,payment_intent.payment_failedcharge.refunded,charge.dispute.created,.closed,.funds_withdrawn,.funds_reinstated
- Reveal the endpoint Signing secret (
whsec_…)
- URL:
- (Optional) If using Mollie, configure:
https://yourdomain.com/webhooks/billing/mollie - Enable payment methods (Settings → Payment methods). The hosted Checkout pins an
explicit, currency-aware allowlist in code:
cardalways, plussepa_debit,ideal, andbancontactfor EUR checkouts (these are EUR-only at Stripe; non-EUR checkouts offer card only). The dashboard-enabled set must include the methods you bill in. - Set environment variables:
Terminal window export STRIPE_SECRET_KEY="sk_live_..."export STRIPE_WEBHOOK_SECRET="whsec_..."export MOLLIE_API_KEY="live_xxx" # optionalexport PAYMENT_CARD_PROVIDER="stripe" # required only when both providers are ready
Used By: Purser (billing API)
Optional: Not required for basic platform operation
Note: Stripe webhooks require STRIPE_WEBHOOK_SECRET in all environments. Asynchronous
methods (SEPA Direct Debit, iDEAL, Bancontact) settle after checkout completes; value is
granted only on the confirming event (async_payment_succeeded / invoice.paid /
customer.subscription.updated), never on checkout.session.completed alone.
Purser advertises the generic CARD invoice-payment option only when the selected provider is
fully reconcilable. Stripe readiness requires STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET, and WEBAPP_PUBLIC_URL; Mollie readiness requires
MOLLIE_API_KEY, GATEWAY_PUBLIC_URL, and WEBAPP_PUBLIC_URL. If both are ready and
PAYMENT_CARD_PROVIDER is unset or invalid, card payments remain unavailable instead of
silently routing to one provider.
Provider HTTP requests are acknowledged only after Purser commits the verified
or validated payload to purser.provider_webhook_inbox. Reconciliation runs in
a leased background worker with exponential backoff. Monitor
purser.payment_report_webhook_inbox_stuck and
purser.payment_report_invoice_email_outbox_stuck; rows appear after five
failed attempts and are not automatically discarded.
x402 facilitator and crypto settlement
Section titled “x402 facilitator and crypto settlement”Production uses FrameWorks’ embedded x402 v2 exact/EIP-3009 facilitator by default. Configure:
export X402_FACILITATOR_PROVIDER="self"export X402_GAS_WALLET_PRIVKEY="<dedicated-gas-only-key>"export X402_GAS_WALLET_ADDRESS="0x<address-derived-from-key>"export GATEWAY_PUBLIC_URL="https://api.example.com"export X402_PREPAID_BUFFER_EUR_CENTS="500"export SUPPLIER_COUNTRY="NL"export CRYPTO_SCAN_START_BLOCK_BASE="<audited-first-block>"export CRYPTO_TREASURY_BASE="0x<treasury-address>"export CRYPTO_SWEEP_RELAYER_PRIVATE_KEY_BASE="<dedicated-gas-only-key>"Purser verifies and settles EIP-3009 locally, while the payer still signs a gasless authorization. Each response creates a five-minute tenant-bound quote with a locked USD/EUR rate. A facilitator timeout is an unknown settlement outcome and must be reconciled; it never grants provisional credit.
Confirmed prepaid and x402 credit is the VAT tax point; later usage only
consumes already-invoiced credit. When an EU VAT identifier is supplied, Purser
validates it against the official VIES service and stores a hashed/masked
evidence record. A current valid result enables reverse charge only when the
customer and SUPPLIER_COUNTRY are different EU member states. Domestic VAT
identifiers receive domestic VAT. Billing-country and GeoIP-country signals are
stored separately. Full invoices use the complete billing address; anonymous
simplified top-ups conservatively use supplier-country VAT. GeoIP is audit
metadata and never silently creates a zero-VAT export or hides the document.
Inspect live dependencies with frameworks crypto readiness; production
advertisement fails closed when RPC, token, relayer, tax, scanner, or custody
dependencies are missing. Test reports and ceremony records belong in the
release/change record, not opaque environment-variable strings.
Every enabled deposit network needs an audited
CRYPTO_SCAN_START_BLOCK_<NETWORK> and a version-controlled
CRYPTO_TREASURY_<NETWORK> destination. Purser accepts any non-zero EVM
address; using a cold multisig/Safe is an operator custody choice. USDC
sweeping also needs a dedicated gas-only
CRYPTO_SWEEP_RELAYER_PRIVATE_KEY_<NETWORK>; it is not the HD deposit key and
must not control customer funds. Fund it only for bounded relay gas.
Treasury configuration is per chain. Purser validates that each configured value is a non-zero EVM address but does not require or inspect contract code.
The operator ceremony is frameworks crypto sweep plan, offline sign,
online broadcast, then reconcile. Commands default to dry-run and bind
execution to checksums. See the CLI reference.
The repository runbook docs/operations/sweep-ceremony.md defines the
two-person evidence and interrupted/unknown-outcome recovery procedure.
Activate a replacement public derivation key with
frameworks crypto wallet rotate --xpub-file new.xpub --network mainnet.
Keep the retired offline xprv until repeated sweep plans have drained every
manifest using its fingerprint. Rotation never initiates a transaction.
Direct deposit and x402 creation remain independently stoppable with
CRYPTO_DEPOSITS_ENABLED=false and X402_PAYMENTS_ENABLED=false. These
breakers do not stop observation or reconciliation of money already submitted.
Summary
Section titled “Summary”| Service | Provider | Required | Automated by |
|---|---|---|---|
| Database | Postgres or YugabyteDB | Yes | CLI (user creation + grants) |
| ClickHouse | ClickHouse | Yes | CLI (users.xml deployment) |
| DNS | Cloudflare (required today) + Bunny DNS (optional) | Yes | Navigator (api_dns) |
| SSL/TLS | Let’s Encrypt + Google Trust Services (multi-CA) | Yes | Navigator (api_dns) |
| Object Storage | Cloudflare R2 / Hetzner / any S3-compat | Yes today | Operator (bucket + CORS); Chandler reads per-cluster row |
| GeoIP | MaxMind / DB-IP / IP2Location | Recommended | CLI (cluster sync-geoip) |
| SMTP | Any provider (Fastmail, SendGrid, AWS SES, …) | Yes | Platform (email delivery) |
| Payments | Stripe, Mollie | Optional (required for billing) | Purser (webhook handling) |