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
| Finding | Meaning | Action |
|---|---|---|
user:ID high — no stripe_customer_id | User has has_paid_access=true but no Stripe customer id | Backfill customer id in Stripe + DB |
user:ID high — migration gap | DB paid, no entitlement in Stripe | Grant the entitlement in Stripe |
user:ID medium — stale grant | Stripe has grant, DB says no access | Verify grant, flip DB flag |
customer:ID medium — duplicate grants | Same feature granted twice | Remove 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
- Entitlements migration contract
- Access contract — Stripe Entitlements source
- Scheduled vs PR
- SDK example:
examples/nextjs-stripe-entitlements/