11 The capture API
Tiago Águeda edited this page 2026-09-22 14:45:33 +02:00

The API

Postulo's API is at /api/v1/, and it does exactly what the token you hand it allows. It began as the capture API — a way for something outside Postulo to hand over a posting, and nothing else — and that part is unchanged. Around it now sits the rest: reading a search, recording to it, downloading what was sent.

Two other surfaces are documented elsewhere. /healthz, /metrics and /logs answer machines about the instance rather than about a search: Health, metrics and logs. And a plugin runs inside Postulo instead of calling it: Writing a plugin.

Tokens and scopes

Settings → API tokens. Give it a name — the device or tool it is for — tick what it may do, choose when it expires, and Postulo shows you the token once. Only a hash is kept: a copy of the database is not a set of working credentials, and a lost token is replaced, not recovered.

Scope What it allows
captures Hand over a posting for review, see what a page would be read as first, and list the captures awaiting review. Nothing else.
read Read everything the owner has: applications with their timelines, listings, companies and contacts, reminders, interviews (as JSON or as .ics), CVs, letters, the list of files, insights.
write Record and change, through the same code as the forms: add listings and apply to them, record applications, change status, add timeline entries and reminders, add companies and contacts, draft letters. A reminder is the one thing that can be deleted — it is a note somebody wrote to themselves and holds no history; nothing else goes, because everything else is a record of something that happened.
documents:read Download the files themselves — uploads and the snapshots of what was sent. Separate, because files are the most sensitive thing here.

A browser extension needs captures and nothing else; a leak costs its holder the ability to fill a review queue you will then decline. An agent starts with read and is given write when you trust it. Every timeline entry written through the API is signed with the token's name — via API token laptop-agent — so you can always see what an agent did, and undo it by hand.

A token is not a sign-in. It never reaches the web interface, and two-factor authentication does not apply to it. Revoke it from the same page; revoked and expired tokens answer 401 like a token that never existed.

Using it

Send the token as a bearer token. To check one and see its scopes:

curl -H "Authorization: Bearer YOUR_TOKEN" https://postulo.example.org/api/v1/me

It answers with the token's name, its owner's address, its scopes, expires_at and last_used_at. It needs no scope of its own, so any live token may ask, and a client can tell a mistyped token from a network problem without creating anything.

Everything the API offers is described in OpenAPI at /api/v1/openapi.json — load it into any client or viewer; there is deliberately no documentation page served by Postulo, since its assets would have to come from a CDN the content security policy forbids. The refusals are described too, per call and with a Problem schema, so a generated client has a type for the 401 it is likelier to meet first than any answer: see Responses. The schema needs a credential like everything else: any live token, whatever its scopes, or a browser signed in to the instance. Reading it spends no allowance and does not count as using the token.

Lists are paginated with limit and offset and come back as {"items": [...], "count": n} — one page's rows, and the count of the whole list. Every list also takes updated_since, which is how a client catches up on a search it already holds a copy of rather than reading all of it again: see Catching up below.

Capturing a posting

Needs captures.

curl -X POST -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -d '{"url": "https://example.org/jobs/42"}' \
  https://postulo.example.org/api/v1/captures

Postulo fetches the one page — public addresses only, robots.txt honoured — reads it, and stores a capture for review. To capture a posting only a signed-in reader can see, or one behind bot protection, send the page source yourself and nothing is fetched:

  -d '{"url": "https://example.org/jobs/42", "html": "<!doctype html>..."}'

The response is 201 with the capture: title, company, location, source, status and a review_url. 422 means the page yielded nothing usable, or the address was refused, and detail says which. Nothing is created but the capture: the owner reviews it into a listing, and applies from there. GET /api/v1/captures lists the captures awaiting review, newest first, paginated like every other list.

Sending the same posting twice. If you send a capture and never see the answer, you cannot tell a lost reply from a lost request — and sending it again used to leave two captures of one posting to decline, and two notifications about it. Add an Idempotency-Key header, any string of your own invention with one posting behind it (a UUID is the obvious choice):

curl -X POST -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f1b2f0e-9c1a-4f2b-8f43-1f0a2b3c4d5e" \
  -d '{"url": "https://example.org/jobs/42"}' \
  https://postulo.example.org/api/v1/captures

The first answer given under that key is the answer you keep getting for 24 hours, exactly as it was: no page is fetched again, no second capture is made and nobody is told twice. Reusing a key for a different posting is a 422, because that is a bug in the client and answering it with the first capture would hide it; sending it again while the first is still being answered is a 409, and waiting a moment is the whole of the fix. A capture that was refused gives its key up again, so the obvious thing — retrying under the same key — is also the right thing. Without the header nothing is recorded and nothing is replayed, which is what a client that does not send one has always had.

How often you may ask Postulo to fetch. A capture that Postulo fetches — one sent without html — is counted against POSTULO_CAPTURE_RATE, 30 an hour by default, per account, which is the tightest limit the instance has because it is the one thing that makes your server dial an address somebody else chose. It is the same allowance the capture form spends, so the two share one ceiling and a second token is not a second allowance. A capture that brings its own html fetches nothing and is not counted: those answer to POSTULO_API_RATE alone, 600 an hour per token, which is why a browser extension can send forty from one results page in one gesture. POST /captures/preview fetches too, so it is counted the same way. Over the limit is a 429, with detail saying how many are allowed in how long; wait, or raise the setting.

To see what a page would be captured as first, send the same body to POST /api/v1/captures/preview. It answers 200 with the page's url, the source that read it, and data: every field of the posting as read — title, company_name, location, remote_type, employment_type, description, salary_min, salary_max, salary_currency, salary_period, posted_at, closes_at, url and source. It stores nothing and notifies nobody; it is refused exactly as a capture would be.

To send corrections with the capture, add the fields that changed as data:

  -d '{"url": "https://example.org/jobs/42", "html": "<!doctype html>...",
       "data": {"title": "Senior Research Engineer", "location": "Lyon"}}'

The page is still read, so the capture's source is still the one that read it; each field in data replaces what was read, and the result is checked exactly as a source's own output is. A field that does not exist, an empty title or a salary that is not a number is a 422, with detail locating it under data, and nothing is stored. A corrected capture is still a capture: it waits for review, and the review screen opens with the corrections in it.

To send several from one page — a results page lists forty postings and the useful gesture is look at forty, keep three — send one request per posting with the same page source and each posting's own address as url: Postulo reads the page again and picks the posting that names that address. Add batch so the arrivals are announced once rather than forty times:

  -d '{"url": "https://example.org/jobs/42", "html": "<!doctype html>...",
       "batch": {"size": 3, "position": 1}}'

size is how many the gesture sends and position this one's place in it, from 1. The first of a batch is announced — Captured 3 postings from example.org — and the others arrive without a word. A posting read off a results page is usually a stub: a title, a company and a place, with the description left to the advert's own page. It is a capture to triage, not a listing to read.

To ask whether a posting has been captured before, send its address — and its title and company, if known — to POST /api/v1/captures/known, up to a hundred at a time:

  -d '{"postings": [{"url": "https://example.org/jobs/42",
                     "title": "Research Engineer", "company": "Black Mesa"}]}'

It answers, per posting, with listings — the listings at that address, however it was spelled — captures, the captures of it still waiting for review, and similar, listings with that title at that company at another address, which is how a board that mints a fresh address on every visit hides a duplicate. Nothing is refused on the strength of it: the extension says captured before and the choice is yours. The web form and the review screen say the same.

The browser extensions

The capture API was built for a browser extension, and there is one — for Chromium-based browsers (Chrome, Edge, Brave, Vivaldi, Opera, Arc) and for Firefox and its forks, including Firefox for Android:

  • postulo-chromium holds the source, one Manifest V3 codebase built for both browsers.
  • postulo-firefox assembles the Firefox package from it and carries what addons.mozilla.org needs.

Set it up once: make a token under Settings → API tokens with the captures scope and nothing more, then paste your Postulo's address and the token into the extension's settings. Test asks Postulo who the token is; Save asks the browser for permission to reach your address. From then on, on a job posting, the button (or Alt+Shift+P) reads the page as your browser sees it — so a posting only visible to a signed-in reader, or one behind bot protection, is captured too — and shows in the popup what Postulo read from it. Correct whatever it got wrong, then Send for review: the capture arrives in the review queue with your corrections, and the popup links to its review screen. Against a Postulo older than the preview, it sends straight away instead. The page goes to your server and nowhere else: no analytics, no third-party requests, no permission to run on any site until you press the button.

Reading

Needs read. Everything is the token owner's; another person's records are 404, as they are in the web interface.

Call What comes back
GET /applications?status=&company=&since=&open_only=&quiet= Applications, newest first
GET /applications/{id} One application with its timeline, reminders, interviews and sent documents
GET /listings?state=undecided Listings; state is undecided (default), new, shortlisted, discarded, applied, closed or all
GET /listings/{id} One listing, description included
GET /companies?q= · GET /companies/{id} Companies, with their industries as a list of names and their identifiers as {scheme, value, label, url}; q matches identifier values too; the detail carries contacts and listing ids
GET /reminders?due=true · ?outstanding=true Reminders
GET /interviews?state=upcoming · GET /interviews/{id} Interviews; state is upcoming (default), scheduled, past or all; each carries a stable uid
GET /interviews/calendar.ics · GET /interviews/{id}/calendar.ics The diary, or one interview, as iCalendar text
GET /offers?application= · GET /offers/{id} What was offered, with the base pay brought to a year within its currency; an application's detail lists its own
GET /cvs · GET /cvs/{id} CVs; the detail lists what each includes
GET /letters · GET /letters/{id} Letters of every kind; the detail carries the text
GET /documents?source= Files — upload, rendered, or both when unset — with a download_url each
GET /insights The figures the Insights page shows
GET /search?q=&limit= Everything matching, grouped by kind as the search page shows it, each hit with its passage and web_url

Every list in that table — the calls with no {id} in them, apart from /insights, /search and the calendar feed — is paginated: limit and offset in, {"items": [...], "count": n} out, and updated_since for catching up. So is GET /captures.

Catching up

Needs read (or captures, for the capture queue). Every list takes updated_since, a moment, and answers with the rows that changed at or after it, oldest change first. Every row carries an updated_at, and that is the cursor: read a page, remember the updated_at of the last row on it, and ask again from there.

curl -H "Authorization: Bearer YOUR_TOKEN" \
  "https://postulo.example.org/api/v1/applications?updated_since=2026-09-15T08:00:00Z&limit=100"

The ordering is the point. A list in the order things were recorded cannot be walked with a cursor, so a list asked what has changed is ordered by when it changed instead, with ties broken by id so nothing swaps places between one page and the next. Remember to encode the moment: a +00:00 offset in a query string is read as a space unless it is escaped, so Z is the easier spelling.

One thing it cannot tell you: a deletion. A row that is gone cannot appear in a list of rows, so anything keeping a mirror still has to notice absences itself. Outbound webhooks, which would say so as it happens, are a separate feature and not built yet.

Writing

Needs write. Every write goes through the same services as the forms, so the event log stays the single truth.

Call What it does
POST /listings Add a listing: company_name, title, and any posting fields
POST /listings/{id}/apply Apply: status, channel, priority, deadline, tags
POST /listings/{id}/shortlist · /discard (reason) · /restore Decide about a listing; reason is not_for_me, pay, location, closed or other (the default), and restoring makes it new again
POST /applications Record an application in one step: the listing fields and the application fields together
POST /applications/{id}/status status, optional note — the timeline records the change
POST /applications/{id}/events kind, summary, body, optional occurred_at
POST /reminders · POST /reminders/{id}/complete Reminders
PATCH /reminders/{id} · DELETE /reminders/{id} Change what a reminder says, when it falls due or which application it is about, or remove it. Moving due_at clears the announced stamp, so it is announced again at the new time. done_at is not a field you may write: use /complete
POST /interviews Schedule: application_id, starts_at, optional ends_at, kind, location, contact_ids, notes, remind
PATCH /interviews/{id} · POST /interviews/{id}/outcome Move or change an interview; record done, cancelled or no_show
POST /companies · PATCH /companies/{id} · POST /companies/{id}/contacts Companies, matched by name as the forms do — or by a Wikidata id in identifiers, which wins over the name — and their people; industries is a list of names, unknown ones join the vocabulary; identifiers is a list of {scheme, value, label} (schemes wikidata, lei, register, linkedin, crunchbase, opencorporates, other), a malformed or borrowed one is a 422; a PATCH replaces either whole list. POST /listings and POST /applications take company_wikidata beside company_name
POST /letters Draft a cover letter

Downloading

Needs documents:read. GET /documents/{source}/{id}/download, where source is upload or rendered; the download_url on each document is exactly this.

Giving an AI agent a way in

Postulo has no AI in it and never asks you for an API key. If you want an agent to know about your job search anyway, run postulo-mcp beside the agent you already use. It speaks the Model Context Protocol on one side and this API on the other, so the agent gets no privileged path: it can do exactly what your token allows, and nothing else.

Make a token with Read everything, and point the agent at it:

{
  "mcpServers": {
    "postulo": {
      "command": "postulo-mcp",
      "env": {
        "POSTULO_URL": "https://postulo.example.org",
        "POSTULO_TOKEN": "the token you just made"
      }
    }
  }
}

Two things worth deciding deliberately. A read token shows the model your whole job search — applications and their timelines, companies and people, CVs and letters as text, reminders, interviews and figures. And writing is off unless you turn it on, with postulo-mcp --write and a token carrying write; every write an agent makes then appears on the timeline with the token's name against it, so you can see it and undo it. The one delete the API has — a reminder — is not offered as a tool.

Every call

The same calls as above, as one list, with the scope each needs. A test in Postulo's repository reads this list against the API itself, so a call that is added without a line here fails there.

Method Path Scope What it does
GET /api/v1/applications read List applications
POST /api/v1/applications write Record an application
GET /api/v1/applications/{id} read One application, with its timeline
POST /api/v1/applications/{id}/events write Add an entry to the timeline
POST /api/v1/applications/{id}/status write Move an application to another status
GET /api/v1/captures captures List captures awaiting review
POST /api/v1/captures captures Capture a posting
POST /api/v1/captures/known captures Ask whether postings have been captured before
POST /api/v1/captures/preview captures Read a posting without capturing it
GET /api/v1/companies read List companies
POST /api/v1/companies write Add a company
GET /api/v1/companies/{id} read One company, with its contacts
PATCH /api/v1/companies/{id} write Change a company
POST /api/v1/companies/{id}/contacts write Add a contact at a company
GET /api/v1/cvs read List CVs
GET /api/v1/cvs/{id} read One CV, with what it includes
GET /api/v1/documents read List files: uploads and snapshots
GET /api/v1/documents/{source}/{id}/download documents:read Download a file
GET /api/v1/insights read The figures the Insights page shows
GET /api/v1/interviews read List interviews
POST /api/v1/interviews write Schedule an interview
GET /api/v1/interviews/calendar.ics read Everything still ahead, as an iCalendar file
GET /api/v1/interviews/{id} read One interview
PATCH /api/v1/interviews/{id} write Change an interview
GET /api/v1/interviews/{id}/calendar.ics read One interview, as an iCalendar file
POST /api/v1/interviews/{id}/outcome write Record how it went: done, cancelled or no_show
GET /api/v1/letters read List cover letters
POST /api/v1/letters write Draft a cover letter
GET /api/v1/letters/{id} read One letter, with its text
GET /api/v1/listings read List listings
POST /api/v1/listings write Add a listing
GET /api/v1/listings/{id} read One listing
POST /api/v1/listings/{id}/apply write Apply: turn a listing into an application
POST /api/v1/listings/{id}/discard write Discard
POST /api/v1/listings/{id}/restore write Restore
POST /api/v1/listings/{id}/shortlist write Shortlist
GET /api/v1/me any live token Check a token
GET /api/v1/offers read List offers
GET /api/v1/offers/{id} read One offer
GET /api/v1/reminders read List reminders
POST /api/v1/reminders write Add a reminder
PATCH /api/v1/reminders/{id} write Change a reminder
DELETE /api/v1/reminders/{id} write Delete a reminder
POST /api/v1/reminders/{id}/complete write Mark a reminder done
GET /api/v1/search read Search everything the owner has

Responses

401 is a missing, mistyped, revoked or expired token — nothing more is said, because confirming a token exists is itself information. 403 is a live token without the scope the call needs, and detail names the scope. 404 is a record that is not yours, or does not exist; the two are deliberately indistinguishable. 409 is an Idempotency-Key that another request is still using; wait a moment and send it again. 422 is a value that will not do, with detail saying why. 429 is an allowance spent — either the token's own (POSTULO_API_RATE) or, on a capture Postulo has to fetch, the account's (POSTULO_CAPTURE_RATE) — and detail says how many are allowed in how long.

What a refusal looks like

Every one of them is a problem document, as RFC 9457 describes: Content-Type: application/problem+json, and five members.

{
  "type": "https://source.tiagoagueda.com/postulo/postulo/wiki/The-capture-API#insufficient-scope",
  "title": "This token does not carry the scope this call needs",
  "status": 403,
  "detail": "This token does not have the 'read' scope.",
  "instance": "/api/v1/applications",
  "scope": "read"
}
Member What it is
type A URI naming the kind of refusal. This is the one to branch on. about:blank where the status code already says everything
title A short label for the type, the same every time. Not translated, so a client may compare it
status The status code again, for a client that has the body and not the response
detail What is wrong with this request. Translated into the account's language, because a person debugging reads it. Never branch on it
instance The address that refused. Not a unique identifier for this occurrence — Postulo does not mint one

A type may carry members of its own, as scope does above. Ignore any you do not know: more may be added, and that is not a breaking change.

The types with a name

Everything else is about:blank, where the status code is the whole story.

type fragment Status Carries What to do
#validation-failed 422 errors Read errors — one entry per field, each with loc and msg. detail names the fields in one line
#rate-limited 429 retry_after Wait that many seconds. The Retry-After header says the same
#insufficient-scope 403 scope Ask for a token carrying that scope; this one will never work
#idempotency-key-in-use 409 An identical request is still being answered. Wait a moment and send the same key again
#idempotency-key-reused 422 The key was used for a different request. That is a bug in the client: a key belongs to one posting

Changed in 0.4.0. Refusals used to be {"detail": "…"} under application/json. A client that reads detail and shows it is unaffected — detail is an RFC 9457 member and still a sentence. Two things did change: the media type is application/problem+json (match the +json suffix rather than the whole string), and a validation failure's detail is now a sentence instead of the list of field errors — the list moved to errors, unchanged in shape. A client that iterated detail must read errors instead.