Self-hosted platform for standing up and running software factories on AWS: complete DevSecOps environments with secure delivery pipelines, supply-chain controls, compliance evidence and multi-tenant governance — in minutes, not months.
Setup walkthroughs for the two real billing providers.
BILLING_PROVIDER is global; you do not run Stripe and Marketplace side by side.STRIPE_SECRET_KEY is a secret — provision it via a sealed secret or SSM, never commit it.BILLING_ENABLED=true, the default) for any provider to serve plans.Pipeline Builder charges through a pluggable billing provider, selected by BILLING_PROVIDER:
BILLING_PROVIDER |
Use it for | Charging model |
|---|---|---|
stub (default) |
Local dev / demos | No real charges — subscriptions are created in-app with no external provider |
stripe |
Direct SaaS billing you own | Stripe Customers + Subscriptions; the app owns plans/prices and reconciles via webhooks |
aws-marketplace |
Selling through AWS Marketplace | Entitlements flow from AWS; add-ons report as metered usage (BatchMeterUsage) |
This page is the setup walkthrough for the two real providers. For what billing does once configured, see Billing Add-on Bundles, Billing Discounts, and the Environment Variables → Billing reference.
One provider per deployment.
BILLING_PROVIDERis global. Pick the one that matches how the deployment is sold.
Stripe — the app pushes, Stripe confirms:
AWS Marketplace — AWS pushes, the app follows:
ResolveCustomer to turn that token into a customer identifier, which becomes the account’s orgId.BatchMeterUsage once metering is enabled.Each provider’s walkthrough below is a numbered sequence — follow one, not both.
Both sections end with their own environment-variable table, and the Endpoints reference lists the routes involved.
Stripe billing is direct: the app creates a Stripe Customer per organization and a Subscription per plan, using Price objects you create in Stripe. The app owns the reduction logic (discounts are customer-balance credits, never Stripe coupons — see Billing Discounts); Stripe owns the card, the invoice, and the payment. All state changes flow back through a signed webhook.
developer / pro / team / enterprise) — the app’s plan ids, from Environment Variables → Billing.BILLING_ENABLED=true
BILLING_PROVIDER=stripe
Stripe Dashboard → Developers → API keys → copy the Secret key (sk_test_… in test mode, sk_live_… in production):
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_SECRET_KEY is a secret — provision it via a sealed secret / SSM, never commit it.
In Stripe, create a Product + a recurring Price for every paid plan and every add-on you sell, one Price per interval (monthly and/or annual). The amount you set in Stripe must match the app’s configured price (Stripe is what actually charges the card). Copy each Price id (price_…).
Then wire them all up with STRIPE_PRICE_MAP — a single JSON object whose keys are <id>_<interval> (<id> is a plan id or a bundle id) and whose values are Stripe Price ids. interval is monthly or annual.
| Plan id | Monthly | Annual | Map keys |
|---|---|---|---|
developer |
Free | Free | — (nothing charged) |
pro |
$39 | $390 | pro_monthly, pro_annual |
team |
$79 | $790 | team_monthly, team_annual |
enterprise |
$599 | $5,990 | enterprise_monthly, enterprise_annual |
The free developer tier needs no Price; the hidden unlimited tier is never sold. If a customer picks a plan/interval whose key is missing, subscription creation fails fast with No Stripe Price ID configured for plan "…" with interval "…" — so map every paid combination.
Provisioning helper. Stripe Prices are immutable, so a fresh install (or any price change) needs new Price objects. Run
STRIPE_SECRET_KEY=… node api/billing/scripts/provision-stripe-prices.mjs(fromapi/billing; add--dry-runto preview) — it reads the effective billing config, creates a Price per interval for every paid plan and every chargeable bundle (below), and prints the ready-to-pasteSTRIPE_PRICE_MAPJSON. Combos are customer-balance credits, not line items, so they need no Price.
Add-ons are charged as extra subscription line items on the same subscription, so each sellable bundle also needs a Stripe Price per interval, keyed <bundleId>_<interval> in the same STRIPE_PRICE_MAP. Annual defaults to ~10× monthly:
| Bundle id | Monthly | Annual | Map keys |
|---|---|---|---|
seat |
$19.99 | $199.90 | seat_monthly, seat_annual |
pipeline_pack |
$15 | $150 | pipeline_pack_monthly, pipeline_pack_annual |
plugin_pack |
$10 | $100 | plugin_pack_monthly, plugin_pack_annual |
api_pack |
$19.99 | $199.90 | api_pack_monthly, api_pack_annual |
ai_pack |
$19.99 | $199.90 | ai_pack_monthly, ai_pack_annual |
storage_pack |
$19.99 | $199.90 | storage_pack_monthly, storage_pack_annual |
listing_pack |
$4.99 | $49.90 | listing_pack_monthly, listing_pack_annual |
retention_pack |
$15 | $150 | retention_pack_monthly, retention_pack_annual |
dora_history_pack |
$30 | $300 | dora_history_pack_monthly, dora_history_pack_annual |
advanced_reporting |
$30 | $300 | advanced_reporting_monthly, advanced_reporting_annual |
team_usage_analytics |
$30 | $300 | team_usage_analytics_monthly, team_usage_analytics_annual |
compliance_standard |
$29.90 | $299 | compliance_standard_monthly, compliance_standard_annual |
compliance_advanced |
$99.90 | $999 | compliance_advanced_monthly, compliance_advanced_annual |
stakeholder_reports |
$30 | $300 | stakeholder_reports_monthly, stakeholder_reports_annual |
If a purchased bundle’s
<bundleId>_<interval>key is absent from the map, its line item is silently skipped — the customer gets the entitlement but is never charged for it. Map every bundle you enable (seeBILLING_BUNDLES_ENABLED), only for the intervals you sell.
Copy-paste and replace each price_REPLACE with the real price_… id from your Stripe dashboard (or let the provisioning helper above emit the whole map). Provide it as a single line — it’s expanded here only for readability. The free developer tier is intentionally absent (nothing is charged); the hidden unlimited tier is never sold.
STRIPE_PRICE_MAP='{
"pro_monthly":"price_REPLACE","pro_annual":"price_REPLACE",
"team_monthly":"price_REPLACE","team_annual":"price_REPLACE",
"enterprise_monthly":"price_REPLACE","enterprise_annual":"price_REPLACE",
"seat_monthly":"price_REPLACE","seat_annual":"price_REPLACE",
"pipeline_pack_monthly":"price_REPLACE","pipeline_pack_annual":"price_REPLACE",
"plugin_pack_monthly":"price_REPLACE","plugin_pack_annual":"price_REPLACE",
"api_pack_monthly":"price_REPLACE","api_pack_annual":"price_REPLACE",
"ai_pack_monthly":"price_REPLACE","ai_pack_annual":"price_REPLACE",
"storage_pack_monthly":"price_REPLACE","storage_pack_annual":"price_REPLACE",
"listing_pack_monthly":"price_REPLACE","listing_pack_annual":"price_REPLACE",
"retention_pack_monthly":"price_REPLACE","retention_pack_annual":"price_REPLACE",
"dora_history_pack_monthly":"price_REPLACE","dora_history_pack_annual":"price_REPLACE",
"advanced_reporting_monthly":"price_REPLACE","advanced_reporting_annual":"price_REPLACE",
"team_usage_analytics_monthly":"price_REPLACE","team_usage_analytics_annual":"price_REPLACE",
"compliance_standard_monthly":"price_REPLACE","compliance_standard_annual":"price_REPLACE",
"compliance_advanced_monthly":"price_REPLACE","compliance_advanced_annual":"price_REPLACE",
"stakeholder_reports_monthly":"price_REPLACE","stakeholder_reports_annual":"price_REPLACE"
}'
That’s the full set: 3 paid plans + 14 add-ons, each with a _monthly and _annual key. Drop any interval you don’t sell (e.g. omit the _annual keys for monthly-only pricing), and drop any add-on you haven’t enabled via BILLING_BUNDLES_ENABLED — an unmapped bundle’s line item is silently skipped (entitlement granted, never charged), so only omit what you deliberately don’t bill.
Four combos and the per-seat volume ladder are applied by the platform as credit
lines on the invoice projection, computed from the add-ons an org holds. They are
not products, they have no STRIPE_PRICE_MAP key, and creating Stripe Prices for
them would double-count. Defaults:
| Combo id | Members | Combined monthly | Combined annual |
|---|---|---|---|
analytics_suite |
advanced_reporting + team_usage_analytics + stakeholder_reports |
$63 | $630 |
team_growth |
seat + team_usage_analytics |
$90.99 | $909.90 |
compliance_suite |
compliance_standard + compliance_advanced |
$90.86 | $908.60 |
scale_bundle |
api_pack + storage_pack |
$27.99 | $279.90 |
The credit is the difference between the members’ list basket and the combined price,
so compliance_suite ($29.90 + $99.90 = $129.80 list → $90.86) credits $38.94/mo.
Override with BILLING_COMBO_<ID>_MONTHLY / _ANNUAL (cents).
seat also carries a volume ladder — 10% off from 5 seats, 20% from 15, 30% from
40 — applied as a separate credit line. Override with BILLING_BUNDLE_SEAT_VOLUME_TIERS.
On Marketplace these credits are realized through metered drawdown, which is why
every sellable dimension needs a price in AWS_MARKETPLACE_DIMENSION_PRICE_MAP: an
unpriced dimension cannot be valued, so it is reported in full and the discount is lost.
The app reconciles all subscription and payment state from Stripe webhooks. The endpoint is:
POST https://<your-public-host>/billing/stripe/webhook
Stripe Dashboard → Developers → Webhooks → Add endpoint → enter that URL, then select the events the app consumes:
| Event | Effect in Pipeline Builder |
|---|---|
customer.subscription.created |
Records the subscription + grants the tier |
customer.subscription.updated |
Re-syncs status/plan (status mapped internally; unpaid ⇒ canceled after grace) |
customer.subscription.deleted |
Cancels the subscription, downgrades tier |
invoice.payment_succeeded |
Marks paid / clears past-due |
invoice.payment_failed |
Moves to past-due (grace period applies) |
invoice.upcoming |
Drives renewal reminders + recurring-credit re-grant |
charge.refunded |
Reverses the matching subscription |
charge.dispute.created |
Reverses on dispute |
invoice.voided / invoice.marked_uncollectible |
Reversal handling |
Copy the endpoint’s Signing secret (whsec_…) into:
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
The webhook route verifies every delivery against this secret over the raw request body and de-dupes redeliveries — so the URL must be publicly reachable through the nginx gateway, and STRIPE_WEBHOOK_SECRET must match the endpoint exactly or deliveries are rejected.
stripe login
# Forward live test events to your local gateway and print a whsec_ to use as STRIPE_WEBHOOK_SECRET:
stripe listen --forward-to https://localhost:8443/billing/stripe/webhook
# In another shell, simulate the lifecycle:
stripe trigger customer.subscription.created
stripe trigger invoice.payment_succeeded
Confirm the subscription appears (GET /billing/subscription for the org) and the tier is granted.
Swap test → live everywhere: STRIPE_SECRET_KEY=sk_live_…, a live-mode webhook endpoint with its own STRIPE_WEBHOOK_SECRET, and live Price ids in STRIPE_PRICE_MAP. Test-mode and live-mode objects never interoperate.
Unlike AWS Marketplace (where the customer subscribes on AWS and the platform reconciles), Stripe is driven entirely in-app and is wired end to end — there is no external redirect and no missing UI piece:
getPaymentProvider() constructs the StripeProvider when BILLING_PROVIDER=stripe and throws at startup if STRIPE_SECRET_KEY is unset (STRIPE_SECRET_KEY is required when BILLING_PROVIDER=stripe), so a misconfigured deployment fails fast rather than silently running stubbed.POST /billing/subscriptions (authenticated, billing:manage), which runs createCustomer → createSubscription on the provider. createSubscription looks up the Stripe Price from STRIPE_PRICE_MAP[<planId>_<interval>] and fails fast if that key is missing.syncAddons; each bundle’s Stripe Price comes from STRIPE_PRICE_MAP[<bundleId>_<interval>], applied as an extra subscription line item (a missing key is skipped — granted but not charged).POST /billing/stripe/webhook, which verifies each delivery with stripe.webhooks.constructEvent(rawBody, signature, STRIPE_WEBHOOK_SECRET), returns 503 if the secret is unset (so Stripe retries rather than the app processing unsigned payloads), and de-dupes redeliveries by event.id. The consumed event set is the table in Step 4.Net: enabling Stripe is purely configuration (BILLING_PROVIDER, the two secrets, and STRIPE_PRICE_MAP) — the subscribe UI, add-on flow, and webhook reconciliation already ship.
| Variable | Default | Description |
|---|---|---|
STRIPE_SECRET_KEY |
— | Secret. Stripe API secret key (sk_test_… / sk_live_…). Required when BILLING_PROVIDER=stripe |
STRIPE_WEBHOOK_SECRET |
— | Secret. Signing secret (whsec_…) for the endpoint at POST /billing/stripe/webhook; every delivery is signature-verified against it |
STRIPE_PRICE_MAP |
{} |
JSON map of <id>_<interval> → Stripe Price id, where <id> is a plan id or a bundle id (e.g. {"pro_monthly":"price_…","seat_annual":"price_…"}). A plan/interval absent here cannot be subscribed; a bundle absent here is granted but not charged |
Every variable Stripe needs, at its code default. Replace the two secrets and the
34 price_REPLACE ids; everything else can ship as-is.
BILLING_ENABLED=true
BILLING_PROVIDER=stripe
# From the Stripe dashboard (Developers → API keys, and the webhook endpoint).
STRIPE_SECRET_KEY=sk_test_REPLACE
STRIPE_WEBHOOK_SECRET=whsec_REPLACE
# 3 paid plans + 14 add-ons, monthly and annual = the 34 keys the validator wants.
# A missing key means that plan cannot be subscribed, or that add-on is granted
# and never charged.
STRIPE_PRICE_MAP='{"pro_monthly":"price_REPLACE","pro_annual":"price_REPLACE","team_monthly":"price_REPLACE","team_annual":"price_REPLACE","enterprise_monthly":"price_REPLACE","enterprise_annual":"price_REPLACE","seat_monthly":"price_REPLACE","seat_annual":"price_REPLACE","pipeline_pack_monthly":"price_REPLACE","pipeline_pack_annual":"price_REPLACE","plugin_pack_monthly":"price_REPLACE","plugin_pack_annual":"price_REPLACE","api_pack_monthly":"price_REPLACE","api_pack_annual":"price_REPLACE","ai_pack_monthly":"price_REPLACE","ai_pack_annual":"price_REPLACE","storage_pack_monthly":"price_REPLACE","storage_pack_annual":"price_REPLACE","listing_pack_monthly":"price_REPLACE","listing_pack_annual":"price_REPLACE","retention_pack_monthly":"price_REPLACE","retention_pack_annual":"price_REPLACE","dora_history_pack_monthly":"price_REPLACE","dora_history_pack_annual":"price_REPLACE","advanced_reporting_monthly":"price_REPLACE","advanced_reporting_annual":"price_REPLACE","team_usage_analytics_monthly":"price_REPLACE","team_usage_analytics_annual":"price_REPLACE","compliance_standard_monthly":"price_REPLACE","compliance_standard_annual":"price_REPLACE","compliance_advanced_monthly":"price_REPLACE","compliance_advanced_annual":"price_REPLACE","stakeholder_reports_monthly":"price_REPLACE","stakeholder_reports_annual":"price_REPLACE"}'
# Discounts, promotions and lifecycle timing — code defaults, safe to omit.
BILLING_DISCOUNTS_ENABLED=true
BILLING_DISCOUNT_MAX_PERCENT=100
BILLING_DISCOUNT_MAX_CENTS=10000000
BILLING_PROMOTIONS_ENABLED=true
PAYMENT_GRACE_PERIOD_DAYS=7
RENEWAL_REMINDER_DAYS=7
Stripe subscription statuses are mapped to internal statuses by a fixed table in the app (no env var); notably unpaid ⇒ canceled (Stripe sets unpaid only after the grace period), and unknown statuses fall back to incomplete.
Under AWS Marketplace the customer subscribes on AWS, not in-app. AWS owns the contract and the bill; Pipeline Builder resolves the customer on redirect, reads entitlements to set the tier, listens to SNS for lifecycle changes, and reports add-on consumption as metered usage. Self-service in-app bundle purchase is disabled — entitlements flow from AWS.
In the AWS Marketplace Management Portal, create the SaaS product and define its dimensions:
pro / team / enterprise).Note the product code AWS assigns.
BILLING_ENABLED=true
BILLING_PROVIDER=aws-marketplace
AWS_MARKETPLACE_PRODUCT_CODE=<your-product-code>
AWS_MARKETPLACE_REGION=us-east-1 # defaults to AWS_REGION, else us-east-1
Set the product’s Fulfillment URL to the built-in fulfillment page:
https://<your-public-host>/marketplace/register
AWS POSTs the x-amzn-marketplace-token here as a form field after a customer subscribes. Because the AWS purchaser is not yet a Pipeline Builder user at this point, registration is two-phase:
POST /billing/marketplace/resolve runs ResolveCustomer on the token → GetEntitlements to determine the entitled tier (a dimension with no map entry falls back to developer) → banks a short-lived, single-use registrationRef (30 min TTL). It does not create a subscription yet — there’s no org to bind to. If the customer is already linked, it returns alreadyRegistered instead.
POST /billing/marketplace/resolve Body: { "token": "<x-amzn-marketplace-token>" }
→ { registrationRef, planName } | { alreadyRegistered: true }
POST /billing/marketplace/claim (billing:manage) binds the registrationRef to their organization, creating the subscription (keyed on the real orgId; the AWS customerIdentifier is stored only in metadata, never the AWS account id) and syncing the tier to quota.
POST /billing/marketplace/claim Body: { "registrationRef": "<ref>" }
The /marketplace/register page handles both: it resolves the token, then either shows a “Link to my organization” button (if already signed in) or sends the purchaser through sign-up/sign-in — carrying the registrationRef so it’s claimed automatically when they reach the dashboard. Guards: a registrationRef binds once (single-use), an AWS customer can’t bind to two orgs (409), and an org that already has an active subscription is rejected (409).
AWS Marketplace publishes notifications to two SNS topics that AWS Marketplace creates and owns for your product — you don’t create them, you subscribe to them. Both ARNs are shown in the AWS Marketplace Management Portal under your product’s Product summary:
| Portal label | Topic | Carries |
|---|---|---|
| Metering Service SNS topic ARN | aws-mp-subscription-notification-<product-code> |
subscribe-success, subscribe-fail, unsubscribe-pending, unsubscribe-success |
| Entitlement Service SNS topic ARN | aws-mp-entitlement-notification-<product-code> |
entitlement-updated (tier / quantity change) |
Set both, comma-separated. Both topics live in us-east-1 under AWS Marketplace’s own account 287250355862 (not your AWS account); <product-code> is your listing’s product code:
AWS_MARKETPLACE_SNS_TOPIC_ARN=arn:aws:sns:us-east-1:287250355862:aws-mp-subscription-notification-<product-code>,arn:aws:sns:us-east-1:287250355862:aws-mp-entitlement-notification-<product-code>
The list is an exact-match allowlist: a message whose TopicArn isn’t in it is rejected (403), and when the variable is unset every notification is rejected (fail-closed). Leaving one topic out silently drops that topic’s actions — the service logs a startup warning when either is missing.
Then subscribe the app’s SNS webhook (HTTPS) to each topic:
POST https://<your-public-host>/billing/marketplace/sns
The endpoint confirms the SNS SubscriptionConfirmation handshake and verifies message signatures, then processes entitlement updates, cancellations, and reactivations (re-checking entitlements and updating the plan).
Setting BILLING_PROVIDER=aws-marketplace in .env is enough: the deploy attaches
the grant for you. On EKS bin/setup.sh creates the billing Pod Identity
association and its policy; on EC2 CloudFormation attaches
InstanceRoleMarketplacePolicy from template.yaml. Both are conditional on that
one variable, so selecting the provider and deploying is the whole step.
The policy it attaches, for reference — or if you deploy outside setup.sh:
{
"Effect": "Allow",
"Action": [
"aws-marketplace:ResolveCustomer",
"aws-marketplace:GetEntitlements",
"aws-marketplace:BatchMeterUsage"
],
"Resource": "*"
}
Resource: "*" is not laziness — the Marketplace Metering and Entitlement APIs do
not support resource-level permissions. The bound is the action list: three
read/meter calls, no subscribe or modify.
Still yours to do: confirm the SNS subscription for your topic (Step 4), which happens in the AWS console because the topic is Marketplace-owned.
Three JSON maps connect AWS dimensions to the app’s plans, add-ons, and prices:
| Variable | Default | Maps | Purpose |
|---|---|---|---|
AWS_MARKETPLACE_DIMENSION_MAP |
identity | Marketplace tier dimension → local plan id | Resolve the entitled tier from GetEntitlements |
AWS_MARKETPLACE_BUNDLE_DIMENSION_MAP |
mostly identity — see note | Add-on bundle id → metered dimension key | Which dimension each add-on reports under |
AWS_MARKETPLACE_DIMENSION_PRICE_MAP |
{} |
Metered dimension → cents per unit per cycle | Drives credit drawdown; an unpriced dimension is reported in full |
Create one AWS entitlement dimension per paid tier. The free developer tier needs no dimension — an entitlement that resolves to nothing falls back to developer. If you name the AWS dimensions to match the plan ids, the identity default applies and you can omit AWS_MARKETPLACE_DIMENSION_MAP entirely; the explicit map is shown for clarity:
| Plan id | Monthly (default) | Suggested AWS dimension | Needs a dimension? |
|---|---|---|---|
developer |
Free | — | No — the fallback tier |
pro |
$39 | pro |
Yes |
team |
$79 | team |
Yes |
enterprise |
$599 | enterprise |
Yes |
# Identity naming → this line is optional (it's the default):
AWS_MARKETPLACE_DIMENSION_MAP='{"pro":"pro","team":"team","enterprise":"enterprise"}'
Create one metered AWS dimension per add-on you sell. Map each bundle id → its AWS dimension name, and give each dimension its per-unit list price (cents) so credit drawdown values a withheld unit correctly. The rows below use every add-on that ships today, at its default monthly price:
| Bundle id | AWS dimension | List price (default) | Available tiers |
|---|---|---|---|
seat |
Seat |
$19.99 (1999) |
team, enterprise |
pipeline_pack |
PipelinePack |
$15 (1500) |
team, enterprise |
plugin_pack |
PluginPack |
$10 (1000) |
all |
api_pack |
ApiPack |
$19.99 (1999) |
all |
ai_pack |
AiPack |
$19.99 (1999) |
all |
storage_pack |
StoragePack |
$19.99 (1999) |
all |
listing_pack |
ListingPack |
$4.99 (499) |
all |
retention_pack |
RetentionPack |
$15 (1500) |
all (max 7) |
dora_history_pack |
DoraHistoryPack |
$30 (3000) |
all (max 1) |
advanced_reporting |
AdvancedReporting |
$30 (3000) |
developer, pro, team |
team_usage_analytics |
TeamUsageAnalytics |
$30 (3000) |
team |
compliance_standard |
ComplianceStandard |
$29.90 (2990) |
developer, pro, team |
compliance_advanced |
ComplianceAdvanced |
$99.90 (9990) |
developer, pro, team |
stakeholder_reports |
StakeholderReports |
$30 (3000) |
pro, team |
AWS_MARKETPLACE_BUNDLE_DIMENSION_MAP='{"seat":"Seat","pipeline_pack":"PipelinePack","plugin_pack":"PluginPack","api_pack":"ApiPack","ai_pack":"AiPack","storage_pack":"StoragePack","listing_pack":"ListingPack","retention_pack":"RetentionPack","dora_history_pack":"DoraHistoryPack","advanced_reporting":"AdvancedReporting","team_usage_analytics":"TeamUsageAnalytics","compliance_standard":"ComplianceStandard","compliance_advanced":"ComplianceAdvanced","stakeholder_reports":"StakeholderReports"}'
AWS_MARKETPLACE_DIMENSION_PRICE_MAP='{"Seat":1999,"PipelinePack":1500,"PluginPack":1000,"ApiPack":1999,"AiPack":1999,"StoragePack":1999,"ListingPack":499,"RetentionPack":1500,"DoraHistoryPack":3000,"AdvancedReporting":3000,"TeamUsageAnalytics":3000,"ComplianceStandard":2990,"ComplianceAdvanced":9990,"StakeholderReports":3000}'
The built-in default is not pure identity. With
AWS_MARKETPLACE_BUNDLE_DIMENSION_MAPunset, every bundle maps to a dimension named exactly like its id exceptretention_pack→RetentionPackanddora_history_pack→DoraHistoryPack. The default covers every sellable bundle, and a test (billing-config.test.ts) fails if a new add-on is ever added without one — a bundle missing from the map is granted and then never metered, i.e. given away. The block above still sets the map explicitly, because the identity names are rarely what a real listing’s dimensions are called.
Only list the add-ons you actually sell on Marketplace — a bundle with no dimension mapping isn’t metered, and a dimension with no price in AWS_MARKETPLACE_DIMENSION_PRICE_MAP is reported in full (never drawn against for credit). Tier availability (the “Available tiers” column) is enforced separately by BILLING_BUNDLE_<ID>_TIERS.
The price-map values are cents per metered unit per metering cycle (cycle =
BILLING_METERING_INTERVAL_MS). The prices above are the monthly list defaults; if your metering cadence isn’t monthly, scale each value to the cycle. Either way, mirror your AWS listing’s dimension prices exactly — a wrong value directly mis-draws credit.
Add-on charges and usage-credit realization run on the metering cycle. Turn it on, but shadow it first:
BILLING_METERING_ENABLED=true
BILLING_METERING_INTERVAL_MS=3600000 # 1h; AWS BatchMeterUsage dedupes by (customer, dimension, hour)
BILLING_METERING_DRAWDOWN_DRYRUN=true # compute + log intended withholding, report FULL quantities, touch nothing
Watch the logs for a cycle or two, confirm the intended dimensions/quantities match your listing, then set BILLING_METERING_DRAWDOWN_DRYRUN=false to go live.
Metering is default-off, and usage-credit discounts on Marketplace require both
BILLING_DISCOUNTS_ENABLEDandBILLING_METERING_ENABLED— a credit would otherwise bank but never reduce the AWS bill, so it’s rejected. See Billing Discounts → AWS Marketplace.
GET /billing/marketplace/entitlements).BatchMeterUsage dimensions before going live.| Variable | Default | Description |
|---|---|---|
AWS_MARKETPLACE_PRODUCT_CODE |
— | The Marketplace product code |
AWS_MARKETPLACE_REGION |
AWS_REGION or us-east-1 |
Region for the Metering/Entitlement clients |
AWS_MARKETPLACE_SNS_TOPIC_ARN |
— | Comma-separated SNS topic ARNs accepted by the webhook — set both the subscription and entitlement topics |
AWS_MARKETPLACE_DIMENSION_MAP |
identity | JSON map of Marketplace tier dimension → local plan id |
AWS_MARKETPLACE_BUNDLE_DIMENSION_MAP |
mostly identity | JSON map of add-on bundle id → metered dimension key. The default names each dimension after its bundle id EXCEPT retention_pack → RetentionPack and dora_history_pack → DoraHistoryPack |
AWS_MARKETPLACE_DIMENSION_PRICE_MAP |
{} |
JSON map of metered dimension → local list price in cents per metered unit per cycle |
BILLING_METERING_ENABLED |
false |
Run the metering cycle (report add-on usage + realize credits). Off = no metering, and Marketplace credits are rejected |
BILLING_METERING_INTERVAL_MS |
3600000 |
Metering cycle cadence (1 hour) |
BILLING_METERING_DRAWDOWN_DRYRUN |
false |
Shadow mode — compute + log intended withholding but report full quantities and leave balances untouched |
Every variable the provider needs, at its code default. Replace the product code and the two topic ARNs; the dimension names below must match what you registered on the listing.
BILLING_ENABLED=true
BILLING_PROVIDER=aws-marketplace
AWS_MARKETPLACE_PRODUCT_CODE=REPLACE
AWS_MARKETPLACE_REGION=us-east-1
# BOTH AWS-owned topics from the listing's Product summary — unlisted topics are rejected.
AWS_MARKETPLACE_SNS_TOPIC_ARN=arn:aws:sns:us-east-1:287250355862:aws-mp-subscription-notification-REPLACE,arn:aws:sns:us-east-1:287250355862:aws-mp-entitlement-notification-REPLACE
# Tier dimensions. `developer` is the free fallback and needs none.
AWS_MARKETPLACE_DIMENSION_MAP='{"pro":"pro","team":"team","enterprise":"enterprise"}'
# All 14 sellable add-ons. An add-on missing here is granted and NEVER metered.
AWS_MARKETPLACE_BUNDLE_DIMENSION_MAP='{"seat":"Seat","pipeline_pack":"PipelinePack","plugin_pack":"PluginPack","api_pack":"ApiPack","ai_pack":"AiPack","storage_pack":"StoragePack","listing_pack":"ListingPack","retention_pack":"RetentionPack","dora_history_pack":"DoraHistoryPack","advanced_reporting":"AdvancedReporting","team_usage_analytics":"TeamUsageAnalytics","compliance_standard":"ComplianceStandard","compliance_advanced":"ComplianceAdvanced","stakeholder_reports":"StakeholderReports"}'
# Cents per metered unit. An unpriced dimension cannot be valued, so its usage is
# reported in full and the customer's credit is never applied.
AWS_MARKETPLACE_DIMENSION_PRICE_MAP='{"Seat":1999,"PipelinePack":1500,"PluginPack":1000,"ApiPack":1999,"AiPack":1999,"StoragePack":1999,"ListingPack":499,"RetentionPack":1500,"DoraHistoryPack":3000,"AdvancedReporting":3000,"TeamUsageAnalytics":3000,"ComplianceStandard":2990,"ComplianceAdvanced":9990,"StakeholderReports":3000}'
# Metering is off until you turn it on; start in dry-run and watch a cycle or two.
BILLING_METERING_ENABLED=false
BILLING_METERING_INTERVAL_MS=3600000
BILLING_METERING_DRAWDOWN_DRYRUN=true
# Discounts, promotions and lifecycle timing — code defaults, safe to omit.
BILLING_DISCOUNTS_ENABLED=true
BILLING_PROMOTIONS_ENABLED=true
PAYMENT_GRACE_PERIOD_DAYS=7
RENEWAL_REMINDER_DAYS=7
| Endpoint | Auth | Provider | Purpose |
|---|---|---|---|
POST /billing/stripe/webhook |
Signature | Stripe | Receives + verifies Stripe events (raw body) |
POST /billing/marketplace/resolve |
None (AWS redirect) | Marketplace | Exchange a registration token for a single-use registrationRef (banks a pending registration; no subscription yet) |
POST /billing/marketplace/claim |
Auth (billing:manage) |
Marketplace | Bind a registrationRef to the caller’s org and create the subscription |
POST /billing/marketplace/sns |
SNS signature | Marketplace | Entitlement/subscription lifecycle notifications |
GET /billing/marketplace/entitlements |
Auth | Marketplace | Current entitlements for the account |
The frontend fulfillment page lives at /marketplace/register (set this as the AWS Fulfillment URL).