Live API Demo

TimeSheet Demo API

A multi-tenant API has to control two things at once: what each user can reach, and which tenants they can reach it in. The usual answer is a check written by hand on every route — correct until the day someone forgets one.

This demo shows the CustomAPIs tenant API framework deriving both from the shape of the data instead. The application is open source, the keys are real and published, and every request on this page shows you the exact call it makes.

Two employers — Harbourline Hospitality and Kestrel Facilities Group — and five people between them. Every request on this page runs as one of them, and the same URL answers differently depending on which. Each of them is introduced below, with what they can reach and what they are turned away from.

Every timesheet write fires a live event and lands in a field-level audit trail — both shown below.

Make an API call

Please use the workbench further down the page to make any request on any route on the API.

Test this API with your AI Agent

Paste it into Claude, ChatGPT or anything else that can make an HTTP request. It asks for the refusals specifically — an agent left alone stays politely in its lane and reports that everything worked, which proves nothing.

Explore the live demo API at https://timesheetdemo.customapis.co

Start by fetching /docs.md — it explains everything and links the rest.
Get credentials from /keys (public, no auth needed; they rotate every 2 hours).

Work through the challenges the API publishes at /challenges, in order: the reads
first, then the write sequence that ends in an approval. For each, tell me what you
sent, what came back, and — this is the interesting part — what you were REFUSED and
what the server did or didn't tell you about why.

Then give me your assessment: is the isolation real, or could a client have faked it?

Keys rotate every 2 hoursThe database rebuilds once a day

Who you can be

Five people, one endpoint, five different answers

Press a card below and its request runs as that person, right here — the answer opens over the page and closing it leaves you exactly where you were. Same URL, different result.

The tenant in the bullets below is the company someone works for — the two words mean the same thing on this page. Tenant scope is the boundary around one company, and it is why the two employers cannot see each other at all.

Alice Nguyen

employee · Harbourline Hospitality

Only the timesheets she owns.

Notably cannot

  • See a colleague's timesheet — owner scope, 404
  • Approve a timesheet — no such permission, 403
  • See cost rates — field visibility: the field is absent from the response, not null
  • See another company — tenant scope, 404
  • Write to her own company — it is read-only for her, 404

Use Alice's key to fetch a timesheet Ben Carter entered — a colleague in her own department.

She has no access to anyone's rows but her own, so she gets a 404 — with no hint the row even exists.

Sam Okafor

duty manager · Harbourline Hospitality

Everyone's timesheets across every department. He can approve too, but only in the Scratch Sandbox — the one company anyone can write to.

Notably cannot

  • See cost rates — field visibility, the field is absent
  • See invoices — no such permission, 403
  • See another company — tenant scope, 404
  • Approve a timesheet in his own company — read-only for every key, 404

Use Sam's key to fetch the same Ben Carter timesheet Alice was just refused.

A duty manager can read every timesheet in the company, so the same URL answers 200.

Tomas Ferreira

department manager · Harbourline Hospitality

Everyone's timesheets, but only inside his own department (Front of House).

Notably cannot

  • See another department's timesheet — path restriction, 404
  • See cost rates — field visibility, the field is absent
  • See another company — tenant scope, 404
  • Approve a timesheet in his own company — read-only for every key, 404

Use Tomas's key to fetch a timesheet from the Kitchen — one department over from his Front of House.

His access stops at his own department's rows, so he gets a 404 — same company, same location.

Priya Raman

payroll · Harbourline Hospitality

Everything in Harbourline, including cost rates and invoices.

Notably cannot

  • See another company — tenant scope, 404
  • Approve a timesheet — no such permission, 403

Use Priya's key to fetch a Harbourline timesheet and look for the costRate field.

Payroll is allowed to see cost rates, so her 200 carries the one field Sam's response for the same kind of row never has.

Omar Haddad

payroll · Kestrel Facilities Group

Everything in Kestrel Facilities Group, and nothing at all from Harbourline.

Notably cannot

  • See any Harbourline record, including by direct id — tenant scope, 404

Use Omar's key — he works at Kestrel, a different company — to fetch a Harbourline timesheet by its real id.

Tenant isolation means he gets a 404: a real row, a valid id, the wrong company.

What this proves

  • Tenant isolation is real. Enforced by the server, not by a filter a client could forget to apply.
  • Access follows relational paths. A timesheet has no tenant column. Its company is four joins away, down the chain in the schema below.
  • Every committed change leaves a trail. A write lands in the audit log with a field-level diff, the actor, and the reason if one was required. Nothing above this point wrote anything — the write section below makes a row you can then go and find. Refusals leave no trace at all, which is deliberate: see what this demo isn't, below.
  • The API declines to explain its own boundaries. A refusal is uninformative on purpose: 403 or 404, and nothing about why.

What this demo isn’t

  • Scaling is not what this demo sets out to show. It is one small shared instance, deliberately — how the architecture actually scales is its own subject, and it is covered on the architecture pages rather than inferred from this sandbox.
  • The keys are published, which a production API must never do. A real deployment authenticates through your own identity provider; this demo has none, so it hands out rotating keys instead — the trade that makes it usable the moment you arrive.
  • The event stream here is unfiltered — every connected client sees every event, across both companies. Events do carry permissions, and the same grants govern subscriptions; this demo just publishes to a plain broadcast socket rather than wiring one up, because all it needs to show is that an event fires once a write commits.
  • The audit trail records committed changes only. A refused read — a 403 or a 404 — leaves no row anywhere you can go and find; only writes leave a trail.

Scaling, identity and event delivery are covered on the Tenant API pages.

Check it yourself

Seven challenges — reading data from the API

Each one is a claim and the exchange that settles it. Expand for the captured request and response, then press Try it to run the same call live — the answer opens over the page, so you keep your place. These are all reads — the writes have a section of their own below, because they need a row that belongs to you.

Nothing below is a recording. Each challenge names the request and the status it should come back with; Try it sends it live against the real API, right now, and shows you what actually came back. The prediction is on the page before you press it, so it can be wrong in front of you.

1Who am I?/me reports identity, scope, and what you will be refused — before you try it.

Every key answers the same question differently. /me reports the identity, the companies in scope, the permissions held — and, unusually, what the key will be refused.

GET/meas Alice Nguyen200

The cannot array is a pre-commitment. Everything in it is attempted below.

GET/meas Tomas Ferreira200

The department manager reports a narrowed scope; every other persona reports "all departments".

Proves: The server publishes its own limits. An agent reads cannot to know what to attempt, which is how this demo generates refusals instead of role-play.

src/routes/me.ts

2Same search request, different results for each user's accessOne unchanged GET /timesheets: only her own rows for Alice, everyone’s for Sam — and costRate only for payroll.

One unchanged request — GET /timesheets — sent by three keys in the same company. The counts below are from the captured run. The Scratch Sandbox is shared and accumulates every visitor’s writes until the nightly rebuild, so your own numbers will be higher — it is the difference between the three that is the point, not the totals.

GET/timesheetsas Alice Nguyen2002 rows

Alice: only rows she owns — however many the sandbox has accumulated.

GET/timesheetsas Sam Okafor2005 rows

Sam: everyone's, across every department — and every sandbox row any visitor has left behind since the rebuild.

GET/timesheetsas Priya Raman2005 rows + costRate

Payroll: the same rows as Sam, plus costRate.

Proves: On this endpoint the whole difference is one permission: customapis_admin_timesheets is what turns "your timesheets" into everyone's. Sam holds one other that Alice does not — customapis_update_timesheets — and it only matters when he approves something. Neither the route nor the query changed, and no filtering code exists in the application.

src/routes/timesheet.ts

3Reach your colleague's rowAlice gets 404 on a colleague's timesheet; Sam, one permission later, gets 200.

Alice and Ben are in the same company and the same department. Only owner_id separates their timesheets.

GET/timesheets/1042as Alice Nguyen404

Alice has read permission. The row simply isn't hers.

GET/timesheets/1042as Sam Okafor200

The same id, one permission later.

Proves: Row privacy is the presence of an owner_id column. There is no configuration for it and no code in this repo that reads it.

src/routes/timesheet.ts

3bReach the next departmentTomas reads Front of House fine; the Kitchen, one department across, is 404.

Tomas manages Front of House. The Kitchen is a sibling department — same company, same location, one step across.

GET/timesheets/1041as Tomas Ferreira200

Front of House: fine.

GET/timesheets/1045as Tomas Ferreira404

The Kitchen: nothing. Same company, same location.

GET/timesheets/1045as Sam Okafor200

Sam, who holds no department restriction, reads it.

Proves: The restriction names a department, but timesheet has no department column — its department is two joins away, on the path to the company. The scope narrowed in the middle of that path.

src/plugins/getAppUser.ts

4Field visibilityPayroll's response has costRate; the duty manager's response on the same row doesn't.

The same record, fetched by two keys in the same company.

GET/timesheets/1041as Priya Raman200

Payroll sees costRate.

GET/timesheets/1041as Sam Okafor200

Sam does not. Compare the two bodies: the field is absent, not null.

Proves: A hidden field and an unset field are indistinguishable on the wire, so the absence itself discloses nothing.

src/routes/timesheet.ts

5Cross-companyA valid id and a real row still 404 — it belongs to the other company.

Timesheet 1041 belongs to Harbourline. Ids come from one global sequence, so it belongs to exactly one company in the whole deployment.

GET/timesheets/1041as Priya Raman200

Harbourline payroll: the record.

GET/timesheets/1041as Omar Haddad404

Kestrel payroll: nothing. Identical permissions, different company.

GET/timesheets/1043as Omar Haddad200

The key works. The row was the problem.

Proves: The id is valid and the row exists. The response admits neither.

src/routes/timesheet.ts

6Read the documentation your key can seeThe unfiltered spec has every route; each key's own copy has only what it can call.

The API documents itself, generated from the same route registry that serves the requests — so it cannot drift. What it shows you depends on who is asking.

GET/openapi.jsonas No credential200

No credential. The full surface — every route this API exposes. Note that this is deliberate: a permission-filtered spec fetched without a key returns an empty document, and a real evaluator once concluded the API had no endpoints.

GET/openapi.jsonas Alice Nguyen200

Alice's view. No invoices, no approve — she holds neither permission.

GET/openapi.jsonas Priya Raman200

Payroll gets invoices back and still no approve. Diff the three.

Proves: A consumer cannot call an endpoint it never sees. The docs are a projection of the same permissions the pipeline enforces — and ?key= is how any caller gets their own copy.

src/routes/docs.ts

The write and approve challenges

Would rather just run a shell script? It's at the bottom of this section — same seven requests, nothing needed from this page.

Every request runs live against the real API. The page shows you each one before it sends it, and prints exactly what came back — nothing here is a recording.

Step 1 makes the timesheet. The rest unlock once it exists — except step 4, which tries a different company's row on purpose, and step 6, which waits for step 5 because “approve it twice” needs a first approval to be a second one.

  1. 1Create a timesheet as Alice, an employeeexpects 201

    Writes land in the Scratch Sandbox only — the one company any key here can write to. Every step below acts on this row, which is why none of them depends on anyone else who has been here.

    "status": "submitted" is doing real work here. The column defaults to draft, and the guard only approves a submitted row — leave it out and step 5 is a 422 instead of the climax.

    POST/timesheetsas Alice Nguyen

    body sent

    {
      "employeeId": 81,
      "workDate": "2026-09-04",
      "startAt": "2026-09-04T07:00:00Z",
      "endAt": "2026-09-04T15:00:00Z",
      "hours": 8,
      "status": "submitted",
      "note": "run-8295"
    }
    the same request, in your terminal
    curl -sS \
      -H "Content-Type: application/json" \
      -d '{
      "employeeId": 81,
      "workDate": "2026-09-04",
      "startAt": "2026-09-04T07:00:00Z",
      "endAt": "2026-09-04T15:00:00Z",
      "hours": 8,
      "status": "submitted",
      "note": "run-8295"
    }' \
      -X POST \
      'https://timesheetdemo.customapis.co/timesheets'

    Proves: A write lands only where the caller's grants allow. ownerId is stamped by the server from the key, never taken from the body.

    src/routes/timesheet.ts

  2. 2Try to approve it as Alice, who wrote itexpects 403

    The only 403 in the sequence, and the only one on the page that is about a person rather than a row. Writing a timesheet is not approving one.

    POST/timesheets/{id}/approveas Alice Nguyen

    body sent

    {"auditMessage":"looks fine to me"}
    the same request, in your terminal
    curl -sS \
      -H "Content-Type: application/json" \
      -d '{"auditMessage":"looks fine to me"}' \
      -X POST \
      'https://timesheetdemo.customapis.co/timesheets/{id}/approve'
    needs the timesheet from step 1

    Proves: A permission the caller does not hold is refused before any row is loaded — the 403 is about Alice, not about the row.

    src/routes/timesheet.ts

  3. 3Try to approve as Sam, a duty manager, with no reasonexpects 400

    The approval Sam is entitled to make, minus the one thing the route requires him to say.

    POST/timesheets/{id}/approveas Sam Okafor

    body sent

    {}
    the same request, in your terminal
    curl -sS \
      -H "Content-Type: application/json" \
      -d '{}' \
      -X POST \
      'https://timesheetdemo.customapis.co/timesheets/{id}/approve'
    needs the timesheet from step 1

    Proves: The written reason is not application code. One line of route config makes it mandatory, and the request fails without it.

    src/routes/timesheet.ts

  4. 4Try to approve a Harbourline row — read-only to allexpects 404

    Timesheet 1041 is in Harbourline — Sam’s own company, and a row he can read. Harbourline and Kestrel are curated data, read-only for every key, so the write is refused.

    POST/timesheets/1041/approveas Sam Okafor

    body sent

    {"auditMessage":"Approving a Harbourline row."}
    the same request, in your terminal
    curl -sS \
      -H "Content-Type: application/json" \
      -d '{"auditMessage":"Approving a Harbourline row."}' \
      -X POST \
      'https://timesheetdemo.customapis.co/timesheets/1041/approve'

    Proves: Read scope is not write scope. Sam reads this row in challenge 4 and still cannot write it — and the refusal is a 404, which does not admit the row exists.

    src/routes/timesheet.ts

  5. 5Approve the timesheet as Samexpects 200

    The row is Alice’s, so Sam reaches it only through his owner exemption — the grant that lets him act on rows he does not own.

    POST/timesheets/{id}/approveas Sam Okafor

    body sent

    {"auditMessage":"Checked against the roster; hours match."}
    the same request, in your terminal
    curl -sS \
      -H "Content-Type: application/json" \
      -d '{"auditMessage":"Checked against the roster; hours match."}' \
      -X POST \
      'https://timesheetdemo.customapis.co/timesheets/{id}/approve'
    needs the timesheet from step 1

    Proves: The guard, the transaction, the row lock and the audit entry are one commit. Only a submitted row can be approved.

    src/routes/timesheet.ts

  6. 6Try to approve the same timesheet againexpects 409

    The 409 the route’s own description promises — on your row, so it is reproducible on demand rather than a claim.

    POST/timesheets/{id}/approveas Sam Okafor

    body sent

    {"auditMessage":"And again."}
    the same request, in your terminal
    curl -sS \
      -H "Content-Type: application/json" \
      -d '{"auditMessage":"And again."}' \
      -X POST \
      'https://timesheetdemo.customapis.co/timesheets/{id}/approve'
    needs the timesheet from step 1

    Proves: The status check runs inside the same transaction as the write, so two approvals cannot race each other.

    src/routes/timesheet.ts

  7. 7Read back the reason Sam had to giveexpects 200

    The last step reads back what the fifth one wrote. Scoped to your row on purpose: unscoped, the shared sandbox would show other people’s.

    GET/activity?entityType=timesheets&entityId={id}as Sam Okafor
    the same request, in your terminal
    curl -sS \
      'https://timesheetdemo.customapis.co/activity?entityType=timesheets&entityId={id}'
    needs the timesheet from step 1

    Proves: A committed change leaves a field-level diff, the actor, and the reason the API required — scoped to your own row.

    src/routes/activity.ts

Or run the whole thing yourself

The same seven requests as one script. It fetches its own keys, makes its own row and threads the id through — so it needs nothing from this page, and you can read it before you run it.

bash <<'SH'
BASE=https://timesheetdemo.customapis.co
eval "$(curl -sS $BASE/keys | jq -r '.data.keys[] | "export DEMO_" + (.persona|ascii_upcase) + "=" + .key')"

TAG="run-$RANDOM"   # stamped into note, so you can spot your row in the shared sandbox
DAY=$(date -u +%F)  # %F is ISO-8601 on both BSD and GNU date; the times below are Z, so use UTC

# 1 — your own row. status MUST be submitted, or step 5 is a 422.
ID=$(curl -sS -X POST $BASE/timesheets -H "Authorization: Bearer $DEMO_EMPLOYEE" -H 'Content-Type: application/json' -d "{\"employeeId\":81,\"workDate\":\"$DAY\",\"startAt\":\"${DAY}T07:00:00Z\",\"endAt\":\"${DAY}T15:00:00Z\",\"hours\":8,\"status\":\"submitted\",\"note\":\"$TAG\"}" | jq -r '.data.id')
echo "created $ID ($TAG)"

code() { curl -sS -o /dev/null -w '%{http_code}\n' "$@"; }

# 2 — 403: Alice holds no approve permission at all
code -X POST $BASE/timesheets/$ID/approve -H "Authorization: Bearer $DEMO_EMPLOYEE" -H 'Content-Type: application/json' -d '{"auditMessage":"looks fine to me"}'

# 3 — 400: no reason supplied
code -X POST $BASE/timesheets/$ID/approve -H "Authorization: Bearer $DEMO_DUTY_MANAGER" -H 'Content-Type: application/json' -d '{}'

# 4 — 404: Harbourline is read-only by permission
code -X POST $BASE/timesheets/1041/approve -H "Authorization: Bearer $DEMO_DUTY_MANAGER" -H 'Content-Type: application/json' -d '{"auditMessage":"Approving a Harbourline row."}'

# 5 — 200: his owner exemption reaches a row he does not own
curl -sS -X POST $BASE/timesheets/$ID/approve -H "Authorization: Bearer $DEMO_DUTY_MANAGER" -H 'Content-Type: application/json' -d '{"auditMessage":"Checked against the roster; hours match."}' | jq '.data.status'

# 6 — 409: approving twice
code -X POST $BASE/timesheets/$ID/approve -H "Authorization: Bearer $DEMO_DUTY_MANAGER" -H 'Content-Type: application/json' -d '{"auditMessage":"And again."}'

# 7 — your reason, in the trail. Scoped to your row: unscoped would show strangers' approvals too.
curl -sS "$BASE/activity?entityType=timesheets&entityId=$ID" -H "Authorization: Bearer $DEMO_DUTY_MANAGER" | jq '.data[].userMessage'
SH

The bash script needs jq — absent from macOS 14 and earlier and from most stock Linux images, and its absence is silent rather than loud: the keys never get exported, every request answers 401, and the id comes back null. brew install jq or apt install jq first. The PowerShell script needs nothing beyond PowerShell.

Two scripts rather than one clever command: PowerShell aliases curl to Invoke-WebRequest, so every flag would be wrong. Each command is kept on one line, because continuations differ by platform and a wrapped command breaks on paste. The bash block runs itself through bash (bash <<'SH'), so pasting it into zsh — the macOS default — treats the # lines as comments rather than running them as commands.

Why none of that can be faked in the client

There is no tenant column on a timesheet to filter on. A timesheet reaches its company four joins away — timesheet, employee, department, location, organisation — and the framework walks that path on every request. Derives is meant literally: the path is read from the foreign keys already in the schema below, so nobody writes it down, and there is no per-route filter to forget because none is ever written. Tenant isolation is derived, not remembered sets out the mechanism.

organisations
  └─ locations
       └─ departments
            ├─ employees
            │    └─ timesheets    ← no tenant_id column. Isolation is derived from this chain.
            └─ invoices

db/schema.sql — The schema the framework reads to derive tenancy and ownership.

Most routes are declared, not written

A route here can contain no handler function at all. Not a short one: none. Everything an endpoint does, including every refusal on this page, comes from its declaration and the shape of the tables. No code is necessary for most routes — and where one does need some, it hangs off the same declaration, as the approve rule further down shows.

The whole invoice resource, both routes

f.route({
  // ── Mandatory — a standard route needs only these two ──────────────────────────
  model: 'invoices',
  operation: 'search',

  // ── Optional — every line below is a CHOICE, not a requirement ─────────────────
  filters: { allow: ['departmentId', 'status'] },
});

// Mandatory config only — there is no optional half. This is the whole route.
f.route({ model: 'invoices', operation: 'get' });

operation: 'search' derives GET /invoices; operation: 'get' derives GET /invoices/:id, and that one is the whole route, on one line. Nothing in either says who may call them: Sam is refused invoices outright and Priya reads them, and neither outcome appears in this file. An invoice reaches its company down a different branch of the same schema — through its department — and the framework walks that one too.

src/routes/invoice.ts — 27 lines, and everything above is all of them but the import and the comment.

Customising a route

The timesheet routes carry lines the invoice routes do not. Alongside a description and the permissions they consult, processReturnData runs hideCostRate after the row is serialised — which is why costRate is in Priya’s response and absent, not null, from Sam’s.

// GET /timesheets
f.route({
  // ── Mandatory — a standard route needs only these two ──────────────────────────
  model: 'timesheets',
  operation: 'search',

  // ── Optional — every line below is a CHOICE, not a requirement ─────────────────
  // All fields are filterable until you declare `filters.allow`; then only these are.
  filters: { allow: ['workDate', 'status', 'employeeId'] },
  // remove costRate sorting, so as not to leak any cost rate data.
  sort: { deny: ['costRate'] },
  description: `List the timesheets in your read scope. ${COST_RATE_NOTE}`,
  // add cost rate permission here for visibility in docs
  consultedPermissions: [...TIMESHEET_PERMISSIONS],
  stageSettings: { processReturnData: { after: hideCostRate } },
});

// GET /timesheets/:id
f.route({
  // ── Mandatory — a standard route needs only these two ──────────────────────────
  model: 'timesheets',
  operation: 'get',

  // ── Optional — every line below is a CHOICE, not a requirement ─────────────────
  description: `One timesheet by id. ${COST_RATE_NOTE}`,
  consultedPermissions: [...TIMESHEET_PERMISSIONS],
  stageSettings: { processReturnData: { after: hideCostRate } },
});

Two lines are required — the model and the operation. Everything under them is a choice, and not one of those choices is a tenant filter or an ownership check: rows stay private to their owner because the table has an owner_id column, and the company comes from the path above. This excerpt — plus that column — is the entire configuration behind every persona’s answer above.

src/routes/timesheet.ts — the whole timesheet resource is under 220 lines, and more than a third of those are comment. How the derivation works is the subject of the architecture pages.

When a route needs a rule

Approving a timesheet is the one endpoint in this demo that needed one. Its route is declared the same way as the routes above, with three added choices: audit, which makes a written reason mandatory; validate.removeProperties, so an approval cannot double as an edit; and stageSettings.operation, which names the function to run inside the pipeline:

// POST /timesheets/:id/approve — a named business action.
f.route({
  // ── Mandatory ──────────────────────────────────────────────────────────────────
  model: 'timesheets',
  operation: 'update',
  // `method` + `path` join them here ONLY because this is a named action: an `update`
  // would otherwise derive `PATCH /timesheets/:id`, which is not the endpoint wanted.
  method: 'POST',
  path: '/timesheets/:id/approve',

  // ── Optional — every line below is a CHOICE, not a requirement ─────────────────
  // Describe the custom action for the docs - openapi, llm etc.
  description:
    'Approve a submitted timesheet. Needs a written reason in `auditMessage`, which lands ' +
    'in the audit trail. Approving twice is a 409; approving outside your write scope — ' +
    `the Scratch Sandbox here — is a 404. ${COST_RATE_NOTE}`,
  // One config line, and approval cannot happen without a reason — which lands in the
  // audit row as `userMessage`.
  audit: { requireUserMessage: true },
  stageSettings: {
    // Everything but `auditMessage` is stripped: an approval must not double as an edit.
    validate: {
      removeProperties: ['status', 'costRate', 'hours', 'note', 'workDate', 'startAt', 'endAt'],
    },
    // hooks for custom business logic - only approve once
    operation: { before: approveGuard, after: emitApproved },
    // hide costRate if you don't have permission
    processReturnData: { after: hideCostRate },
  },
  consultedPermissions: [...TIMESHEET_PERMISSIONS],
});

And approveGuard — the function that stageSettings.operation line plugs in — is all the code this endpoint needed:

function approveGuard(ctx: RequestContext): void {
  const current = ctx.originalRecord as { status?: string } | undefined;
  const status = current?.status;

  if (status === 'approved') {
    ctx.error('conflict', 'This timesheet has already been approved.');
  }
  if (status !== 'submitted') {
    ctx.error(
      'unprocessable_entity',
      `A timesheet can only be approved from "submitted"; this one is "${status}".`,
    );
  }
  ctx.body.status = 'approved';
}

Fifteen lines, and they say only what is specific to this business: an approved timesheet cannot be approved twice, and only a submitted one can be approved. What is not in them is everything you would otherwise have written — the transaction, the row lock, the audit entry, the tenant check, and the written reason the API refuses to proceed without. Those all came from the declaration above. The write sequence runs all of it against a row you make: no reason is a 400, a second approval is a 409.

This is what the architecture pages call a convention route — a route with no handler function — and an extended route. The tiers, and where business rules belong, are covered under your business rules, in ordinary code.

The audit trail, live — the last 25 committed changes

/activity is the audit trail, and it records committed changes, not requests. A read leaves no row; so does a refusal. Approve a timesheet — in the write sequence above or the workbench below — and the entry appears here, with the field-level diff, who did it and the reason the API required before it would proceed.

Read live from https://timesheetdemo.customapis.co/activity?entityType=timesheets&sort=-createdAt&limit=25 as the duty manager, newest first. Like everything else here it is tenant-scoped — this is Harbourline’s trail, not every visitor’s.

The trail loads once the keys arrive.

Live events

connecting…

This stream is unfiltered — every connected client sees every event, across both companies. That is a choice in this demo, not a limit of the framework: events carry permissions, and the same grants that decide what you can read decide what you can subscribe to. The demo publishes to a plain broadcast socket instead, so you can watch an event land the moment a write commits. A real subscriber authenticates, and the transport filters per subscription.

Connecting…

The workbench

Change any part of the request, including who is asking

Method, URL, key and body are all yours — the request goes out exactly as you leave it, and the command that produced every answer is printed beside it to paste into your own terminal. Nothing else on the page writes into this form: the buttons above run on their own, so what is here is only ever what you put here. Pick a route or type a path of your own, change who is asking, and press Send request.

Response

Nothing sent yet. Press Send request.

Read the whole thing

The source, and the endpoints you can fetch yourself

Source files behind the demo
Source codeThe entire application — routes, plugins and server come to a bit over a thousand lines, small enough to read in one sitting.
db/schema.sqlThe schema the framework reads to derive tenancy and ownership.
Endpoints you can fetch yourself
/openapi.json Fetch it with no key, then again with a payroll key, and diff the two — what disappears is what that key cannot call.
/docs.md The same surface, human-readable markdown.
/llms.txt The agent-readable surface, in one fetch.
/activity Committed changes, with field-level diffs and the written reason each required. The one entry in this list that needs a key — clicked bare it answers 401, so send one from /keys as a bearer token.
/keys The current credentials — public, no signup.

Download the event monitor A single HTML file. Open it from your desktop and it fetches /keys itself, so it keeps working through every rotation — downloaded once, it works indefinitely.

Call the API from your own code

Explore the live demo API at https://timesheetdemo.customapis.co

Start by fetching /docs.md — it explains everything and links the rest.
Get credentials from /keys (public, no auth needed; they rotate every 2 hours).

Work through the challenges the API publishes at /challenges, in order: the reads
first, then the write sequence that ends in an approval. For each, tell me what you
sent, what came back, and — this is the interesting part — what you were REFUSED and
what the server did or didn't tell you about why.

Then give me your assessment: is the isolation real, or could a client have faked it?

Talk to us about your API

The application you just drove is open source and yours to read. The framework underneath it is licensed and delivered as part of a build, deployed into your own AWS account. The useful evaluation is this same exercise against your tenancy structure, not ours.

  • Writes are real until 04:00 UTC, when the database rebuilds. They land in a shared scratch company, so you will see other visitors’ rows there too.
  • All data is synthetic. The keys are public on purpose and safe to share.