A REST API on Express is built from three parts: routes for each action on a resource (GET, POST, PATCH, DELETE on /api/tasks), middleware that parses JSON and logs requests, and a central error handler with four arguments. Input is checked with a Zod schema, and every error — an invalid field, a missing record, a conflict — becomes a response with the right code (400, 404, 409) in a single Problem Details format. In Express 5, errors from async handlers reach that handler automatically.
After this you will be able to
- implement five CRUD endpoints on Express 5 with
Router, async handlers and 200, 201, 204 codes - validate the body, path parameters and query string with Zod schemas and reject unknown fields
- write a central error handler that returns 400, 404, 409 and 500 in the Problem Details format
- check the API with curl and cover it with
node:test+ supertest tests
Know beforehand
Verified with
Node.js 24 LTS (24.18)Express 5.3.0Zod 4.6.6supertest 7.3.1node:test
Checked against docs: October 11, 2026
Contents
Express: the app, routes and middleware
In the Node.js lesson we wrote a server on node:http and ran into manual work: comparing paths, parsing parameters, building responses. Express is a minimalist framework on top of the same node:http that takes this over. The current major version is 5: at the time of checking, latest in the npm registry is 5.3.0, and the 4.x line is tagged latest-4. Express 5 requires Node.js 18 or newer; this lesson's code was verified on Node.js 24.18.
An entire Express app is a chain of middleware functions. Middleware receives the request req, the response res and a next function: it can respond itself or pass the request on by calling next(). A route is middleware too, just bound to a method and a path. If you pass an error to next, Express skips regular middleware and jumps to error handlers — functions with four arguments (err, req, res, next).
Create the project and install dependencies: Express, Zod for validation and supertest for tests. We run tests with the test runner built into Node.js, node --test, so no separate test framework is needed.
mkdir tasks-api
cd tasks-api
npm init -y
npm pkg set type=module
npm pkg set private=true --json
npm pkg delete main scripts.test
npm pkg set scripts.dev="node --watch src/server.js" scripts.start="node src/server.js" scripts.test="node --test"
npm install express zod
npm install -D supertest{
"name": "tasks-api",
"version": "1.0.0",
"description": "",
"scripts": {
"dev": "node --watch src/server.js",
"start": "node src/server.js",
"test": "node --test"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"private": true,
"dependencies": {
"express": "^5.3.0",
"zod": "^4.6.6"
},
"devDependencies": {
"supertest": "^7.3.1"
}
}import express from "express";
import { errorHandler, notFound } from "./errors.js";
import { tasksRouter } from "./tasks/router.js";
export function createApp({ store, logger = console }) {
const app = express();
app.disable("x-powered-by");
app.use((req, res, next) => {
const started = performance.now();
res.on("finish", () => {
const ms = (performance.now() - started).toFixed(1);
logger.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${ms}ms`);
});
next();
});
app.use(express.json({ limit: "10kb" }));
app.get("/health", (req, res) => res.json({ status: "ok" }));
app.use("/api/tasks", tasksRouter(store));
app.use(notFound);
app.use(errorHandler(logger));
return app;
}createAppreceives the store and logger as parameters instead of importing them. Tests can then pass their own — a silent logger and a broken store — without starting a serverexpress.json()parses the body only withContent-Type: application/json. The default body limit is 100 KB; 10 KB is plenty for a tasks API, and a smaller limit leaves less room for large-body attacksapp.disable("x-powered-by")removes the header that tells everyone the server runs Express- The 404 handler and the error handler are registered last: Express walks the chain in order
The resource and CRUD routes
A REST resource is an entity with its own address. Ours is tasks: the collection /api/tasks and a single task /api/tasks/:id. CRUD (Create, Read, Update, Delete) is the four actions on a resource, mapped to HTTP methods. That gives five endpoints:
GET /api/tasks
- Action
- List with a
donefilter andlimit/offsetpagination - Success
- 200
- Errors
- 400 — invalid parameters
GET /api/tasks/:id
- Action
- One task
- Success
- 200
- Errors
- 400 — id is not a number, 404
POST /api/tasks
- Action
- Create a task
- Success
- 201 +
Location - Errors
- 400, 409 — the title already exists
PATCH /api/tasks/:id
- Action
- Partial update
- Success
- 200
- Errors
- 400, 404, 409
DELETE /api/tasks/:id
- Action
- Delete
- Success
- 204 with no body
- Errors
- 400, 404
import { Router } from "express";
import { HttpError } from "../errors.js";
import { createTaskSchema, idSchema, listQuerySchema, updateTaskSchema } from "./schemas.js";
export function tasksRouter(store) {
const router = Router();
async function findTask(rawId) {
const parsed = idSchema.safeParse(rawId);
if (!parsed.success) throw new HttpError(400, "Task id must be a positive integer");
const task = await store.get(parsed.data);
if (!task) throw new HttpError(404, `Task ${parsed.data} not found`);
return task;
}
async function assertTitleIsFree(title, exceptId) {
const existing = await store.findByTitle(title);
if (existing && existing.id !== exceptId) {
throw new HttpError(409, `Task "${existing.title}" already exists (id ${existing.id})`);
}
}
router.get("/", async (req, res) => {
const query = listQuerySchema.parse(req.query);
const { items, total } = await store.list(query);
res.json({ data: items, meta: { total, limit: query.limit, offset: query.offset } });
});
router.get("/:id", async (req, res) => {
res.json(await findTask(req.params.id));
});
router.post("/", async (req, res) => {
const input = createTaskSchema.parse(req.body);
await assertTitleIsFree(input.title);
const task = await store.create(input);
res.status(201).location(`${req.baseUrl}/${task.id}`).json(task);
});
router.patch("/:id", async (req, res) => {
const task = await findTask(req.params.id);
const patch = updateTaskSchema.parse(req.body);
if (patch.title) await assertTitleIsFree(patch.title, task.id);
res.json(await store.update(task.id, patch));
});
router.delete("/:id", async (req, res) => {
const task = await findTask(req.params.id);
await store.remove(task.id);
res.status(204).end();
});
return router;
}Notice that no handler has a try/catch. In Express 5, a handler that returns a rejected promise or throws inside an async function automatically passes the error to next(err) — both the migration guide and the error handling guide say so. In Express 4 such an error was lost, and every async route had to be wrapped manually or with a separate package.
$ node wildcard.js # app.get("/api/*", ...)
…/node_modules/path-to-regexp/dist/index.js:108
throw new PathError(`Missing parameter name at index ${index}`, str);
^
PathError [TypeError]: Missing parameter name at index 6: /api/*; visit https://git.new/pathToRegexpError for info
originalPath: '/api/*'
}Validating input with a Zod schema
The server trusts nothing that arrives in a request: the body, path parameters or the query string. Checking fields by hand with typeof quickly becomes unreadable, so data is described with a schema. Zod 4 (4.6.6 at the time of checking) defines the schema in code, checks the data and returns it already shaped — trimmed and with defaults applied.
import * as z from "zod";
const title = z.string().trim().min(1, "Title is required").max(200);
const priority = z.enum(["low", "normal", "high"]);
export const createTaskSchema = z.strictObject({
title,
priority: priority.default("normal"),
done: z.boolean().default(false),
});
export const updateTaskSchema = z
.strictObject({
title: title.optional(),
priority: priority.optional(),
done: z.boolean().optional(),
})
.refine((data) => Object.keys(data).length > 0, "Provide at least one field to update");
export const listQuerySchema = z.object({
done: z.enum(["true", "false"]).transform((value) => value === "true").optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
offset: z.coerce.number().int().min(0).default(0),
});
export const idSchema = z.coerce.number().int().positive();z.strictObject
- What it does
- Rejects unknown fields
- Why
- A client cannot sneak in
idorcreatedAt— protection against mass assignment
.trim().min(1)
- What it does
- Trims whitespace, then checks length
- Why
- A title made of spaces does not pass
.default("normal")
- What it does
- Fills in a value if the field is missing
- Why
- The handler always gets a complete object
z.coerce.number()
- What it does
- Converts a string to a number
- Why
- Path and query parameters are always strings
.refine(...)
- What it does
- A custom rule for the whole object
- Why
- A PATCH with an empty body is an error, not a silent success
Schemas have two checking methods. parse returns the data or throws a ZodError — convenient in handlers: the error goes to the central handler, which turns it into a 400. safeParse does not throw but returns { success, data | error } — we use it for id to answer with our own message. The update schema is defined separately rather than via .partial() of the create schema, so defaults do not overwrite existing fields on PATCH.
Status codes and a single error format
An API client needs errors in one predictable format: the frontend then writes one parsing function instead of guessing each route's response shape. The standard for this is Problem Details, RFC 9457. The response has the application/problem+json type and the members type, title, status, detail and instance; extension members are allowed too — ours is errors with a per-field list. If type is omitted, the standard defines it as about:blank, and then title should match the status code's reason phrase.
import { STATUS_CODES } from "node:http";
import * as z from "zod";
export class HttpError extends Error {
constructor(status, detail) {
super(detail);
this.status = status;
}
}
function sendProblem(res, status, detail, extra = {}) {
res
.status(status)
.type("application/problem+json")
.json({ title: STATUS_CODES[status], status, detail, ...extra });
}
export function notFound(req, res, next) {
next(new HttpError(404, `Route ${req.method} ${req.originalUrl} not found`));
}
export function errorHandler(logger) {
return (err, req, res, next) => {
if (res.headersSent) return next(err);
if (err instanceof z.ZodError) {
const errors = err.issues.map((issue) => ({
path: issue.path.join(".") || "body",
message: issue.message,
}));
return sendProblem(res, 400, "Request validation failed", { errors });
}
if (err instanceof HttpError) return sendProblem(res, err.status, err.message);
if (err.type === "entity.parse.failed") {
return sendProblem(res, 400, "Request body is not valid JSON");
}
if (err.type === "entity.too.large") {
return sendProblem(res, 413, "Request body is too large");
}
logger.error(err);
sendProblem(res, 500, "Unexpected server error");
};
}200 OK
- When in this API
- A read or update succeeded
- Body
- A task or a list
201 Created
- When in this API
- A task was created
- Body
- The task + a
Locationheader
204 No Content
- When in this API
- A task was deleted
- Body
- Empty
400 Bad Request
- When in this API
- Broken JSON, invalid fields or parameters
- Body
- Problem Details +
errors
404 Not Found
- When in this API
- No such task or route
- Body
- Problem Details
409 Conflict
- When in this API
- The title is taken
- Body
- Problem Details
413 Payload Too Large
- When in this API
- The body exceeds the
express.jsonlimit - Body
- Problem Details
500 Internal Server Error
- When in this API
- An unexpected error
- Body
- Problem Details with no details
For invalid fields some APIs respond with 422 Unprocessable Content — as our HTTP and JSON lesson did, and as the example in RFC 9457 itself does. Both are common in practice; what matters is picking one and documenting it. The conventions in detail — error format, pagination, versioning — are covered in the API design lesson; here we apply them in minimal form.
Storage: in memory first, a database later
To focus on HTTP, tasks are kept in a Map in process memory for now. One important detail: the store methods are asynchronous even though Map is synchronous. When the store is replaced with PostgreSQL its methods will be asynchronous anyway, and the routes will not need rewriting: just pass createApp another object with the same methods.
export class TaskStore {
#tasks = new Map();
#nextId = 1;
async list({ done, limit, offset }) {
let items = [...this.#tasks.values()];
if (done !== undefined) items = items.filter((task) => task.done === done);
return { items: items.slice(offset, offset + limit), total: items.length };
}
async get(id) {
return this.#tasks.get(id) ?? null;
}
async findByTitle(title) {
const wanted = title.toLowerCase();
for (const task of this.#tasks.values()) {
if (task.title.toLowerCase() === wanted) return task;
}
return null;
}
async create(data) {
const task = { id: this.#nextId++, ...data, createdAt: new Date().toISOString() };
this.#tasks.set(task.id, task);
return task;
}
async update(id, patch) {
const task = { ...this.#tasks.get(id), ...patch };
this.#tasks.set(id, task);
return task;
}
async remove(id) {
this.#tasks.delete(id);
}
}import { createApp } from "./app.js";
import { TaskStore } from "./tasks/store.js";
const port = Number(process.env.PORT ?? 8080);
const app = createApp({ store: new TaskStore() });
app.listen(port, "127.0.0.1", (error) => {
if (error) {
console.error(`Cannot start server: ${error.code}`);
process.exit(1);
}
console.log(`Tasks API: http://127.0.0.1:${port}`);
});$ npm start # second instance, port 8080 already in use
> tasks-api@1.0.0 start
> node src/server.js
Cannot start server: EADDRINUSEAn in-memory store is fine for a lesson and tests, but not for real work: data vanishes on restart, and the title uniqueness check between find and create is not protected against two simultaneous requests. In a database such rules are enforced with a unique index, and the conflict is caught from the database error — a topic for the Databases direction.
Checking: curl and automated tests
Run npm run dev and walk through the whole scenario: create two tasks, get a page of the list, mark a task done, filter, delete. The commands are for bash (macOS, Linux, Git Bash on Windows).
$ curl -si -X POST http://127.0.0.1:8080/api/tasks -H 'Content-Type: application/json' -d '{"title": "Call the client", "priority": "high"}'
HTTP/1.1 201 Created
Location: /api/tasks/1
Content-Type: application/json; charset=utf-8
Content-Length: 104
ETag: W/"68-Oz2mnFVsGJlVZ4AF7cp/JbdJJXQ"
Date: Sun, 11 Oct 2026 23:23:02 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":1,"title":"Call the client","priority":"high","done":false,"createdAt":"2026-10-11T23:23:02.359Z"}
$ curl -s -X POST http://127.0.0.1:8080/api/tasks -H 'Content-Type: application/json' -d '{"title": "Prepare the contract"}'
{"id":2,"title":"Prepare the contract","priority":"normal","done":false,"createdAt":"2026-10-11T23:23:02.388Z"}
$ curl -s 'http://127.0.0.1:8080/api/tasks?limit=1'
{"data":[{"id":1,"title":"Call the client","priority":"high","done":false,"createdAt":"2026-10-11T23:23:02.359Z"}],"meta":{"total":2,"limit":1,"offset":0}}
$ curl -s -X PATCH http://127.0.0.1:8080/api/tasks/1 -H 'Content-Type: application/json' -d '{"done": true}'
{"id":1,"title":"Call the client","priority":"high","done":true,"createdAt":"2026-10-11T23:23:02.359Z"}
$ curl -s 'http://127.0.0.1:8080/api/tasks?done=true'
{"data":[{"id":1,"title":"Call the client","priority":"high","done":true,"createdAt":"2026-10-11T23:23:02.359Z"}],"meta":{"total":1,"limit":20,"offset":0}}
$ curl -si -X DELETE http://127.0.0.1:8080/api/tasks/2
HTTP/1.1 204 No Content
Date: Sun, 11 Oct 2026 23:23:02 GMT
Connection: keep-alive
Keep-Alive: timeout=5$ curl -si -X POST http://127.0.0.1:8080/api/tasks -H 'Content-Type: application/json' -d '{"title": " ", "priority": "urgent", "id": 7}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
Content-Length: 276
ETag: W/"114-ReqTpYGYyIiQxhc06Nl/h2R0V8Y"
Date: Sun, 11 Oct 2026 23:23:02 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"title":"Bad Request","status":400,"detail":"Request validation failed","errors":[{"path":"title","message":"Title is required"},{"path":"priority","message":"Invalid option: expected one of \"low\"|\"normal\"|\"high\""},{"path":"body","message":"Unrecognized key: \"id\""}]}
$ curl -s -X POST http://127.0.0.1:8080/api/tasks -H 'Content-Type: application/json' -d '{"title": '
{"title":"Bad Request","status":400,"detail":"Request body is not valid JSON"}
$ curl -s -X POST http://127.0.0.1:8080/api/tasks -d 'title=Call'
{"title":"Bad Request","status":400,"detail":"Request validation failed","errors":[{"path":"body","message":"Invalid input: expected object, received undefined"}]}
$ curl -si -X POST http://127.0.0.1:8080/api/tasks -H 'Content-Type: application/json' -d '{"title": "call the client"}'
HTTP/1.1 409 Conflict
Content-Type: application/problem+json; charset=utf-8
Content-Length: 91
ETag: W/"5b-JwoMGqDcMhfgj8sjRVM1/SweD8Y"
Date: Sun, 11 Oct 2026 23:23:02 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"title":"Conflict","status":409,"detail":"Task \"Call the client\" already exists (id 1)"}
$ curl -s http://127.0.0.1:8080/api/tasks/2
{"title":"Not Found","status":404,"detail":"Task 2 not found"}
$ curl -s http://127.0.0.1:8080/api/tasks/abc
{"title":"Bad Request","status":400,"detail":"Task id must be a positive integer"}
$ curl -s -X PATCH http://127.0.0.1:8080/api/tasks/1 -H 'Content-Type: application/json' -d '{}'
{"title":"Bad Request","status":400,"detail":"Request validation failed","errors":[{"path":"body","message":"Provide at least one field to update"}]}
$ curl -s 'http://127.0.0.1:8080/api/tasks?limit=500'
{"title":"Bad Request","status":400,"detail":"Request validation failed","errors":[{"path":"limit","message":"Too big: expected number to be <=100"}]}
$ curl -s http://127.0.0.1:8080/api/projects
{"title":"Not Found","status":404,"detail":"Route GET /api/projects not found"}The third request shows an Express 5 change: a body without Content-Type: application/json is not parsed, and req.body is undefined (Express 4 gave an empty object). The schema honestly reports that it expected an object. The extra id field is rejected thanks to z.strictObject.
$ curl -s -X POST http://127.0.0.1:8080/api/tasks -H "Content-Type: application/json" --data-binary @task.json
{"id":3,"title":"Позвонить клиенту","priority":"normal","done":false,"createdAt":"2026-10-11T23:23:02.780Z"}A manual check does not repeat itself. An automated test locks the behavior in: supertest takes an Express app, binds it to a free port itself and sends requests, while node:test and node:assert are built into Node.js. A fresh app with an empty store is created before each test, so tests do not depend on each other.
import assert from "node:assert/strict";
import { beforeEach, describe, it } from "node:test";
import request from "supertest";
import { createApp } from "../src/app.js";
import { TaskStore } from "../src/tasks/store.js";
const silent = { log() {}, error() {} };
let app;
beforeEach(() => {
app = createApp({ store: new TaskStore(), logger: silent });
});
describe("POST /api/tasks", () => {
it("creates a task: 201, Location and defaults", async () => {
const res = await request(app).post("/api/tasks").send({ title: " Call the client " });
assert.equal(res.status, 201);
assert.equal(res.headers.location, "/api/tasks/1");
assert.equal(res.body.title, "Call the client");
assert.equal(res.body.priority, "normal");
assert.equal(res.body.done, false);
});
it("rejects invalid fields with 400 and a problem+json body", async () => {
const res = await request(app).post("/api/tasks").send({ title: "", priority: "urgent" });
assert.equal(res.status, 400);
assert.match(res.headers["content-type"], /^application\/problem\+json/);
assert.deepEqual(res.body.errors.map((e) => e.path), ["title", "priority"]);
});
it("rejects malformed JSON with 400", async () => {
const res = await request(app)
.post("/api/tasks")
.set("Content-Type", "application/json")
.send('{"title": ');
assert.equal(res.status, 400);
assert.equal(res.body.detail, "Request body is not valid JSON");
});
it("returns 409 for a duplicate title (case-insensitive)", async () => {
await request(app).post("/api/tasks").send({ title: "Call the client" });
const res = await request(app).post("/api/tasks").send({ title: "CALL THE CLIENT" });
assert.equal(res.status, 409);
});
});
describe("GET, PATCH and DELETE /api/tasks/:id", () => {
beforeEach(async () => {
await request(app).post("/api/tasks").send({ title: "Call the client" });
await request(app).post("/api/tasks").send({ title: "Prepare the contract" });
});
it("lists tasks with pagination and a filter", async () => {
const page = await request(app).get("/api/tasks?limit=1&offset=1");
assert.equal(page.status, 200);
assert.deepEqual(page.body.meta, { total: 2, limit: 1, offset: 1 });
assert.equal(page.body.data[0].title, "Prepare the contract");
const done = await request(app).get("/api/tasks?done=true");
assert.equal(done.body.meta.total, 0);
});
it("updates a task partially", async () => {
const res = await request(app).patch("/api/tasks/1").send({ done: true });
assert.equal(res.status, 200);
assert.equal(res.body.done, true);
assert.equal(res.body.title, "Call the client");
});
it("rejects an empty update and a taken title", async () => {
const empty = await request(app).patch("/api/tasks/1").send({});
assert.equal(empty.status, 400);
const taken = await request(app).patch("/api/tasks/1").send({ title: "Prepare the contract" });
assert.equal(taken.status, 409);
});
it("deletes a task: 204, then 404", async () => {
const first = await request(app).delete("/api/tasks/2");
assert.equal(first.status, 204);
const second = await request(app).delete("/api/tasks/2");
assert.equal(second.status, 404);
});
it("returns 400 for a non-numeric id and 404 for an unknown route", async () => {
assert.equal((await request(app).get("/api/tasks/abc")).status, 400);
assert.equal((await request(app).get("/api/projects")).status, 404);
});
});
describe("unexpected errors", () => {
it("answers 500 without leaking details and logs the error", async () => {
const logged = [];
const brokenStore = {
async list() {
throw new Error("connection to database lost");
},
};
const broken = createApp({ store: brokenStore, logger: { log() {}, error: (e) => logged.push(e) } });
const res = await request(broken).get("/api/tasks");
assert.equal(res.status, 500);
assert.equal(res.body.detail, "Unexpected server error");
assert.doesNotMatch(JSON.stringify(res.body), /database/);
assert.equal(logged[0].message, "connection to database lost");
});
});$ npm test
> tasks-api@1.0.0 test
> node --test
▶ POST /api/tasks
✔ creates a task: 201, Location and defaults (34.175ms)
✔ rejects invalid fields with 400 and a problem+json body (9.5175ms)
✔ rejects malformed JSON with 400 (4.2543ms)
✔ returns 409 for a duplicate title (case-insensitive) (6.3681ms)
✔ POST /api/tasks (55.2454ms)
▶ GET, PATCH and DELETE /api/tasks/:id
✔ lists tasks with pagination and a filter (11.4126ms)
✔ updates a task partially (7.5939ms)
✔ rejects an empty update and a taken title (8.3594ms)
✔ deletes a task: 204, then 404 (7.6509ms)
✔ returns 400 for a non-numeric id and 404 for an unknown route (7.2247ms)
✔ GET, PATCH and DELETE /api/tasks/:id (42.6496ms)
▶ unexpected errors
✔ answers 500 without leaking details and logs the error (2.7755ms)
✔ unexpected errors (2.8955ms)
ℹ tests 10
ℹ suites 3
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 371.4657Common mistakes, limitations and exercises
req.body is undefined
- Cause
express.json()is not registered, or the client did not sendContent-Type: application/json- Fix
- Register the parser before the routes; validate the body with a schema
Missing parameter name error at startup
- Cause
- An Express 4 path pattern:
/*,:param? - Fix
/*splat,{/:param}— path-to-regexp v8 syntax
An HTML response with a stack instead of JSON
- Cause
- Your error handler did not run: it has three arguments or is registered before the routes
- Fix
- Four arguments
(err, req, res, next), registered last
res.json(obj, 201) responds 200, not 201
- Cause
- The Express 4 signature is not supported: the second argument is silently ignored
- Fix
res.status(201).json(obj)
A request hangs with no response
- Cause
- Middleware neither called
next()nor sent a response - Fix
- Every middleware branch must end with a response or
next()
An error in a setTimeout callback or an old-style API crashes the process
- Cause
- Express only catches synchronous errors and rejected handler promises
- Fix
- Catch it inside the callback and pass it to
next(err)
This API is for learning. Before production it lacks authentication and permission checks, rate limiting, a real database with a unique index, security headers and structured logs. The Express security best practices guide recommends, among other things, keeping dependencies up to date and not revealing your technology stack. If a project needs a strict module structure and dependency injection out of the box, look at NestJS — by default it runs on top of the same Express.
How this was verified6 · passed1 · not runchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.