Two surfaces, one codebase:
- Public job search. No key, no account.
GET /api/jobs, also served atGET /api/v1/jobs(a re-export of the same handler, not a second one). - Authenticated account endpoints.
Authorization: Bearer rk_…, under/api/v1/me. A key is created on/account/api(linked from the foot of the account Overview; not a tab since #227) and acts as the person who created it.
Base URL: https://remotli.ch. Everything returns JSON. This file is rendered
at https://remotli.ch/docs/api; the terms of use for the public feed are at
the end.
Shipped with the accounts slice (B4 S3, 2026-09-15). The MCP server at the end
of this file is built and ships dark until MCP_ENABLED=true.
Authentication
curl -H "Authorization: Bearer rk_..." https://remotli.ch/api/v1/me
- A key is 32 random bytes, base64url, prefixed
rk_. Only its sha256 is stored, so it is shown exactly once, at creation. Lost key → create a new one. - Max 5 active keys per account. Revoking is immediate and irreversible:
the lookup filters on
revoked_at IS NULL, so a revoked key is indistinguishable from a wrong one. - A missing, malformed, unknown or revoked key gets
401withWWW-Authenticate: Bearerand body{"error":"unauthorized"}. - A key cannot create or revoke keys. That is session-only, on
/account/api, so a leaked key cannot extend its own life. - Keys are per account, and only a job seeker account can create one: the
/account/apipage and the create action both refuse an employer session. Revoking stays open to any signed-in account, so a key can always be killed by the account that holds it.
Rate limits
| Surface | Limit |
|---|---|
GET /api/jobs, GET /api/v1/jobs |
60 requests per minute per IP address. Over that: 429 with {"error":"rate_limited"}, Retry-After: 60, never cached. The complete inventory is under 20 pages at limit=50, so a full read fits in one window. Added 2026-09-18 after unattributed pagination at about one request a second; before that there was none. |
Every /api/v1/me* endpoint |
120 requests per minute per key. Over that: 429 with {"error":"rate_limited"}. |
Both limiters are an in-memory map per serverless instance
(lib/rate-limit.ts), so they are dampeners rather than quotas. The per-key one
is keyed on an authenticated id and cannot be side-stepped by rotating IP
addresses; the per-IP one can, which is why the terms below exist as well.
Caching
/api/v1/me* responses are Cache-Control: private, no-store. They are
per-account and must never land in a shared cache. The public job search keeps
its public, s-maxage=60, stale-while-revalidate=300.
GET /api/jobs · GET /api/v1/jobs
Public. The board's job search, same implementation on both paths.
| Param | Values | Notes |
|---|---|---|
page |
int ≥ 1 | Default 1 |
limit |
int 1-50 | Default 20 |
type |
full-time, part-time, contract, freelance |
|
location |
a city slug, remote, other |
Swiss city spellings are matched for you |
category |
a category slug | See lib/constants.ts |
q |
free text | Matches title and company |
remote |
fully_remote, remote_friendly, hybrid, hybrid_workation, all |
Repeatable |
benefit |
a benefit slug | Repeatable, ANDed |
salaryMin, salaryMax |
int | Against the posted range |
employer |
employer-type filter | See lib/company-helpers.ts |
sort |
recent (default), popular, most-applied, most-benefits, highest-paid |
Response: { jobs: [{ jobs: {...}, companies: {...} }], pagination: { page, limit, total, totalPages } }.
Indexers: use ?remote=all
The default view is not the inventory. With no remote param the endpoint
answers what the website shows a human: the remote-first default
(fully_remote + remote_friendly), which has been as little as 43% of
active listings. Hybrid, workation and rows with no policy at all are invisible
in that view.
?remote=all opts out of the work-mode filter entirely and returns every
active listing. Enumerating the four work modes is not equivalent: it still
misses rows whose COALESCE(company policy, job policy) is null, and
office_based is not in the vocabulary at all. Any crawler that must traverse
the complete inventory should use ?remote=all.
→ docs/code-gotchas.md § Public API.
jobs.description is normalised (HTML entities decoded) before it leaves the
API, so a consumer can strip tags once and be done. Do not re-decode.
GET /api/v1/me
The account behind the key, and its standing profile.
{
"account": { "id": 7, "kind": "seeker", "displayName": "R. K.", "createdAt": "2026-09-01T00:00:00.000Z" },
"profile": {
"headline": "Backend engineer",
"baseCanton": "ZH",
"seniority": "senior",
"targetCategories": ["engineering"],
"targetWorkModes": ["fully_remote"],
"salaryMinChf": 120000,
"weeksAbroad": 6,
"links": { "linkedin": "…", "github": "…" }
}
}
profile is null when the account has not filled one in.
Deliberately absent, and not a bug to fix: the account's email address, the
CV blob url and filename, the recruiter opt-in state, the phone number, and the
residence and right-to-work answers. A key lives in a
config file or an agent's environment; the address is the account's recovery
channel, the CV url is a storage address the owner never sees either (CVs are
private since 2026-10-06 and download through /account/profile/cv with the
session), and the opt-in is a consent
record changed in one place with its sentence stored verbatim. Adding a field
here is a privacy decision.
GET /api/v1/me/matches
The scoring /account/matches uses, from the same code (lib/matches.ts): the top limit roles by score, re-ordered by any stored fit-note verdict when the account may see notes (see note below), in plain score order otherwise. For a Pro account whose fit notes are shown, the page picks its 20 from the top 50 by score (ASSESSED_POOL) instead, so the two lists can differ.
| Param | Values |
|---|---|
limit |
int 1-50, default 20 |
{
"hasProfile": true,
"matches": [
{
"id": 1234, "slug": "senior-backend-engineer-acme", "title": "Senior Backend Engineer",
"company": "Acme", "companySlug": "acme", "companyLogo": null, "location": "Zurich, Remote",
"category": "engineering", "experienceLevel": "senior", "effectiveWorkMode": "fully_remote",
"salary": "CHF 130k-150k", "salaryMin": 130000, "salaryMax": 150000, "salaryCurrency": "CHF",
"publishedAt": "2026-09-10T00:00:00.000Z",
"score": 100,
"reasons": ["Engineering, one of your target categories", "Posted range reaches your CHF floor"],
"cautions": [],
"note": {
"verdict": "possible",
"note": "Your CV shows five years of backend work in Go. The listing also asks for Kafka, which it does not mention.",
"strengths": ["Go backend services"],
"missing": ["Kafka"]
}
}
]
}
note is the stored fit note for the row (verdict: strong, possible or
weak; note: the text; strengths: what the CV shows that the listing
asks for, a few words each, [] on a note written before 2026-10-31;
missing: requirements not found in the CV; a note written since
2026-10-27 also carries the cv, listing and ask rows), or
null when none has been written for it yet, or when the account may not see
notes: only a Pro account with the agent consent on gets them, and only while
the fit-note model is configured on the site (decision 2026-10-05). For any other key, a lapsed Pro included, every note is null,
the list is in plain score order, and stored notes are not read; they are kept
and come back if the account is Pro with the consent again. The endpoint never
writes one.
companyLogo and experienceLevel are nullable.
Pay cap (#228): a note written since 2026-10-06 has its verdict capped at
weak when the listing posts a CHF range whose top is more than 20% below the
profile's CHF floor (PAY_FLOOR_TOLERANCE), and its text says so. A CHF top
under 20,000 (MIN_ANNUAL_CHF) never caps, nor does a range in EUR or USD.
Notes written earlier keep their verdict until they are written again.
hasProfile: false means the list is empty because there is no profile, not
because nothing fit. Scoring is deterministic and documented in the header of
lib/matching.ts: category 40, work mode 25, seniority 15, salary 15,
freshness 5, and anything under 30 is dropped. A job whose category is not one
the profile named scores 0 and is never returned, whatever else it agrees on.
No model call is involved.
Two weaker signals break the tie between listings that agree on all of the
above (2026-09-17). A listing that states NO experience_level has its band
read off its title instead (head of|vp|director|chief and
lead|principal|staff → lead, senior|sr → senior,
junior|jr|intern|working student|trainee → junior, anything else → mid),
worth 12 for an exact match and 6 for one band BELOW the seeker's — a guessed
band above theirs earns a caution and no points, and a stated band still scores
the full 15/8. And each of the seeker's own words (their headline, plus the 40
most frequent words of their stored CV text) that appears in the title adds 2,
capped at 10, so the maximum is 110 rather than 100. score is a number, not a
percentage. The job description is never read.
Candidates are drawn from the same publicListingScope() the website browses,
so a match is never a role the person could not have found by hand.
GET /api/v1/me/saved
The seeker pipeline: saved → applied → interview → offer → rejected/withdrawn.
{
"saved": [
{
"id": 12, "kind": "listing", "jobId": 1234, "status": "applied",
"appliedAt": "2026-09-12", "appliedVia": "company_site", "note": null,
"slug": "senior-backend-engineer-acme", "title": "Senior Backend Engineer",
"company": "Acme", "location": "Zurich, Remote", "jobStatus": "active",
"jobType": "full-time", "applyUrl": "https://boards.example.com/acme/1",
"companyRemotePolicy": "fully_remote", "jobRemotePolicy": null,
"companyStreet": null, "companyPostalCity": null, "companyCountry": null,
"contactName": null, "contactEmail": null, "contactPhone": null,
"ravMethod": null, "pensum": null, "ravAssigned": null, "interviewed": false,
"companyPoBox": null, "rejectionReason": null
},
{
"id": 13, "kind": "manual", "jobId": null, "status": "interview",
"appliedAt": "2026-09-15", "appliedVia": "other", "note": null,
"slug": null, "title": "Data Analyst", "company": "Beta GmbH",
"location": null, "jobStatus": null, "jobType": null,
"applyUrl": "https://beta.example.ch/jobs/7",
"companyRemotePolicy": null, "jobRemotePolicy": null,
"companyStreet": "Bahnhofstrasse 1", "companyPostalCity": "8001 Zürich",
"companyCountry": null, "contactName": "Anna Muster",
"contactEmail": "anna@beta.example.ch", "contactPhone": null,
"ravMethod": "brieflich", "pensum": "full", "ravAssigned": false,
"interviewed": true, "companyPoBox": "Postfach 1234", "rejectionReason": null
}
]
}
id is the pipeline entry's own id. jobStatus is the listing's own state: a
saved role whose posting was archived stays in the pipeline and says so.
Changed 2026-09-29, and not purely additive. Rows now carry kind:
listing for a saved Remotli role, manual for an application the person made
elsewhere and typed in on /account/saved, so the pipeline is one complete
monthly record for the RAV. Four fields that were always set are now null on a
manual row: jobId, slug, jobStatus and jobType (location too). A
client that builds /jobs/${slug} must skip rows with kind = 'manual'. On a
manual row title, company and applyUrl are what the person typed. A
listing row whose posting Remotli later deletes becomes a manual row that
keeps its company, title and link, so a row can change kind between two
reads; key on id, never on jobId. Every row
also carries the RAV form's own fields, all nullable: companyStreet,
companyPostalCity, companyCountry, contactName, contactEmail,
contactPhone, ravMethod (brieflich, persoenlich, telefonisch; only
meaningful when appliedVia is other), pensum (full, part) and
ravAssigned (whether the RAV assigned the role; null when not answered). The
RAV contact fields (address, contact person, e-mail, phone) are the account
holder's own typed data about the employer, never data Remotli collected; they
are returned only to a key issued to that same account.
Added 2026-09-30, additive. Every row carries interviewed, a boolean,
never null: whether an interview took place, the Job-Room form's separate
Vorstellungsgespräch tick box. It turns true whenever the row's status is set
to interview and stays true through a later offer or rejected; only the
person's own tick on /account/saved sets it back to false. Read it instead of
inferring an interview from the current status.
Added 2026-09-30, additive. Every row also carries two nullable strings
from the same form: companyPoBox (its Postfach-Nr.) and rejectionReason
(its Absagegrund, at most 250 characters). A reason exists only on a
rejected row: any write that sets another status clears it in the same
statement, and the RAV export writes it only while the status is rejected.
Both pipeline endpoints are seeker-only. A valid key on an employer account
gets 403 forbidden, the same answer the website's cookie route gives: an
employer account has no pipeline, and the answer must not depend on which
credential asked.
POST /api/v1/me/saved
curl -X POST https://remotli.ch/api/v1/me/saved \
-H "Authorization: Bearer rk_..." \
-H "content-type: application/json" \
-d '{"jobId": 1234}'
201{"saved":{"jobId":1234,"status":"saved","created":true}}on the first save.200with"created": falsewhen the row already existed. Idempotent, so a retry is safe and never resets a status the person has already moved on from.400invalid_json/invalid_job_id,404not_foundfor an unknown job,403forbiddenfor a key on an employer account.- Listing rows only. A manual entry (an application made elsewhere) is added on
/account/saved, not through the API.
Writes go through the same helper as the website's save button
(lib/saved-jobs.ts), so an agent and a person produce the same row.
MCP server
https://remotli.ch/api/mcp: a free, read-only Model Context Protocol server
with no login (decision 2026-10-06). Built 2026-10-06 and dark until
MCP_ENABLED=true is set on Vercel: until then it answers initialize and
tools/list, and every tool call returns a tool error saying the search is
temporarily unavailable. Setup for people: https://remotli.ch/mcp.
Transport: streamable HTTP, stateless (no session id), JSON responses.
POSTone JSON-RPC message to the URL;GETandDELETEanswer 405. The client must sendAccept: application/json, text/event-stream(anything else is 406). A batch (a JSON array) is refused with 400 (MCP removed batching in protocol 2025-06-18), a body over 64 KB with 413.curl -s https://remotli.ch/api/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_jobs","arguments":{"query":"python","limit":3}}}'Limit: 300 requests a minute per IP, best-effort (per function instance); every POST counts, not only tool calls. Over it: HTTP 429 with a JSON-RPC error and
Retry-After: 60. Results are cached for 5 minutes per normalised argument set (a search with more than 3 keywords is not cached), so aget_joborget_companyanswer can be up to 5 minutes stale: a job archived in that time can still come back, and a new slug can still read as not found.Links: every Remotli URL in a response carries
utm_source=mcp. No response carries the employer's apply URL, a view or apply counter, or a highlight field: applying happens on the Remotli job page.
| Tool | Arguments | Returns |
|---|---|---|
search_jobs |
query (keywords, matched against title, company name and tags; every keyword must match; only the first 5 are used), category (a slug from list_categories), city (a SWISS_CITIES slug), work_mode (fully_remote, remote_friendly, hybrid, hybrid_workation or all; default fully_remote + remote_friendly), salary_min_chf (yearly floor; CHF rows only, compared on the top of the posted range, rows with no salary dropped), limit (clamped to 1 to 20, default 10) |
{ results, count, workModes, hint? }. Each row: title, company, companySlug, location, workMode, verifiedPolicy ({ statement, sourceUrl } or null), salary, postedDate, category, excerpt (plain text, at most 600 characters), slug, url. Newest first, on publicListingScope(), so the 365-day listing rule applies. hint on an empty result names the widenings that apply |
get_job |
slug |
One role that is active and not past expires_at, whatever its work mode or age: the search row's fields without excerpt, plus employmentType, description (plain text, at most 4,000 characters) and longOpen (older than the 365-day rule, so absent from search). Otherwise a tool error, "not found or no longer listed" |
get_company |
slug |
An active company: name, slug, url, headquarters, industry, size, remotePolicy (as recorded; null for none or office-based), verifiedPolicy (the company page's rule: only when the line exists, is not stale and names a policy the site shows), openRoles (the page's own list: active, inside the age cap, every work mode, at most 20). Otherwise a tool error, "not found" |
list_categories |
none | { categories }: every category with slug, label, openRoles (default view) and url |
An invalid argument (an unknown category, city or work mode, a malformed slug, a
non-numeric limit) is a tool error naming the field. Every answered call sends
one mcp_tool_call event (docs/analytics.md); no query text and no IP are
sent or stored.
/api/jobs is unchanged. The two share the column allowlists and the
description decoding in lib/jobs-api.ts, not the WHERE clause: /api/jobs
keeps its own scope, whose ?remote=all returns every active row including
null and office-based policies, which the MCP's publicListingScope() does not.
Pro tools (the personal layer)
Three more tools on the same URL and the same transport read the caller's own
account (decision 2026-10-28, "The MCP's personal tools are the Pro layer").
They are always in tools/list; they answer only a request whose
Authorization: Bearer rk_… header holds a live API key of a seeker account
with Remotli Pro. Read-only: no writes, no save_job, no model call, no
cache.
| Tool | Arguments | Returns |
|---|---|---|
my_matches |
limit (clamped to 1 to 50, default 20) |
Exactly the body of GET /api/v1/me/matches for that key ({ hasProfile, matches }, the same note rule, no signals; one implementation, apiMatchesBody in lib/matches-api.ts), plus on every row profileMatch (the score), cvFit (the note's verdict, or null) and url (the job page, utm_source=mcp) |
my_fit_note |
slug |
{ job: { slug, title, company, url }, note }. note is the stored fit note in the reading every surface uses (fitNoteDisplay): { verdict, lead, cv, listing, ask, strengths, missing }, a row null when the note has none (a note from before 2026-10-27 keeps its whole text in lead); strengths (since 2026-10-31) is what the CV brings that the listing asks for, [] on an older note. note: null when none is stored yet or the account may not see notes now: a result, not an error. Never a model call. An unknown slug is a tool error |
my_saved |
none | Exactly the body of GET /api/v1/me/saved ({ saved }, apiSavedBody), plus url on a listing row (null on a manual row) |
The auth rule. The route reads the bearer once per request
(resolveMcpAuth in lib/mcp/handler.ts, authenticateApiKey). It never
throws and never turns into an HTTP error: no header, a wrong or a revoked key
is "no key", so the public tools answer exactly as without one. Each personal
tool then answers, as a tool error:
- no key: "This tool needs your Remotli API key: create one under Account, API, and send it as
Authorization: Bearer rk_…."; - the key's budget spent: "Rate limited. Max 120 requests per minute per key." (the key's own budget, the one
/api/v1draws on, on top of the 300 per IP); - an employer key: "This endpoint is for seeker accounts.";
- a seeker without Pro: "Pro only. Your matches, notes and saved roles are read by your AI assistant with Remotli Pro: https://remotli.ch/account/pro";
- the key lookup failing on the server: "
<tool>failed on the server. Try again later."
Every response to a request that carried a bearer key is Cache-Control: private, no-store.
The switches. The public tools serve only with MCP_ENABLED=true, as
above. The personal tools ignore it: they serve while PRO_LIVE is true
(lib/pro-live.ts) and the key's account is Pro, so a Pro key reads its
matches while the public server is still dark (its tools then answer
"temporarily unavailable"). With PRO_LIVE false every personal tool answers
"The Remotli Pro tools are not available right now." The rule is written
once, next to isMcpEnabled in lib/mcp/constants.ts.
Clients. A client must be able to send the key as a header to a remote
server. Checked against each vendor's docs on 2026-10-28, with the setup on
/account/pro#assistant, the "Remotli MCP connector" card (lib/mcp/connect.ts): Claude on the web and in
Claude Desktop (Customize, Connectors, Add custom connector, "Request headers";
on a Team or Enterprise plan the header is shared with every member, so
personal plans only), Claude Code (claude mcp add --transport http remotli https://remotli.ch/api/mcp --header "Authorization: Bearer rk_…") and Cursor
(~/.cursor/mcp.json, url plus headers). ChatGPT's developer mode offers
OAuth or no authentication and no static header, so it reaches the public
tools only. Never put the key in the URL.
What comes next, and only on the signal. The public server's second step
is decided by the rule in docs/analytics.md § The MCP server's kill line: a
stateless match_jobs(profile_text). Writes (save_job) and OAuth are not
built. No tool ever applies on a person's behalf
(tasks/archive/accounts-plan.md § 7).
Design: docs/designs/remotli-mcp-server.md.
Terms of use for the public job search
These apply to GET /api/jobs, GET /api/v1/jobs, and to reading the
website by script. Reading /api/v1/me* with your own key is covered by your
account. Written 2026-09-18, after the first requests to use the feed
commercially; they are a courtesy notice, not a licence.
- Attribution. Where a listing from this feed is shown to anyone, name
Remotli and link the listing's own page,
https://remotli.ch/jobs/<slug>. Numbers derived from the feed citehttps://remotli.ch. - Rate. 60 requests a minute per IP address, enforced with
429. Stay under one request a second on average and read withlimit=50. - Inventory.
?remote=allreturns every active listing. Without it you get the remote-first default view, which is not the inventory (above). - No republication. Showing single listings with attribution is fine. Mirroring the feed, reselling it, or presenting it as your own job data is not.
- No outreach in Remotli's name. When you contact a company you found here, do not say you found it on Remotli and do not imply that Remotli endorses or introduced you.
- No profiling. The feed carries no personal data. Do not combine it with any to build profiles of people.
- No guarantee. The endpoint, its shape, its limits and these terms can change, or start to need a key, at any time and without notice.
robots.txt disallows /api/ for crawlers. That keeps JSON out of search
indexes; it is not a rule against reading the endpoint by script under the
terms above.
For a licensed feed, a tracked apply link, or any use beyond these terms, write to hello@remotli.ch with what you build and how many requests a day it needs. Nothing of that kind is on offer today; a request tells us whether it should be.
Where the code lives
| Path | What |
|---|---|
app/api/jobs/route.ts |
The public search. The one implementation. |
app/api/v1/jobs/route.ts |
Re-export of the above under the versioned prefix. |
app/api/v1/me/** |
The bearer endpoints. |
lib/auth/api-key.ts |
Key generation, hashing, requireApiKey (auth + rate limit). |
lib/actions/api-keys.ts |
Create and revoke, session-authenticated. |
lib/matching.ts |
The pure scorer. No database, no model. |
lib/matches.ts |
The candidate query plus ranking. |
lib/matches-api.ts |
The bodies of /api/v1/me/matches and /api/v1/me/saved, shared with the MCP's my_matches and my_saved, plus my_fit_note's. |
app/api/mcp/route.ts, lib/mcp/** |
The MCP server: the public tools and the Pro layer (resolveMcpAuth in handler.ts, the gate in server.ts, the client setups in connect.ts). |
lib/saved-jobs.ts |
The pipeline reads and writes. |