Remotli / API

Remotli API

Read the job listings by script. The public search needs no key; the account endpoints act as the person whose key they carry. The terms of use for the public feed are on this page.

Last updated: 18 September 2026 · source on GitHub

Two surfaces, one codebase:

  • Public job search. No key, no account. GET /api/jobs, also served at GET /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 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 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 401 with WWW-Authenticate: Bearer and 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.
  • 200 with "created": false when the row already existed. Idempotent, so a retry is safe and never resets a status the person has already moved on from.
  • 400 invalid_json / invalid_job_id, 404 not_found for an unknown job, 403 forbidden for 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).


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 cite https://remotli.ch.
  • Rate. 60 requests a minute per IP address, enforced with 429. Stay under one request a second on average and read with limit=50.
  • Inventory. ?remote=all returns 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.