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 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
/stripeConfig/products/success-url -> "https://shop.example.com/done?session={CHECKOUT_SESSION_ID}"
/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