Skip to main content

Migrating to Stripe Entitlements with ProdVerdict

This guide walks through migrating a SaaS from local DB has_paid_access flags to Stripe Entitlements as the source of truth for feature access. ProdVerdict's entitlements-migration contract verifies the migration is complete; the Access contract with source_of_truth: stripe_entitlements verifies the steady state afterward.

Why migrate

Stripe Entitlements makes Stripe the source of truth for which features a customer can access. Instead of syncing subscription state into your DB and reading from there, you query Stripe's Entitlements API at request time. This eliminates the webhook-handler bug class — no has_paid_access flag to forget to flip.

The migration itself is the risky part. You have N existing users with has_paid_access = true in your DB. You need to grant each of them the corresponding Entitlements feature in Stripe. Miss one, and a paying customer loses access. ProdVerdict catches both directions of drift.

Prerequisites

  • Stripe account with Entitlements enabled
  • A restricted Stripe key with entitlements.read + customers: Read
  • A read-only Postgres connection to your app database
  • ProdVerdict v0.13.0+

Step 1: Define Entitlements products and features in Stripe

In the Stripe dashboard, create Products and Features for each plan you offer. Set a lookup_key on each Feature that matches your plan slug (e.g., pro, starter). Attach Features to Products.

Step 2: Add the entitlements-migration contract to prodverdict.yml

version: 1
contracts:
- type: entitlements-migration
database:
url_env: DATABASE_URL
users_table: users
columns:
id: id
stripe_customer_id: stripe_customer_id
has_paid_access: has_paid_access
plan: plan
entitlements:
secret_env: STRIPE_SECRET_KEY
require_stripe_customer_id: true
severity: high
fix: "Grant the entitlement in Stripe, then flip the DB flag post-migration."

See entitlements-migration contract for full schema.

Step 3: Run the contract to find migration gaps

export STRIPE_SECRET_KEY=rk_live_...
export DATABASE_URL=postgresql://readonly:...@host/db

npx prodverdict check entitlements-migration --config prodverdict.yml
FindingMeaningAction
user:ID high — no stripe_customer_idUser has has_paid_access=true but no Stripe customer idBackfill customer id in Stripe + DB
user:ID high — migration gapDB paid, no entitlement in StripeGrant the entitlement in Stripe
user:ID medium — stale grantStripe has grant, DB says no accessVerify grant, flip DB flag
customer:ID medium — duplicate grantsSame feature granted twiceRemove duplicate grant

Step 4: Backfill using JSON output

npx prodverdict check entitlements-migration --config prodverdict.yml --format json > gaps.json

Pipe findings into a script that grants entitlements in Stripe for each flagged user.

Step 5: Re-run until the contract passes

After each backfill batch:

npx prodverdict check entitlements-migration --config prodverdict.yml

Pass criteria: every paid DB user has a matching Stripe grant, no stale grants, no duplicates, no missing stripe_customer_id.

Step 6: Flip your app to read from Stripe Entitlements

Once the migration contract passes, update your app to call Stripe's Entitlements API at request time instead of reading has_paid_access from the DB.

Step 7: Switch the Access contract to stripe_entitlements source

- type: access
source_of_truth: stripe_entitlements
database:
url_env: DATABASE_URL
users_table: users
columns:
id: id
stripe_customer_id: stripe_customer_id
has_paid_access: has_paid_access
plan: plan
entitlements:
secret_env: STRIPE_SECRET_KEY
plans:
pro: pro
starter: starter

Schedule steady-state verification:

npx prodverdict scheduled --frequency hourly --install

See Access contract and Scheduled vs PR.

Step 8: Remove the entitlements-migration contract

Once steady state is verified on a schedule, remove entitlements-migration from prodverdict.yml.

Rollback

If the migration goes wrong, the entitlements-migration contract is your rollback signal. As long as it's failing, you haven't cut over. Your DB has_paid_access flag remains the source of truth until you flip the read path in step 6.

See also