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.
Postulo
Running it
- Installing Postulo
- Configuration
- Accounts and invitations
- Backups and your data
- Hardening
- Accessibility
- Health, metrics and logs
- Troubleshooting
Using it
- Getting started
- Listings
- Tracking applications
- Capturing postings
- Insights and the dashboard
- Reports
- Your career record
- CVs and portfolios
- Letters
- Files and what you sent
Building on it
Project