Start here

Tutorial: build a real API

A small task-tracking API, end to end: models, relationships, permissions that scope data per user, search, and a scheduled job. About twenty minutes.

Every other page here is reference. This one is a narrative — read it top to bottom and you will have a working API and a feel for how the pieces fit.

1. Start the project

npx parse-server-kit new tasks-api
cd tasks-api
npm run db:up
npm run dev

You now have a running server with a Note example, docs at /api-docs, and a seeded demo user whose session token is printed at startup. Keep that token — every write below needs it.

2. The first model

Delete the example and generate your own:

psk g resource Project

Open src/models/Project.ts and fill in the fields:

import {
  ParseClass, BaseModel, ParseField, BeforeSave, validateOrThrow,
  implementACL, catchError, cloneAcl, CloudFunction, Cron, CronSchedule,
} from 'parse-server-kit';

@ParseClass('Project', {
  description: 'A container for tasks',
  clp: {
    find:   { requiresAuthentication: true },
    get:    { requiresAuthentication: true },
    count:  { requiresAuthentication: true },
    create: { requiresAuthentication: true },
    update: { requiresAuthentication: true },
    delete: { requiresAuthentication: true },
  },
})
export default class Project extends BaseModel {
  constructor() { super('Project'); }

  @ParseField({ type: 'String', required: true, maxLength: 120 })
  declare name: string;

  @ParseField({ type: 'String', maxLength: 2000 })
  declare description: string;

  @ParseField({ type: 'String', enum: ['active', 'archived'] })
  declare status: string;

  @ParseField({ type: 'Pointer', targetClass: '_User' })
  declare owner: any;

  @BeforeSave()
  static async onBeforeSave(req: Parse.Cloud.BeforeSaveRequest<Project>) {
    const project = req.object as Project;
    if (!project.status) project.status = 'active';
    validateOrThrow(project);
  }
}

Restart. The boot output shows the schema being applied — the class, its fields, and the validator built from maxLength and enum. You wrote no migration.

3. Make projects private to their owner

The CLP above lets any signed-in user reach the class. The ACL decides which rows they see. Set it when the project is created — in src/functions/project.ts:

static async createProject(req: Parse.Cloud.FunctionRequest) {
  const user = req.user!;
  const project = Project.fromParams(req.params);

  // The caller does not get to decide these.
  project.set('owner', user);
  project.set('status', 'active');

  project.setACL(implementACL({
    owner: [{ user: user.id, read: true, write: true }],
    roleRules: [{ role: 'Admin', read: true, write: true }],
  }));

  const [err, saved] = await catchError(
    project.save(null, { sessionToken: user.getSessionToken() })
  );
  if (err) throw err;
  return saved;
}
Notice what you did not write listProjects has no equalTo('owner', user). It does not need one — pass the session token and the database returns only rows this user may read. Create two users, make a project with each, and list as one: you will see one project.

4. A second model, related to the first

psk g resource Task
@ParseField({ type: 'String', required: true })
declare title: string;

@ParseField({ type: 'Boolean' })
declare done: boolean;

@ParseField({ type: 'Date', index: true })
declare dueAt: Date;

// The relationship. targetClass is mandatory - without it,
// fromParams cannot build the pointer.
@ParseField({ type: 'Pointer', targetClass: 'Project', required: true })
declare project: any;

A client now sends the relationship as an id and fromParams converts it:

{ "title": "Write the docs", "project": { "objectId": "abc123" } }

Tasks should follow their project

A task is as private as the project it belongs to. Do that in the trigger, so it holds on every save path:

@BeforeSave()
static async onBeforeSave(req: Parse.Cloud.BeforeSaveRequest<Task>) {
  const task = req.object as Task;

  if (!task.existed()) {
    const project = await new Parse.Query(Project)
      .get(task.project.id, { useMasterKey: true });

    const acl = project.getACL();
    if (acl) task.setACL(cloneAcl(acl));   // same audience as its project
  }

  if (task.done === undefined) task.done = false;
  validateOrThrow(task);
}

5. A list endpoint that filters and paginates

@CloudFunction({
  methods: ['GET'],
  validation: { fields: {
    project: { type: String },
    done:    { type: String },
    search:  { type: String },
    page:    { type: String },
  } },
})
static async listTasks(req: Parse.Cloud.FunctionRequest) {
  const sessionToken = req.user?.getSessionToken();

  const build = () => {
    const q = new Parse.Query(Task);
    if (req.params.project) {
      q.equalTo('project', Project.pointer(req.params.project));
    }
    // GET params are strings - convert deliberately.
    if (req.params.done !== undefined) {
      q.equalTo('done', req.params.done === 'true');
    }
    if (req.params.search) {
      q.matches('title', new RegExp(req.params.search, 'i'));
    }
    return q;
  };

  const page = Math.max(Number(req.params.page) || 1, 1);
  const rows = build()
    .include('project')
    .ascending('dueAt')
    .limit(20)
    .skip((page - 1) * 20);

  const [err, results] = await catchError(rows.find({ sessionToken }));
  if (err) throw err;

  const [, total] = await catchError(build().count({ sessionToken }));
  return { results: results as Task[], page, total: total ?? 0 };
}

6. A scheduled job

Create src/cron.ts and import it from app.ts before the registries initialise:

class TaskJobs {
  @Cron({ schedule: CronSchedule.DAILY_MIDNIGHT, description: 'Flag overdue tasks' })
  static async flagOverdue() {
    const query = new Parse.Query(Task);
    query.lessThan('dueAt', new Date());
    query.equalTo('done', false);
    query.equalTo('overdue', false);
    query.limit(1000);

    const [err, tasks] = await catchError(query.find({ useMasterKey: true }));
    if (err) return console.error(err);

    for (const task of tasks as Task[]) task.set('overdue', true);
    await catchError(Parse.Object.saveAll(tasks!, { useMasterKey: true }));
  }
}

A cron job has no caller, so the master key is right here — this is the administrative case.

7. Try it

export APP=tasks-api
export TOK=<the token printed at startup>

curl -X POST localhost:1337/api/projects/createProject \
  -H "X-Parse-Application-Id: $APP" -H "X-Parse-Session-Token: $TOK" \
  -H "Content-Type: text/plain" -d '{"name":"Launch"}'

curl -X POST localhost:1337/api/tasks/createTask \
  -H "X-Parse-Application-Id: $APP" -H "X-Parse-Session-Token: $TOK" \
  -H "Content-Type: text/plain" \
  -d '{"title":"Write docs","project":{"objectId":""}}'

curl "localhost:1337/api/tasks/listTasks?done=false" \
  -H "X-Parse-Application-Id: $APP" -H "X-Parse-Session-Token: $TOK"

And /api-docs now documents all ten endpoints, with no annotations written.

What you built

Two models with a relationshipschema, indexes and validators derived from them
Ten REST endpointsrouted from method names, documented automatically
Per-row permissionsenforced by the database, with no filter in any query
Inherited permissionstasks as private as their project
A scheduled jobone decorator

Next: Permissions & ACL for the patterns behind step 3, and Queries for everything step 5 skipped.