Skip to content

API reference

The web app, the browser extension, the tailor-cv skill and a phone’s share sheet all talk to the same HTTP API under /api. You only need this page to script against it yourself, for example from an iOS Shortcut.

Access. With LAN_AUTH_TOKEN unset (the default, localhost only) nothing is gated. Once it is set, every /api route needs the token, either in the X-Sumisura-Token header or as the access cookie a browser gets from POST /api/auth/session (for the token, or for a username and password where LAN_AUTH_USER and LAN_AUTH_PASSWORD are set). The only exceptions are the two /api/auth/* paths and the extension capture’s CORS preflight. See LAN-reachable mode.

Shapes. Request and response bodies are JSON. Their exact shapes are not repeated here: the source of truth is frontend/src/api/types.ts, which the contract fixtures under frontend/src/api/contract/fixtures/ hold to the backend (ADR-0035).

Writes. Routes that rewrite or delete a record accept an optional If-Match version token and answer 409 when the record changed under you. Without the header they write unconditionally.

The routes that call Claude can run for minutes. They carry a 10-minute deadline.

RouteWhat it does
GET /api/healthzLiveness check: {"status":"ok"}.
GET /api/auth/statusWhether this installation needs access, whether this request has it, and whether browsers sign in with a username and password (passwordLogin). Ungated.
POST /api/auth/sessionExchanges {"token"}, or {"username", "password"} where those are set, for an HttpOnly access cookie. Ungated.
DELETE /api/auth/sessionClears the access cookie (signs this browser out). Ungated.
GET /api/exportDownloads your Job Listings, Applications and Pending Captures as a zip. Master Data is not included.
GET /api/usageClaude API usage and estimated cost, per Generation and in total.
RouteWhat it does
GET /api/master-data/profileThe profile (profile.yaml). 404 until one exists.
PUT /api/master-data/profileReplaces the profile.
GET /api/master-data/entriesEvery Entry (experience and projects).
POST /api/master-data/entriesCreates an Entry.
GET /api/master-data/entries/{id...}One Entry, by its path-like id (e.g. experience/acme).
PUT /api/master-data/entries/{id...}Replaces an Entry.
DELETE /api/master-data/entries/{id...}Deletes an Entry.
GET /api/master-data/cover-letter-snippetsEvery Cover Letter Snippet.
POST /api/master-data/cover-letter-snippetsCreates a Snippet.
GET /api/master-data/cover-letter-snippets/{id...}One Snippet.
PUT /api/master-data/cover-letter-snippets/{id...}Replaces a Snippet.
DELETE /api/master-data/cover-letter-snippets/{id...}Deletes a Snippet.
GET /api/master-data/tag-lintGroups of Entry tags that look like spellings of the same thing.
POST /api/master-data/import/previewBody {"text": "<pasted CV>"}. Returns draft Entries proposed by Claude from that text, each with the bullets not found in it (notInText) and a free id; skipped counts proposals dropped as invalid. Writes nothing. Calls Claude.
POST /api/master-data/import/entriesSaves one approved draft under the id it carries. 409 if that id already exists: an import never overwrites.
GET /api/master-data/gapsWords and word pairs that recur in at least two non-archived Job Descriptions, each with the Job Listings citing it and whether any Entry’s Tags or text carry it. Uncovered first. Read-only, no Claude call.
RouteWhat it does
GET /api/job-listingsEvery Job Listing paired with its Application. Add ?archived=all to include archived ones, ?location= to filter by where the role is, or ?q= to search the title, company and Job Description (case-insensitive substring; blank is ignored; combines with every other filter).
POST /api/job-listingsSaves a Job Listing from a pasted Job Description. Calls Claude (RAL Range, Application Method).
POST /api/job-listings/from-extensionThe browser extension’s capture. Calls Claude.
OPTIONS /api/job-listings/from-extensionCORS preflight for the extension capture. Ungated.
POST /api/job-listings/capture-lookupWhether a posting is tracked, and what else you track at that company. Read-only, no Claude call.
OPTIONS /api/job-listings/capture-lookupCORS preflight for the lookup. Ungated.
GET /api/job-listings/{id}One Job Listing, with its full Job Description, and its Application.
PATCH /api/job-listings/{id}Corrects the Job Title, Company, Location or RAL Range. Honours If-Match.
DELETE /api/job-listings/{id}Deletes a Job Listing and its Application.
GET /api/job-listings/{id}/logoThe Company Logo image, when one was downloaded.
POST /api/job-listings/{id}/resolveRetries whichever of RAL Range and Application Method is still Unresolved. Calls Claude.
POST /api/job-listings/{id}/suggest-contactResearches a Contact to suggest. Writes nothing. Calls Claude.
POST /api/job-listings/{id}/check-freshnessRechecks whether the posting is still open.
POST /api/job-listings/{id}/archiveArchives the Job Listing.
POST /api/job-listings/{id}/unarchiveBrings it back from the archive.

Every route that saves a Job Listing — the manual form, the ATS browse save, the extension capture and Pending Capture completion — refuses a posting you already track. The refusal is 409 with this body:

{
"reason": "duplicate-posting",
"message": "This posting is already saved as a Job Listing.",
"existing": {
"id": "acme",
"title": "Backend Engineer",
"company": "Acme",
"savedAt": "2026-09-20T09:12:44.102Z",
"archived": false
}
}

Two URLs are the same posting when they share a Posting Key, so LinkedIn’s search-pane URL and the posting’s own /jobs/view/ page count as one, as do Indeed’s jk and vjk and a Greenhouse, Lever or Ashby posting and its apply page. A listing with no URL, or one whose URL is not an absolute http(s) link, has no Posting Key and is never refused.

An archived Job Listing still holds its Posting Key, so re-saving its posting is refused too. existing.archived says so, which is what lets a client offer to bring it back instead; the message itself is the same either way.

This is separate from duplicateWarning, the fuzzy “this looks like a role you already have” hint that still comes back on a successful save and never blocks anything.

POST /api/job-listings/capture-lookup is what the browser extension asks about the posting you have open, before you click anything. It writes nothing and makes no Claude call. It takes one of two forms.

Single — the posting you are looking at, plus what else you track at that company:

{ "url": "https://www.linkedin.com/jobs/view/4012345678/", "company": "Acme" }
{
"tracked": {
"id": "acme",
"title": "Backend Engineer",
"savedAt": "2026-09-20T09:12:44.102Z",
"status": "saved",
"archived": false,
"allowedTransitions": ["tailoring", "withdrawn"]
},
"company": {
"listings": [
{ "id": "acme-2", "title": "Platform Engineer", "savedAt": "2026-09-18T11:02:10.551Z", "status": "tailoring" }
]
}
}

tracked is null when the posting is not tracked. allowedTransitions is the Status state machine’s own answer for the current Status, so a client offers only legal moves — every move is still re-validated on the way in. company.listings excludes archived Job Listings and the tracked listing itself, and matches company names through the same normalisation the duplicate score uses, so “Acme Inc.”, “ACME, Inc” and “Acme S.p.A.” are one company.

Batch — tracked state only, for badging a results page in one request:

{ "urls": ["https://www.linkedin.com/jobs/view/4012345678/", "https://www.linkedin.com/jobs/view/4099999999/"] }
{
"results": [
{ "url": "https://www.linkedin.com/jobs/view/4012345678/", "tracked": { "id": "acme", "status": "saved" } },
{ "url": "https://www.linkedin.com/jobs/view/4099999999/", "tracked": null }
]
}

At most 200 URLs per request; more is a 400. A URL with no recognisable Posting Key is simply untracked.

Like the capture route, the lookup carries CORS headers and answers an OPTIONS preflight without a token, because a browser strips custom headers from a preflight. The POST itself is gated in LAN mode like any other /api request.

PATCH /api/job-listings/{id} takes any subset of title, company, location and ral. A field you don’t send is left alone; sending title or location as "" clears it, and an empty company is a 400, since a Job Listing without one isn’t a Job Listing.

{ "title": "Staff Backend Engineer", "location": "Remote (EU)", "ral": { "min": 55000, "max": 65000, "currency": "EUR" } }

A ral you send is recorded with "source": "manual" — the figure is yours, and you’re never shown it as though Claude had estimated it. The source is stamped by the backend whatever you send, so a client can’t claim a figure was stated by the posting, and POST /api/job-listings/{id}/resolve leaves a manual range alone exactly as it leaves a stated one: a retry can’t overwrite the most reliable figure on the record.

url and jobDescription are refused with a 400, rather than ignored. The URL is the record’s identity, which one-Job-Listing-per-posting rests on; the Job Description is what the RAL Range, the Application Method and every recorded Generation were derived from.

Like every other record-writing route, it honours an optional If-Match carrying the Job Listing’s version token and answers 409 when the record changed on disk since you read it.

Deciding about a company you already track

Section titled “Deciding about a company you already track”

POST /api/job-listings/from-extension takes an optional resolution, the client’s answer to “you already track other roles at this company”.

If a capture arrives with no resolution and the company has non-archived Job Listings, nothing is written, no Claude call is made, and the answer is 409:

{
"reason": "company-has-listings",
"message": "You already track other roles at this company.",
"company": {
"listings": [
{ "id": "acme-2", "title": "Platform Engineer", "savedAt": "2026-09-18T11:02:10.551Z", "status": "tailoring" }
]
}
}

This is a question, not a refusal — the client answers it:

resolutionWhat happens
{"kind": "save-anyway"}Saves the role alongside the existing ones. Answers the company question only: the one-Job-Listing-per-posting refusal still applies and is not skippable.
{"kind": "replace", "jobListingId": "acme-2"}Saves the new role, then archives the named one. 201 carries archivedJobListingId.
{"kind": "unarchive-existing", "jobListingId": "acme"}Brings that Job Listing back from the archive and saves nothing. Answers 200 with the listing.

A replace target is checked before anything is written — it must exist, not already be archived, and belong to the company being saved. Otherwise the answer is 409 with reason: "replace-target-unavailable" and nothing is written, so a stale choice fails cleanly instead of half-applying. Replace archives, never deletes: the replaced Application’s Status history, Notes and Generations all survive, and you can unarchive it.

If the save succeeds but the archive does not, the 201 carries "archiveFailed": true rather than swallowing it — you are never left believing you consolidated something you didn’t.

This gate is on the extension route only. POST /api/job-listings and the ATS browse save keep today’s post-save duplicateWarning and never ask. That asymmetry is deliberate and temporary; the two are expected to converge.

RouteWhat it does
GET /api/pending-capturesEvery link shared from another device that is waiting for its Job Description.
POST /api/pending-capturesSaves a shared link (url, text, title). A public Greenhouse, Lever or Ashby link is saved straight as a Job Listing.
DELETE /api/pending-captures/{id}Dismisses one.
POST /api/pending-captures/{id}/hintsSuggests Company and Job Title from a pasted Job Description (jobDescription), to pre-fill the completion form. Writes nothing. Calls Claude.
POST /api/pending-captures/{id}/completeTurns it into a Job Listing from a confirmed Company and pasted Job Description. Calls Claude.
RouteWhat it does
GET /api/applicationsEvery Application, grouped by Status. Takes the same archived parameter.
GET /api/applications/statsThe funnel and time-in-stage statistics.
GET /api/applications/export.csvDownloads one row per Application, archived ones included, as a UTF-8 CSV with a BOM for spreadsheets. Notes and contact are left out; cells a spreadsheet would read as a formula get a leading apostrophe.
GET /api/applications/follow-ups.icsDownloads an iCalendar file with one all-day event per open Application (not Rejected, Withdrawn or Offer, not archived) that has a follow-up date. Each event has a stable UID, so importing it again updates rather than duplicates.
GET /api/applications/offersThe Applications in Offer, newest offer first, with Location, RAL Range, the date of the offer and the latest Notes. Archived Job Listings are left out.
PATCH /api/applications/{id}/statusMoves the Application to another Status. Carries CORS headers, for the browser extension’s card.
OPTIONS /api/applications/{id}/statusCORS preflight for that move. Ungated.
PATCH /api/applications/{id}/methodCorrects the Application Method.
PATCH /api/applications/{id}/contactCorrects the Contact.
PATCH /api/applications/{id}/follow-upSets the follow-up day ({"followUpAt": "YYYY-MM-DD"}) or clears it (empty string). Honours If-Match.
GET /api/applications/{id}/mailtoA mailto: link drafting an email to the Contact.
POST /api/applications/{id}/generationsRecords a rendered Generation in the Application’s history. The skill uses this too.
POST /api/applications/{id}/notesAdds a Note.
PATCH /api/applications/{id}/notes/{noteId}Edits a Note.
DELETE /api/applications/{id}/notes/{noteId}Deletes a Note.
RouteWhat it does
POST /api/generationsRuns Selection, Rewrite and the Cover Letter draft for a Job Description (or Default Mode). Calls Claude.
POST /api/generations/previewSelection only, to preview which Entries would be used. Calls Claude.
POST /api/generations/renderRenders the approved text to PDF with Typst and returns the page count and ATS Reports. Pass jobDescription for term coverage.
GET /api/generationsEvery CV generated so far, and whether its files are still under output/.
GET /api/generations/{slug}/ats-reportThe Generation’s ATS Reports (CV and cover letter), from its record or from output/. 404 when none was kept.
GET /api/generations/{slug}/{file}One of a Generation’s files, e.g. cv.pdf.
DELETE /api/generations/{slug}Deletes a Generation’s files.
RouteWhat it does
GET /api/ats/{provider}/{slug}/listingsOpen postings on a public Greenhouse, Lever or Ashby board.
GET /api/ats/tracked-boardsThe boards you follow, each with a count of postings you haven’t seen.
POST /api/ats/tracked-boardsFollows a board.
DELETE /api/ats/tracked-boards/{id}Stops following a board.