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 (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 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, and only a job seeker account can create one: the /account/api page 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.
  • 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.
  • 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. POST one JSON-RPC message to the URL; GET and DELETE answer 405. The client must send Accept: 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 a get_job or get_company answer 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/v1 draws 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.


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/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.