- Prerequisites
- Step 1 — Create a product and prices
- Step 2 — Get your API key
- Step 3 — Start the webhook listener
- Step 4 — Configure RESTHeart
- Step 5 — Read the catalog
- Step 6 — Check the starting state
- Step 7 — Subscribe
- Step 8 — Watch the webhook
- Step 9 — Confirm
- Step 10 — Assign a seat
- Step 11 — Gate a feature
- Step 12 — Cancel
- Going to production
- Where to go next
Tutorial: Stripe Setup
RESTHeartThis tutorial takes a RESTHeart instance from nothing to a working subscription: a Stripe test account, a product with monthly and annual prices, webhooks reaching your laptop, and a first end-to-end purchase.
Everything happens in Stripe test mode. No real money moves, and the test card numbers below only work there.
Expect about 30 minutes.
Prerequisites
-
RESTHeart 9.8 or later, running with MongoDB
-
restheart-accountsenabled, with at least one registered user and team — the default provider bills teams -
A Stripe account (free)
-
The Stripe CLI
|
Note
|
Confirm you are in test mode before starting: the Dashboard shows a Test mode toggle, and test keys begin with |
Step 1 — Create a product and prices
In Stripe Dashboard → Products → Add product:
-
Name:
Gold— this is what customers see, and whatGET /stripe/plansreturns -
Description:
For growing teams -
Pricing:
Recurring,€29.00,Monthly
Save, then add a second price to the same product: €290.00, Yearly.
Copy both price ids — price_…, under each price:
Monthly: price_1AbCdEfGhIjKlMnOpQrStUvW
Annual: price_1PqRsTuVwXyZaBcDeFgHiJkL
|
Tip
|
Two prices, one product.
The product carries the name and description shown to customers; the prices carry the amounts.
That is why raising a price later never means editing your configuration: |
Step 2 — Get your API key
Developers → API keys → reveal the Secret key (sk_test_…).
|
Warning
|
Never commit this key, and never paste it into your configuration file. Even a test key can create customers, sessions and refunds, and it identifies your account. Keep it in the environment. |
export STRIPE_SECRET_KEY="sk_test_51AbC..."
Step 3 — Start the webhook listener
Stripe cannot reach localhost, so the CLI forwards events to it:
stripe login
stripe listen --forward-to localhost:8080/stripe/webhook
It prints a signing secret for this session:
> Ready! Your webhook signing secret is whsec_1a2b3c4d5e6f... (^C to quit)
export STRIPE_WEBHOOK_SECRET="whsec_1a2b3c4d5e6f..."
|
Important
|
This secret belongs to the CLI session.
It changes each time you restart Leave this terminal running for the rest of the tutorial. If you restart it, re-export the new secret and restart RESTHeart. |
Step 4 — Configure RESTHeart
Create an override file (e.g. stripe.conf):
/stripeConfig/enabled -> true
/stripeConfig/secret-key -> "${STRIPE_SECRET_KEY}"
/stripeConfig/webhook-secret -> "${STRIPE_WEBHOOK_SECRET}"
/stripeConfig/subscriptions/enabled -> true
/stripeConfig/subscriptions/default-plan -> free
/stripeConfig/subscriptions/success-url -> "http://localhost:4200/billing?success=true"
/stripeConfig/subscriptions/cancel-url -> "http://localhost:4200/billing?canceled=true"
/stripeConfig/subscriptions/portal-return-url -> "http://localhost:4200/billing"
/stripeConfig/subscriptions/plans -> {
"free": { "seats": { "mode": "capped", "max": 1 } },
"gold": {
"price-id-monthly": "price_1AbCdEfGhIjKlMnOpQrStUvW",
"price-id-annual": "price_1PqRsTuVwXyZaBcDeFgHiJkL",
"trial-period-days": 14,
"seats": { "mode": "capped", "max": 10 }
}
}
/stripeService/enabled -> true
/stripeInitializer/enabled -> true
/stripeCatalogCache/enabled -> true
/stripeCheckoutService/enabled -> true
/stripePortalService/enabled -> true
/stripeSubscriptionService/enabled -> true
/stripeWebhookService/enabled -> true
/stripeLicensesService/enabled -> true
/stripePlansService/enabled -> true
Grant access to the endpoints — the module registers an ACL rule only for /stripe/webhook:
# append to the same file or a separate override file
/fileAclAuthorizer/permissions/- -> {
"role": "user",
"predicate": "path-prefix(path=\"/stripe/checkout\") or path-prefix(path=\"/stripe/portal\") or path-prefix(path=\"/stripe/subscription\") or path-prefix(path=\"/stripe/licenses\")",
"priority": 10
}
/fileAclAuthorizer/permissions/- -> {
"role": "$unauthenticated",
"predicate": "path(path=\"/stripe/plans\") and method(value=\"GET\")",
"priority": 10
}
Start RESTHeart with the override file and check the startup log:
[stripe] plugin initialised — mode=TEST, db=restheart, teams-collection=teams, plans=[free, gold]
|
Important
|
If you see an
|
Step 5 — Read the catalog
curl -s http://localhost:8080/stripe/plans | jq
{
"default_plan": "free",
"plans": [
{ "id": "free", "name": "free", "seats": { "mode": "capped", "max": 1 } },
{
"id": "gold",
"name": "Gold",
"description": "For growing teams",
"seats": { "mode": "capped", "max": 10 },
"prices": {
"month": { "price_id": "price_1AbC…", "amount": 2900, "currency": "eur" },
"year": { "price_id": "price_1PqR…", "amount": 29000, "currency": "eur" }
}
}
]
}
Seeing Gold and 2900 here proves your key works and the price ids are correct — before any customer is involved.
free shows "name": "free" because it has no price id and therefore no Stripe product to read a name from. That is expected.
|
Tip
|
If |
Step 6 — Check the starting state
Log in as a user who owns a team and note the JWT:
TOKEN="eyJhbGciOi..."
curl -s http://localhost:8080/stripe/subscription \
-H "Authorization: Bearer $TOKEN" | jq
{
"plan": "free",
"active": false,
"licensed": false,
"cancel_at_period_end": false,
"seats": { "limit": 1, "licensed": 0, "available": 1, "over_limit": false }
}
|
Tip
|
|
Step 7 — Subscribe
curl -s -X POST http://localhost:8080/stripe/checkout \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"plan": "gold", "interval": "month"}' | jq
{ "url": "https://checkout.stripe.com/c/pay/cs_test_..." }
Open that URL and pay with a test card:
| Card number | Behaviour |
|---|---|
|
Succeeds |
|
Declined — insufficient funds |
|
Requires 3D Secure authentication |
Any future expiry, any CVC, any postcode.
|
Tip
|
|
Step 8 — Watch the webhook
The stripe listen terminal shows the events as they arrive:
2026-02-14 10:23:45 --> checkout.session.completed [evt_1...]
2026-02-14 10:23:45 <-- [200] POST http://localhost:8080/stripe/webhook
2026-02-14 10:23:46 --> customer.subscription.created [evt_2...]
2026-02-14 10:23:46 <-- [200] POST http://localhost:8080/stripe/webhook
customer.subscription.created is the one that matters — it carries the state that changes the plan.
| Response | Meaning |
|---|---|
|
Verified and applied. |
|
Signature mismatch — |
|
The event was genuine and something downstream failed. Read the RESTHeart log. |
nothing |
|
Step 9 — Confirm
curl -s http://localhost:8080/stripe/subscription \
-H "Authorization: Bearer $TOKEN" | jq
{
"plan": "gold",
"status": "trialing",
"active": true,
"licensed": false,
"cancel_at_period_end": false,
"trial_end": "2026-02-28T10:23:45Z",
"seats": { "limit": 10, "licensed": 0, "available": 10, "over_limit": false }
}
trialing because the plan declares a 14-day trial; active is true throughout it.
Note licensed: false — paying did not license anyone, not even the buyer.
Step 10 — Assign a seat
curl -s -X POST http://localhost:8080/stripe/licenses \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"userId": "owner@example.com"}' -i | head -1
HTTP/1.1 201 Created
GET /stripe/subscription now reports "licensed": true and seats.available: 9.
Step 11 — Gate a feature
Add a rule that requires the gold plan:
fileAclAuthorizer:
permissions:
- role: user
predicate: path-prefix(path="/restheart/reports") and equals(@subscription.plan, "gold")
priority: 100
Restart, then request /restheart/reports as the subscribed user (allowed) and as a user from a different, unsubscribed team (403).
That 403 is the whole point of the module: the feature is protected server-side, not by hiding a button.
|
Warning
|
Never gate See The lockout trap. |
Step 12 — Cancel
curl -s -X POST http://localhost:8080/stripe/portal \
-H "Authorization: Bearer $TOKEN" | jq -r .url
Open it and cancel the subscription. The subscription is scheduled to end at the period boundary:
{ "plan": "gold", "active": true, "cancel_at_period_end": true }
Access continues — the customer paid for the period. To see the final transition without waiting, cancel immediately from Dashboard → Customers → the subscription → Cancel subscription → immediately.
customer.subscription.deleted then arrives and the team returns to free.
Going to production
| Step | Action |
|---|---|
1 |
Recreate the products and prices in live mode — test-mode price ids do not exist in live mode. Update your configuration with the live ids. |
2 |
Register a real webhook endpoint: Developers → Webhooks → Add endpoint, URL |
3 |
Use the endpoint’s signing secret as |
4 |
Set |
5 |
Confirm the startup log says |
6 |
Set |
7 |
Configure the Customer Portal in Settings → Billing → Customer portal — which plan changes and cancellation options customers get is decided there, not in your configuration. |
8 |
Decide about notifications: if you enable |
|
Warning
|
Verify webhook delivery in production before announcing anything. Make one real purchase and confirm Developers → Webhooks → your endpoint shows |
Where to go next
-
Plans & Seats — seat modes and the over-limit contract
-
Plan Gates & ACL — the
@subscriptionvariable in full -
Subscription Owner Provider — billing something other than a team
-
Multi-tenancy — many tenants, many Stripe accounts