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.
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 hours·The 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.
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.
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.
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.
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.
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.
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.
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
$Base = 'https://timesheetdemo.customapis.co'
$Keys = (Invoke-RestMethod "$Base/keys").data.keys
$Emp = ($Keys | Where-Object persona -eq 'employee').key
$Duty = ($Keys | Where-Object persona -eq 'duty_manager').key
function Hdr($k) { @{ Authorization = "Bearer $k" } }
function Code($k, $path, $body) {
try { Invoke-WebRequest -UseBasicParsing -Method POST "$Base$path" -Headers (Hdr $k) -ContentType 'application/json' -Body $body | % StatusCode }
catch { $_.Exception.Response.StatusCode.value__ }
}
$Tag = "run-$(Get-Random -Maximum 9999)" # stamped into note, to spot your row in the shared sandbox
$Day = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd') # UTC, to match the Z on the times below
# 1 — your own row. status MUST be submitted, or step 5 is a 422.
# Note ${Day} — the braces are required, or PowerShell looks for a variable named DayT07.
$New = @{ employeeId=81; workDate=$Day; startAt="${Day}T07:00:00Z"; endAt="${Day}T15:00:00Z"; hours=8; status='submitted'; note=$Tag } | ConvertTo-Json -Compress
$Id = (Invoke-RestMethod -Method POST "$Base/timesheets" -Headers (Hdr $Emp) -ContentType 'application/json' -Body $New).data.id
"created $Id ($Tag)"
Code $Emp "/timesheets/$Id/approve" '{"auditMessage":"looks fine to me"}' # 2 — 403
Code $Duty "/timesheets/$Id/approve" '{}' # 3 — 400
Code $Duty "/timesheets/1041/approve" '{"auditMessage":"Approving a Harbourline row."}' # 4 — 404
# 5 — 200
(Invoke-RestMethod -Method POST "$Base/timesheets/$Id/approve" -Headers (Hdr $Duty) -ContentType 'application/json' -Body '{"auditMessage":"Checked against the roster; hours match."}').data.status
Code $Duty "/timesheets/$Id/approve" '{"auditMessage":"And again."}' # 6 — 409
# 7 — your reason, in the trail. Scoped to your row: unscoped would show strangers' approvals too.
(Invoke-RestMethod "$Base/activity?entityType=timesheets&entityId=$Id" -Headers (Hdr $Duty)).data.userMessage
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
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.
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?
import requests
keys = requests.get('https://timesheetdemo.customapis.co/keys').json()['data']['keys']
key = next(k['key'] for k in keys if k['persona'] == 'duty_manager')
timesheets = requests.get(
'https://timesheetdemo.customapis.co/timesheets',
headers={'Authorization': f'Bearer {key}'},
).json()
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.