Products & Orders
RESTHeart|
Note
|
The products mode is available starting from RESTHeart v9.8. It is independent of the subscriptions mode: either can be enabled alone, or both together. |
The products mode sells one-time purchases through Stripe Checkout.
The client sends product ids and quantities. The server resolves prices from the catalog, creates a Checkout session, and records the order. Everything downstream — fulfilment, shipping, returns — is your application’s concern.
Quick start
# 1. Create an order
curl -X POST http://localhost:8080/orders \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"items": [{"productId": "SKU-1234", "quantity": 2}]}'
# Response: {_id, checkout_url, secret}
# 2. Redirect the customer to checkout_url
# 3. After payment, the order becomes "paid" via webhook
Collections
| Collection | Module’s role | Purpose |
|---|---|---|
|
Read |
Product definitions and prices |
|
Write |
One document per checkout |
|
Write |
Append-only ledger of money movements |
|
Read, optional |
Stock check before checkout |
The initializer creates these collections, their indexes and the order JSON schema on startup. It never overwrites existing structures.
The catalog
{
"_id": "SKU-1234",
"type": "physical", // physical | digital
"name": "Blue Widget",
"description": "Anodised, 40mm", // optional
"image_url": "https://…", // optional
"unit_amount": 2500, // integer, smallest currency unit
"currency": "eur", // optional, defaults to products.default-currency
"purchasable": true,
"tax_code": "txcd_99999999", // optional
"stripe_price_id": null // optional, see below
}
Required: _id, type, name, unit_amount, purchasable.
The module never writes to the catalog. CRUD is your application’s, served by the MongoDB API.
stripe_price_id is an escape hatch: absent (the normal case), the module builds an ad-hoc price so no product needs to exist in Stripe. Present, it uses that Stripe Price directly.
|
Warning
|
|
physical vs digital
physical |
digital |
|
|---|---|---|
Shipping address collected |
yes |
no |
Inventory checked |
yes |
no |
A cart may mix types. If any line is physical, the session collects a shipping address.
Creating an order
The client sends items only. Any other field is rejected with 400.
POST /orders
{"items": [{"productId": "SKU-1234", "quantity": 2}]}
Response:
{
"_id": { "$oid": "…" },
"checkout_url": "https://checkout.stripe.com/c/pay/cs_…",
"secret": "6f2c…"
}
Redirect the customer to checkout_url.
| Code | Meaning |
|---|---|
|
Order created; redirect to |
|
Unexpected field, bad quantity, unknown product, or mixed currencies |
|
Product not purchasable or out of stock |
|
Stripe unreachable |
Order lifecycle
Orders are created in pending_payment. Webhooks move them forward.
| Event | Effect |
|---|---|
|
→ |
|
→ |
|
→ |
|
→ |
|
Appends refund transaction, updates |
|
Appends dispute transaction |
|
Warning
|
|
Status transitions are monotonic: a redelivered event is a no-op, not a regression.
The return page
Two things make the page the buyer lands on after Checkout harder than it looks.
It does not know which order it is showing. Stripe substitutes only {CHECKOUT_SESSION_ID} in success-url, and a guest has no session for the server to recognise them by. Put {ORDER_ID} and {ORDER_SECRET} in the configured URL and the plugin interpolates them when it creates the Checkout session:
/stripeConfig/products/success-url -> "https://shop.example.com/done#order={ORDER_ID}&secret={ORDER_SECRET}"
|
Important
|
Put The secret is a bearer credential — it is the only thing standing between a stranger and a guest’s order, with their email and shipping address in it. A fragment is never sent to a server, so it stays out of access logs, proxy logs and |
Substitution is opt-in: a success-url without the placeholders is used exactly as configured, so existing deployments are unaffected. {CHECKOUT_SESSION_ID} still works alongside them — Stripe fills that one in after the plugin is done.
The redirect races the webhook. The browser comes back as soon as the payment is authorised, but the order only leaves pending_payment when the webhook arrives — a separate connection, usually seconds later, with no ordering guarantee against the redirect. A page that reads the order once will routinely tell someone who just paid that they have not.
Poll until it leaves pending_payment, and treat running out of time as "not yet" rather than "failed" — the payment succeeded either way, only the webhook is late.
|
Tip
|
|
Notifications
Two of the lifecycle events above can also email the buyer: order-confirmed when an order is
paid, order-refunded when a refund is recorded. Both are off by default — turn one on
explicitly:
/stripeConfig/products/notifications/order-confirmed/enabled -> true
/stripeConfig/products/notifications/order-refunded/enabled -> true
An order notification with no explicit enabled key is not sent — there is no implicit default
the way subscription-canceled and over-limit default to on for subscriptions (see
Configuration → Notifications).
Each has a built-in bilingual template
(stripe/src/main/resources/email-templates/order-confirmed.html and order-refunded.html),
filled in with:
| Placeholder | Value |
|---|---|
|
The order’s |
|
The charged or refunded amount, in the currency’s minor unit — cents for EUR/USD, as Stripe sends it |
|
The same amount converted to the currency’s major unit, formatted with that currency’s own number of decimals (2 for EUR/USD, 0 for JPY, 3 for BHD…) — what a template should actually display |
|
The order’s currency code |
|
Always |
|
Current year |
Override the template per notification, same mechanism subscription templates use:
/stripeConfig/products/notifications/order-confirmed/enabled -> true
/stripeConfig/products/templates/order-confirmed -> "<html>…</html>"
For a multi-tenant deployment, the tenant’s HTML travels as override-stripe-tmpl-order-confirmed
— see Multi-tenancy.
The ledger
transactions is append-only. One order has many money movements:
{
"order_id": { "$oid": "…" },
"type": "payment", // payment | refund | dispute
"amount": 6600,
"currency": "eur",
"stripe_object_id": "pi_…",
"stripe_event_id": "evt_…", // unique — idempotency
"occurred_at": { "$date": 1771200300000 }
}
The unique index on stripe_event_id prevents double-recording on Stripe retries.
Authorization
The module registers no ACL rule for /orders. Grant access in your deployment:
fileAclAuthorizer:
permissions:
# team owners can buy and see their team's orders
- role: user
predicate: path-prefix(path="/orders") and equals(@user.team.role, "owner")
priority: 100
mongo:
readFilter: >
{"payer.id": "@user.team._id"}
mergeRequest: >
{"buyer_id": "@user._id"}
# guest checkout (omit to disable)
- role: $unauthenticated
predicate: path(path="/orders") and method(value="POST")
priority: 100
- role: $unauthenticated
predicate: path-template(value="/orders/{id}") and method(value="GET")
priority: 100
mongo:
readFilter: >
{"secret": "@qparams['secret']"}
|
Warning
|
Grant |
Guest checkout
Enabled by the ACL rules above, not by a config flag.
A guest supplies email in the request body and reads the order back with _id + secret:
curl -X POST http://localhost:8080/orders \
-d '{"email": "buyer@example.com", "items": [{"productId": "SKU-1234", "quantity": 1}]}'
curl "http://localhost:8080/orders/{id}?secret=6f2c…"
Abandoned carts are cleaned up by a TTL index on expires_at, partial to status: "pending_payment".
Configuration
Override file:
/stripeConfig/enabled -> true
/stripeConfig/secret-key -> "${STRIPE_SECRET_KEY}"
/stripeConfig/webhook-secret -> "${STRIPE_WEBHOOK_SECRET}"
/stripeConfig/products/enabled -> true
/stripeConfig/products/catalog-collection -> catalog
/stripeConfig/products/orders-collection -> orders
/stripeConfig/products/transactions-collection -> transactions
# /stripeConfig/products/inventory-collection -> inventory # omit to disable stock checks
/stripeConfig/products/default-currency -> eur
/stripeConfig/products/buyer-email-field -> _id
# {ORDER_ID}/{ORDER_SECRET} are interpolated by the plugin — see "The return page"
/stripeConfig/products/success-url -> "https://shop.example.com/done#order={ORDER_ID}&secret={ORDER_SECRET}"
/stripeConfig/products/cancel-url -> "https://shop.example.com/cart"
/stripeConfig/products/session-expires-minutes -> 60
/stripeConfig/products/max-line-items -> 50
/stripeConfig/products/max-quantity-per-line -> 100
/stripeConfig/products/automatic-tax -> true
/stripeService/enabled -> true
/stripeInitializer/enabled -> true
/stripeWebhookService -> { "enabled": true }
/ordersCheckoutInterceptor/enabled -> true
/ordersCheckoutResponseInterceptor/enabled -> true
See Configuration for shared keys and Multi-tenancy for per-tenant overrides.
Out of scope
-
Stock reservation — the optional
inventorycheck is best-effort only -
Fulfilment and tracking — add your own fields to the order document
-
Refund initiation — start refunds from Stripe Dashboard; they flow back via webhook