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/apiand 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 connector at the end of this file is planned, not built.
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, not per company. Employer accounts use the same
mechanism; their entry point lives under
/company(S4).
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, and the recruiter opt-in state. A key lives in a config file or an agent's environment; the address is the account's recovery channel, the CV url is unguessable by design, 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 same ranking /account/matches renders, from the same code (lib/matches.ts).
| 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", "location": "Zurich, Remote",
"category": "engineering", "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": []
}
]
}
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, "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",
"companyRemotePolicy": "fully_remote", "jobRemotePolicy": null
}
]
}
jobStatus is the listing's own state: a saved role whose posting was archived
stays in the pipeline and says so.
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.
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 connector (planned)
Not built. The point of shipping bearer auth first is that the connector is a
thin wrapper with no new backend: a stdio MCP server that maps tools onto these
endpoints and carries one REMOTLI_API_KEY.
| MCP tool | Endpoint | Auth |
|---|---|---|
search_jobs |
GET /api/v1/jobs |
none |
my_profile |
GET /api/v1/me |
bearer |
my_matches |
GET /api/v1/me/matches |
bearer |
my_saved |
GET /api/v1/me/saved |
bearer |
save_job |
POST /api/v1/me/saved |
bearer |
Deliberately not in that list: anything that applies on a person's behalf.
Applying stays a human act on the employer's own site (tasks/accounts-plan.md
§ 7); the API records the pipeline, it does not submit anything.
An employer-side post_job tool waits on the free-post pricing decision (F17).
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/saved-jobs.ts |
The pipeline reads and writes. |