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.

How to read the examples Every endpoint is shown as an exchange — what the frontend puts on the wire, what your handler does with it, and what comes back. The JSON is real, not illustrative: it is what these handlers actually send and return.

What you are building

A storefront back end with four jobs, in order of how badly they go wrong when done naively:

JobThe naive versionWhat 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;
}
Check the boot log @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.

Frontend sends
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.

Your handler
// 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.
    ...
  }
}
The customer field is the one to think about The request above sends 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:

InputResultWhy
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 };
}
Frontend tries to help itself
{
  "lines": [{ "product": "prd_desk", "quantity": 1 }],
  "totalCents": 1
}
What is stored
{ "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:

  @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 };
  }
Decorator order is load-bearing and fails silently Decorators apply bottom-up, and @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

Two requests, same instant
POST /api/orders/createOrder    { "lines": [{ "product": "prd_desk", "quantity": 1 }] }
POST /api/orders/createOrder    { "lines": [{ "product": "prd_desk", "quantity": 1 }] }
First one
{ "id": "o_9Kd2", "totalCents": 24900, "status": "pending" }
Second one
{ "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:

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 }],
    }));
  }
A different signed-in user asks for it
GET /api/orders/getOrder?id=o_9Kd2
X-Parse-Session-Token: r:someone-else
Response
{ "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.

Two gates, both must open CLP is the class gate; the ACL is the row gate. The 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
  }
The customer tries it on
POST /api/orders/setStatus
{ "id": "o_9Kd2", "status": "shipped" }
Response
{ "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:

1 · Browse
GET /api/products/listProducts?limit=20
Public, no token needed
{ "results": [
    { "objectId": "prd_desk", "name": "Standing desk",
      "sku": "DSK-1", "priceCents": 24900, "stock": 3 }
  ], "count": 1 }
2 · Place the order
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" }
Priced and stocked, in one transaction
{ "id": "o_9Kd2", "totalCents": 24900, "status": "pending" }
3 · Staff marks it paid
POST /api/orders/setStatus
X-Parse-Session-Token: r:staff-token

{ "id": "o_9Kd2", "status": "paid" }
Customer loses write access as a side effect
{ "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.

4 · The customer's open page, via LiveQuery
{ "op": "update",
  "object": { "objectId": "o_9Kd2", "status": "paid" } }

What you built

ProblemWhat handles itLines you wrote
Decoding pointers, dates, geopoints, id arraysOrder.fromParams()1
Forged totalsserver-side pricinga helper
Overselling under load@Transactional + @ParseVersionField2 decorators
Who may read an orderimplementACL in a trigger~10
Who may change its statusa transition table~15
Live status on the customer's pageLiveQuery, using the same ACL3 on the client
Unique SKUs, 2dsphere index, enum enforcement@ParseField options0 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.