Start here
Tutorial: orders, stock and money
A checkout API, where getting it wrong costs real money. Pointers and dates decoded straight from the request body, a total the client cannot forge, stock two shoppers cannot both take, and permissions that follow the order's status. Every step shows exactly what the browser sends and what your handler receives.
The first tutorial builds something small and safe. This one is the opposite: every step here is a place where real systems lose money or leak data, and the point is to show what the library does about each one.
What you are building
A storefront back end with four jobs, in order of how badly they go wrong when done naively:
| Job | The naive version | What you will write |
|---|---|---|
| Decode the request | twenty set() calls that drift from the model |
Order.fromParams() |
| Total the order | trust req.params.total |
price it server-side from the database |
| Take the stock | stock -= qty, and oversell under load |
a transaction plus a version field |
| Decide who may look | a where clause in every query |
one ACL, written when the row is created |
1. Set up
npx parse-server-kit new shop-api
cd shop-api
npm run db:up # MongoDB as a replica set - transactions need one
npm run dev
The replica set matters. Transactions and @ParseVersionField are both
MongoDB features that a standalone mongod does not have, and steps 6 and 7
depend on them. The bundled docker-compose.yml starts a single-node replica
set, which is enough.
Startup seeds an Admin and an Editor role and a
demo user, and prints a session token. Every request below carries that
token — it is how Parse knows who is asking, and therefore which ACLs apply.
2. Products, with stock worth protecting
psk g resource Product
Then fill in src/models/Product.ts:
import {
ParseClass, ParseField, BaseModel, ParseVersionField, roleKey,
} from 'parse-server-kit';
@ParseClass('Product', {
description: 'Something for sale',
// Class-level permissions: the coarse gate, checked before any row is
// looked at. Anyone may browse the catalogue; only an Admin may change it.
clp: {
find: { '*': true },
get: { '*': true },
count: { '*': true },
create: { [roleKey('Admin')]: true },
update: { [roleKey('Admin')]: true },
delete: { [roleKey('Admin')]: true },
},
})
export default class Product extends BaseModel {
constructor() { super('Product'); }
// `declare`, never `name!: string`. @ParseField installs a getter and setter
// on the prototype; a real class field would shadow them with an own property
// set to undefined, and every read would come back empty with nothing logged.
@ParseField({ type: 'String', required: true, maxLength: 200 })
declare name: string;
// `unique: true` creates a unique index at startup, so two products cannot
// share an SKU even if two requests race.
@ParseField({ type: 'String', required: true, unique: true })
declare sku: string;
// Money, in the smallest unit. Integers, because 0.1 + 0.2 is not 0.3 and
// you do not want to discover that in an invoice.
@ParseField({ type: 'Number', required: true, min: 0, description: 'Price in cents' })
declare priceCents: number;
@ParseField({ type: 'Number', required: true, min: 0 })
declare stock: number;
// The whole optimistic-locking feature. It declares the Number field itself,
// so there is no @ParseField above it. Nothing else in your code ever reads
// or writes this: every object carries the version it was read at, and every
// save asserts that version is still current.
@ParseVersionField()
declare version: number;
}
@ParseVersionField needs the versioned MongoDB adapter. If it is missing —
or you are on Postgres — the field is declared and never enforced. As of 2.8.0
that logs a [Versioning] line at startup rather than failing silently. Read
it once and you never have to wonder.
3. The order model, and every type fromParams decodes
This model exists partly to be realistic and partly to cover, in one place, each field
type that fromParams converts for you.
@ParseClass('Order', {
description: 'A customer order',
// Every route below goes through a cloud function using the master key or an
// explicit session, so nothing needs direct class access. Deny it all: the
// only way in is through code you wrote.
clp: {
find: {}, get: {}, count: {}, create: {}, update: {}, delete: {},
},
// One index serving the customer's own order history, newest first.
compoundIndexes: [{ fields: ['customer', 'createdAt'] }],
})
export default class Order extends BaseModel {
constructor() { super('Order'); }
// Pointer. `targetClass` is REQUIRED - without it the decorator throws at
// import, and fromParams would have no idea what to build a pointer to.
@ParseField({ type: 'Pointer', targetClass: '_User', required: true })
declare customer: Parse.User;
// Array of pointers. Also needs targetClass - an Array field without one
// stays exactly as the client sent it, which for a list of ids means a list
// of strings that no query will ever match.
@ParseField({ type: 'Array', targetClass: 'Coupon' })
declare coupons: Parse.Object[];
// Date. The client sends an ISO string; fromParams gives you a Date.
@ParseField({ type: 'Date' })
declare deliverAfter: Date;
// GeoPoint. The client sends {latitude, longitude}; fromParams builds the
// Parse.GeoPoint. `geo: true` adds the 2dsphere index that makes
// "orders near here" a query rather than a full scan.
@ParseField({ type: 'GeoPoint', geo: true })
declare dropPoint: Parse.GeoPoint;
@ParseField({ type: 'String', maxLength: 500 })
declare note: string;
// Server-owned. Listed here so it is in the schema and the OpenAPI output,
// but step 5 is about making sure the client can never set it.
@ParseField({ type: 'Number', min: 0, description: 'Computed server-side, in cents' })
declare totalCents: number;
// `enum` is enforced twice: by validateOrThrow in your trigger, and by the
// MongoDB $jsonSchema validator applyMongoValidators installs at boot. The
// second one catches writes that never went through your code at all.
@ParseField({ type: 'String', enum: ['pending', 'paid', 'shipped', 'cancelled'] })
declare status: string;
}
4. Placing an order — what the browser actually sends
Here is the whole point of fromParams. The frontend sends flat JSON: ids as
strings, dates as strings, a geopoint as a plain object. Your model says what those
fields are. fromParams reads the model's metadata and does the conversion.
POST /api/orders/createOrder
X-Parse-Application-Id: shop-api
X-Parse-Session-Token: r:aecd1f21d674c1db3047fb7e8eac3fa5
Content-Type: text/plain
{
"customer": "QeC10HzbsH",
"coupons": ["cpn_A1", "cpn_B2"],
"deliverAfter": "2026-09-01T09:00:00.000Z",
"dropPoint": { "latitude": 24.7136, "longitude": 46.6753 },
"note": "Leave at reception",
"lines": [
{ "product": "prd_desk", "quantity": 1 },
{ "product": "prd_chair", "quantity": 2 }
]
}
Content-Type: text/plain is not a mistake — it is what
conditionalJsonMiddleware parses, matching Parse's own convention of
avoiding a CORS preflight on every write.
// src/functions/order.ts
import {
Route, CloudFunction, Transactional, catchError, MAX_QUERY_LIMIT,
} from 'parse-server-kit';
import Order from '../models/Order';
@Route(Order)
class OrderFunctions {
@CloudFunction({
methods: ['POST'],
description: 'Place an order',
validation: {
requireUser: true,
fields: { lines: { required: true } },
},
swagger: { tags: ['Orders'] },
})
static async createOrder(req: Parse.Cloud.FunctionRequest) {
// ONE line for the five fields the model knows about. It reads the
// @ParseField metadata and converts as it goes:
//
// customer "QeC10HzbsH" -> Pointer<_User>
// coupons ["cpn_A1","cpn_B2"] -> [Pointer<Coupon>, Pointer<Coupon>]
// deliverAfter "2026-09-01T09:..." -> Date
// dropPoint {latitude,longitude} -> Parse.GeoPoint
// note "Leave at reception" -> String, unchanged
//
// Add a field to the model tomorrow and this line already handles it.
const order = Order.fromParams(req.params);
// `lines` is NOT a model field, so fromParams left it alone - correctly.
// It is a list of objects carrying a quantity, which is not something the
// field metadata can describe. Step 5 handles it explicitly.
...
}
}
customer in the body, so a caller could name someone
else. That is not automatically a hole — the ACL you set in step 7 decides who can read
the resulting order — but it does mean the attribution is the client's. If an
order must belong to whoever is signed in, ignore the body and say so:
// req.user is set by the session token and cannot be forged.
order.customer = req.user!;
That one line is the difference between "the client suggests who the customer is" and
"the server decides". Do the same for any field that is really an identity.
What fromParams deliberately does not do
It is worth being precise, because the gaps are design decisions rather than omissions:
| Input | Result | Why |
|---|---|---|
A key with no @ParseField |
ignored | Only declared fields are set, so an unexpected key cannot reach the database |
Array field with no targetClass |
left as raw values | Nothing says what to point at; guessing would be worse |
Pointer to IMG or File |
skipped | Uploads need their own handling; see Files & images |
Pointer given null or {} |
set to null |
An explicit clear is a real intent and has to be expressible |
Nested objects like lines |
ignored | Not a field type; write the loop, as below |
The full rules, including the security discussion, are on the fromParams page.
5. Never trust the client's total
The request deliberately did not include a price, and if it had, you would throw it away. Prices come from the database, every time.
import { catchError } from 'parse-server-kit';
interface LineInput { product: string; quantity: number }
/**
* Turn the client's line list into priced lines, using the database as the
* only source of truth for price and availability.
*
* Returns the lines and the total, so the caller never has to add up
* anything the client sent.
*/
async function priceLines(lines: LineInput[]) {
// Reject nonsense before touching the database. A negative quantity is a
// refund by another name, and a fractional one is a rounding bug waiting.
for (const line of lines) {
if (!Number.isInteger(line.quantity) || line.quantity < 1) {
throw new Parse.Error(142, `Quantity must be a positive whole number`);
}
}
// One query for every product, not one per line. `containedIn` is the
// difference between 2 round trips and 2N of them.
const ids = lines.map(l => l.product);
const [err, products] = await catchError(
new Parse.Query(Product).containedIn('objectId', ids).find({ useMasterKey: true })
);
if (err) throw err;
// Cast: Parse.Query returns Parse.Object, so `p.priceCents` is not available
// until you tell TypeScript what it really is.
const byId = new Map((products as Product[]).map(p => [p.id, p]));
let totalCents = 0;
const priced = lines.map(line => {
const product = byId.get(line.product);
// An id that does not resolve is a bad request, not an empty line.
if (!product) throw new Parse.Error(101, `No product ${line.product}`);
if (product.stock < line.quantity) {
throw new Parse.Error(142, `Only ${product.stock} left of ${product.name}`);
}
// The price is read here, not received. This is the whole point.
const unit = product.priceCents;
totalCents += unit * line.quantity;
return { product, quantity: line.quantity, unitCents: unit };
});
return { priced, totalCents };
}
{
"lines": [{ "product": "prd_desk", "quantity": 1 }],
"totalCents": 1
}
{ "totalCents": 24900 }
fromParams did set totalCents to 1 — it is a declared
field, so it was decoded like any other. Then the handler overwrote it with the
priced figure before saving. Order matters: compute after
fromParams, never before.
6. Two shoppers, one unit of stock
This is the step that separates a demo from a system. Two requests read
stock: 1 at the same moment, both check "is there enough?", both say yes,
and both write stock: 0. You have sold two and have one.
Two mechanisms fix it, and they do different jobs:
- A transaction makes the order and the stock change one unit of work — either both happen or neither does.
- The version field makes the stock write refuse to land if anyone else changed the row since you read it. A transaction alone does not give you this; two transactions can still interleave a read-then-write.
@CloudFunction({ // MUST be above: it captures the wrapped method
methods: ['POST'],
validation: { requireUser: true, fields: { lines: { required: true } } },
swagger: { tags: ['Orders'] },
})
@Transactional() // MUST be below: it does the wrapping
static async createOrder(req: Parse.Cloud.FunctionRequest) {
const order = Order.fromParams(req.params);
order.customer = req.user!; // server decides, not the body
order.status = 'pending';
const { priced, totalCents } = await priceLines(req.params.lines);
order.totalCents = totalCents; // after fromParams, so it wins
// Every save below joins the transaction automatically. There is no
// session to thread through: AsyncLocalStorage follows the call chain,
// so even priceLines' queries ran inside it.
await order.save(null, { useMasterKey: true });
for (const line of priced) {
// Each product was READ inside this transaction, so it carries the
// version it was read at. This save asserts that version is still
// current and increments it. If the other shopper got there first,
// it fails with CONFLICT rather than overwriting them.
line.product.stock -= line.quantity;
await line.product.save(null, { useMasterKey: true });
const item = new OrderLine();
item.order = order;
item.product = line.product;
item.quantity = line.quantity;
item.unitCents = line.unitCents; // price AT PURCHASE, frozen
await item.save(null, { useMasterKey: true });
}
return { id: order.id, totalCents, status: order.status };
}
@CloudFunction captures the method as it is
applied. Put @Transactional() above it and the registry keeps the
unwrapped method: the transaction never opens, every write commits on its own, and
nothing is logged. The endpoint keeps working right up until two people use it at once.
Rule:
@CloudFunction on top, @Transactional() directly
above the method. And in your ParseServer options,
directAccess: true — without it a save() becomes an internal
HTTP request that lands in a fresh async context and writes outside the transaction,
also with no symptom.
What the loser sees
POST /api/orders/createOrder { "lines": [{ "product": "prd_desk", "quantity": 1 }] }
POST /api/orders/createOrder { "lines": [{ "product": "prd_desk", "quantity": 1 }] }
{ "id": "o_9Kd2", "totalCents": 24900, "status": "pending" }
{ "code": 5001, "error": "Someone else changed this while you were working on it." }
Not an overwrite, not a negative stock count, and not a 500. A refusal your frontend can act on.
Which the caller handles by reloading and retrying:
import { CONFLICT, CONFLICT_MESSAGE } from 'parse-server-kit';
try {
await placeOrder(lines);
} catch (error) {
if (error instanceof Parse.Error && error.code === CONFLICT) {
// CONFLICT_MESSAGE is already written for an end user, so it can go
// straight on screen. Refresh the cart and let them try again.
showBanner(CONFLICT_MESSAGE);
return refreshCart();
}
throw error;
}
The transaction body may re-run up to three times on a transient conflict before you see this, so keep it free of side effects that cannot be repeated — no emails, no charges. Do those after it commits.
7. Permissions that follow the order's status
Now the part that a where clause cannot do. The rule is:
- the customer may always read their own order;
- they may edit it only while it is
pending; - staff may always read and write;
- nobody else sees it at all.
Written once, on the row, in a beforeSave trigger so it is re-applied every
time the status changes:
// src/models/Order.ts — add to the imports at the top of the file:
// BeforeSave, implementACL, validateOrThrow
@BeforeSave()
static async onBeforeSave(req: Parse.Cloud.BeforeSaveRequest<Order>) {
const order = req.object as Order;
if (!order.status) order.status = 'pending';
// Checks the rules already declared on the model - required, min, max,
// enum - and throws a VALIDATION_ERROR listing every failure at once.
validateOrThrow(order);
const editable = order.status === 'pending';
// implementACL takes a DESCRIPTION and RETURNS an ACL. It does not take
// the object. Assign the result with setACL.
order.setACL(implementACL({
// Never public. An order is nobody else's business.
publicRead: false,
publicWrite: false,
roleRules: [
{ role: 'Admin', read: true, write: true },
{ role: 'Editor', read: true, write: true },
],
// Read always; write only while pending. Once it is paid, the customer
// can look but not touch - enforced by the database, not by remembering
// to check in each endpoint.
owner: [{ user: order.customer, read: true, write: editable }],
}));
}
GET /api/orders/getOrder?id=o_9Kd2
X-Parse-Session-Token: r:someone-else
{ "code": 101, "error": "Object not found." }
Not "forbidden" — not found. Parse does not confirm that an object exists to someone who may not read it, so the response leaks nothing about what orders there are. You get that behaviour for free, and it is the correct one.
Order CLP above denies
everything, so the only route in is a cloud function using the master key or an explicit
session token — and once inside, the ACL decides which rows that session may see. The
Permissions & ACL page works through the combinations.
8. Status transitions, gated by role
A customer must not mark their own order shipped. That is not an ACL
question — they legitimately have write access while it is pending — it is a rule about
which change is allowed, which belongs in the trigger.
// src/models/Order.ts — also needs: getUserRoles
// Which statuses may follow which. Anything not listed is refused.
private static readonly TRANSITIONS: Record<string, string[]> = {
pending: ['paid', 'cancelled'],
paid: ['shipped', 'cancelled'],
shipped: [],
cancelled: [],
};
@BeforeSave()
static async onBeforeSave(req: Parse.Cloud.BeforeSaveRequest<Order>) {
const order = req.object as Order;
// `dirty` tells a status CHANGE apart from a save that merely includes
// the current status. Without it, every save would be checked as though
// it were a transition and re-saving a shipped order would fail.
if (!order.isNew() && order.dirty('status')) {
const previous = order.previous('status') as string;
const allowed = Order.TRANSITIONS[previous] ?? [];
if (!allowed.includes(order.status)) {
throw new Parse.Error(142, `Cannot go from ${previous} to ${order.status}`);
}
// Only staff may advance an order. req.master is true when the caller
// used the master key - which your own cloud functions do, so check the
// USER's roles rather than trusting the call site.
const roles = req.user ? await getUserRoles(req.user) : [];
const staff = roles.includes('Admin') || roles.includes('Editor');
const customerCancelling =
order.status === 'cancelled' && previous === 'pending';
if (!staff && !customerCancelling) {
throw new Parse.Error(119, 'Only staff can change an order status');
}
}
... // the ACL block from step 7 goes here
}
POST /api/orders/setStatus
{ "id": "o_9Kd2", "status": "shipped" }
{ "code": 142, "error": "Cannot go from pending to shipped" }
The transition table caught it before the role check even ran — pending
cannot reach shipped at all, whoever is asking. Both rules are useful:
one is about the workflow, the other about authority.
9. Watching an order in real time
The customer's page should update itself when the order ships, without polling. Parse's LiveQuery does that, and — the part that matters — it respects the ACL you already wrote. A subscription only ever delivers rows its session token may read, so there is no second permission model to keep in step.
// Frontend
const query = new Parse.Query('Order').equalTo('objectId', orderId);
// The session token is what scopes this. Without it you would get nothing,
// because the ACL grants no public read.
const subscription = await query.subscribe(sessionToken);
subscription.on('update', order => {
setStatus(order.get('status')); // 'paid' -> 'shipped'
});
And on the server, if you want to log or restrict who may subscribe at all:
// src/models/Order.ts — also needs: BeforeSubscribe
@BeforeSubscribe()
static async onSubscribe(req: any) {
// Runs before the subscription is accepted. Throwing here refuses it.
// The ACL still applies afterwards - this is an extra gate, not the only
// one.
if (!req.user) throw new Parse.Error(101, 'Sign in to watch an order');
}
10. The whole flow, on the wire
Start to finish, this is every exchange a checkout makes:
GET /api/products/listProducts?limit=20
{ "results": [
{ "objectId": "prd_desk", "name": "Standing desk",
"sku": "DSK-1", "priceCents": 24900, "stock": 3 }
], "count": 1 }
POST /api/orders/createOrder
X-Parse-Session-Token: r:aecd1f...
{ "lines": [{ "product": "prd_desk", "quantity": 1 }],
"deliverAfter": "2026-09-01T09:00:00.000Z",
"note": "Leave at reception" }
{ "id": "o_9Kd2", "totalCents": 24900, "status": "pending" }
POST /api/orders/setStatus
X-Parse-Session-Token: r:staff-token
{ "id": "o_9Kd2", "status": "paid" }
{ "id": "o_9Kd2", "status": "paid" }
The trigger re-ran implementACL with editable: false, so the
customer's write permission is gone from the row itself. Nothing had to remember to
check.
{ "op": "update",
"object": { "objectId": "o_9Kd2", "status": "paid" } }
What you built
| Problem | What handles it | Lines you wrote |
|---|---|---|
| Decoding pointers, dates, geopoints, id arrays | Order.fromParams() | 1 |
| Forged totals | server-side pricing | a helper |
| Overselling under load | @Transactional + @ParseVersionField | 2 decorators |
| Who may read an order | implementACL in a trigger | ~10 |
| Who may change its status | a transition table | ~15 |
| Live status on the customer's page | LiveQuery, using the same ACL | 3 on the client |
| Unique SKUs, 2dsphere index, enum enforcement | @ParseField options | 0 extra |
Next, in the order they will bite you: fromParams for the full decoding rules and the security discussion, Permissions & ACL for the model behind step 7, Transactions for what joins a transaction and what never does, and Optimistic locking for the one case a version field cannot protect.