The API¶
Cette page n'a pas encore été traduite ; elle est donc affichée en anglais.
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, list the captures awaiting review, and ask whether a posting you name is already held, which answers with its title, date and link and never its company, address or state. Nothing else. |
listings:bind |
Add an entry to a listing's history, and list your listings briefly — a title, a company, a place and a state — to choose which. Nothing else: this is the scope for a mail client that files the message you are reading. |
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, and change your details — your name, form of address, pronouns, headline and location, and which of your details a CV prints. 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. A call that changes a record answers with that record as it now stands, so a token holding write and not read still sees what it may change: your details too, by sending PATCH /profile with nothing in it. It is not shown what it did not change: PATCH /cvs/{id} answers with the CV and what it prints, and leaves out offered — the list of every number, confirmed address, link and identifier in your details — unless the token also holds read. |
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. A mail client that files messages
into listings needs listings:bind and nothing else; a leak costs its holder the ability
to add entries to histories you can read. 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:
It answers with the token's name, its owner's address, its scopes, the owner's
time_zone, 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:
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.
Telling postings apart. Every capture the API answers with — on the list, from
POST /captures and from the rendering call — carries a posting_key: an opaque name for
the posting at its address. Two captures of one posting have the same key, however the
address was spelled (http or https, with or without www. or a trailing slash, in either
case), and two postings have different keys, so a client counts the postings waiting for
review by counting distinct keys, without copying Postulo's rule for what is one address.
POST /captures/known answers with the key of each address you ask about, which is how a
client puts what it has not sent yet beside the list. The key is a hash keyed by your
instance's secret and your account: it stays the same from one request to the next, it
cannot be turned back into the address, and two accounts have different keys for one
posting. So a token holding only captures gets it too, although it is not told the
address. It changes if the instance's key does (POSTULO_FIELD_KEY, or SECRET_KEY where
that is not set). An address that names no host has an empty key and matches nothing (#711).
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; up to 200 characters, and a longer key is refused with a 422
naming the header):
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.
What the API says about caching, length and wrong tokens. Every answer carries
Cache-Control: private, max-age=0, no-store, the calendar feeds and the JSON alike, so
nothing you read is left in an HTTP cache after the token is revoked (#952). The free-text
fields you write (a timeline body, a status note, notes on a company, contact or
interview, a listing description, a letter body) take at most 40,000 characters; a longer
one is a 422 naming the field, and its maxLength is in the OpenAPI document (#953). A wrong
token is counted against the address that sent it and bars the address after sixty an hour on
every address under /api/v1/, openapi.json, an unknown path and a wrong method included
(#951). A capture sent with only the captures scope is answered, for the rendering call as
for the list, without the address, company, location or source (#950).
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.
It also answers hinted: the fields of data that were read where your own corrections
showed them on this site before, rather than from what the page states. Show them as
such. A preview keeps no score.
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.
Corrections teach exactly as the review screen does: a field corrected to a value the page shows is remembered where it was, for that site (see Capturing postings).
Keeping the page. Where the instance and the account both keep pages — off by default,
see Capturing postings — the source a capture was
read from is kept beside it: the html you sent, or the page Postulo fetched. To not keep
it for one capture, add "keep": {"source": false}. keep can only take something away; it
never switches keeping on. Every capture in an answer carries a page member:
"page": {
"source": true,
"rendering": false,
"rendering_type": "",
"accepts_rendering": true,
"rendering_url": "https://postulo.example.org/api/v1/captures/42/rendering",
"rendering_types": ["image/png", "image/jpeg", "image/webp", "application/pdf"],
"rendering_max_bytes": 10485760,
"note": ""
}
note says in words why something the account asked to keep was not — larger than the
instance keeps, or no room left for the account. The capture is made either way.
Sending a rendering. A picture of the whole page as the browser shows it is what only
the browser can make. Send it after the capture, as the body of a PUT to the capture's
rendering_url, under its own media type — not JSON, not a form:
curl -X PUT -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: image/png" \
--data-binary @page.png https://postulo.example.org/api/v1/captures/42/rendering
Needs captures. It answers 201 with the capture when the rendering was kept, and 200
with the same body when those exact bytes were already kept — a retry after a lost reply
is safe. A PNG, JPEG or WebP image or a PDF, and its first bytes must be what the type says.
Content-Length is compared with rendering_max_bytes before the body is read, so ask
accepts_rendering first and do not draw one that would be refused. A capture takes one
rendering, while it is waiting for review; a different one sent later is refused rather
than replacing it. Nothing in the API hands a kept page back.
A request larger than the API reads — about 2.5 MB, which a page's html can reach — is a
413 problem document of type #too-large, with max_bytes. It used to be an HTML 400.
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 its posting_key (see above), 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.
Keeping the page. An extension already sends the source, as html, so where your
account keeps sources a page captured through it keeps its source. Sending a rendering of
the page — a picture of the whole of it, as you were seeing it — is the extensions' next
step and not built yet; the call above is what they will use.
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. An application also carries referred_by_id, through_agency_id, and, once it has ended, end_reason, end_note and last_stage, read from its timeline; a timeline entry carries its end_reason |
GET /listings?state=undecided |
Listings; state is undecided (default), new, shortlisted, discarded, applied, closed or all |
GET /listings/{id} |
One listing, description included, and its history as events, newest first: each entry's kind, occurred_at, summary, body, contact_id, document_id or capture_id where it points at one, external_id and actor |
GET /companies?q= · GET /companies/{id} |
Companies, with their industries as a list of names, their sectors as NACE section letters (#897), their social profiles in web_links as a contact's are (#906), and their identifiers as {scheme, value, label, url} (scheme is one of Postulo's own keys or the key of a kind the instance defined for itself; one whose kind the instance has since deleted keeps its key, and one whose value its kind no longer accepts keeps its value, each with an empty url); q matches identifier values too; the detail carries contacts and listing ids; a company also carries its status (#941): legal_state (active, administration, acquired, dissolved), legal_state_since, legal_state_source, acquired_by (the id of a company you can see) and hiring_signals, its dated entries newest first, each {id, kind, noted_on, source, author, created_at} with kind one of hiring, freeze, laying_off; whether it has open listings is not stored |
GET /reminders?due=true · ?outstanding=true |
Reminders; each carries a stable uid |
GET /reminders/calendar.ics |
The reminders as iCalendar tasks, in an address of their own: the interviews feed does not carry them |
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; the diary also carries the deadlines and closing dates of the next six months |
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, its show_contact_details, and prints: which of your details it prints. For phone, email, social, repository and website: choice (default follows your details, chosen pins the row named by id, none prints none), id, printed (what the CV prints as things stand) and offered (your own rows of that kind, each with id, label and value). For identifiers: choice, ids, printed, offered. And seven booleans: location, form_of_address, pronouns, birth_date, birth_place, nationality, gender. chosen with a null id means the row has since been deleted, or is kept back and not offered at present: the CV prints none of that kind |
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 /profile |
Your details: first_name, last_name, form_of_address, pronouns and gender — each {key, label}: key is the stored concept (mrs, doctor_f, they, non_binary …, the same in every language), other for free text typed under Other, or empty when nothing is given, and label is its word in the language of your career record (the free text as typed for other) — birth_date, birth_place, birth_country, nationalities (country codes, in the order given), nationality_scope (eu, other or blank, worked out from the countries while any are listed), headline, location as you typed it, printed_location — read-only, what a CV prints: location, or when that is blank the town and country of your primary postal address, or nothing — and record_language |
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 |
A language is a BCP 47 tag in its canonical form wherever the API gives one — pt-BR,
en-GB, sr-Cyrl — in language on a CV, a letter and a document, and in
record_language on the profile. Blank means nobody has said. Postulo used to write these
in lower case; a tag says the same thing in any case, so compare without regard to it.
Every list in that table — the calls with no {id} in them, apart from /insights,
/search, /profile and the calendar feed — is paginated: limit and offset in, {"items": [...],
"count": n} out, and updated_since for catching up. So is GET /captures. limit defaults to 100
and is at most 200: a larger one is a 422, and the OpenAPI schema declares the bound. A page is
read inside one transaction, so a bound keeps one call from holding the instance's database for as long
as it likes; walk a bigger account with offset, or with updated_since and after_id.
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 with its id that is the cursor: read a page,
remember the updated_at and the id of the last row on it, and ask again with both.
curl -H "Authorization: Bearer YOUR_TOKEN" \
"https://postulo.example.org/api/v1/applications?updated_since=2026-09-15T08:00:00Z&after_id=123&limit=100"
Send after_id too: rows that share one updated_at, as after an import or a bulk edit,
would otherwise hand you the same page for ever. GET /captures takes it as well.
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 tell the receiver as things happen, exist (see Webhooks), but carry no deletion event either: a mirror still has to notice absences itself.
Writing¶
Needs write. Every write goes through the same services as the forms, so the event log
stays the single truth.
A time must carry an offset (Z or +02:00); one without is a 422 naming the field,
in a body and in updated_since. GET /me returns the owner's time_zone, to build the
offset from. A call made with a token is carried out in its owner's language and time zone,
so the reminder and timeline entry of an interview booked here name the hour as the owner
reads it. A write that is refused, as a company's or an interview's can be over its
identifiers or contacts, keeps nothing of what it had begun to save.
| Call | What it does |
|---|---|
POST /listings |
Add a listing: company_name, title, and any posting fields. description_format is plain (the default) or markdown: how description is read when the page draws it, which stores it as sent and never as markup (see Listings); a listing read back carries it |
POST /listings/{id}/apply |
Apply: status, channel, priority, deadline, tags, and referred_by_id and through_agency_id |
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, referred_by_id (one of your contacts) and through_agency_id (one of your companies) among them. Anything that is not your own is a 422, the same answer as for a record that does not exist |
POST /applications/{id}/status |
status, optional note and end_reason — the timeline records the change. end_reason is pay, location, not_a_match, filled_internally, my_choice or other, and only goes with rejected, withdrawn or ghosted (anything else is a 422); sent with the status the application already has, it adds the reason to the timeline |
POST /applications/{id}/events |
kind, summary, body, optional occurred_at |
POST /reminders · POST /reminders/{id}/complete · POST /reminders/{id}/reopen |
Make a reminder, mark it done, or mark it not done again. Marking it not done does not announce it again; moving it does. Each reminder carries actor: the token that last made, changed, moved, ticked or unticked it (API token phone), or blank when you did it yourself on the page |
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; a done reminder is not moved. done_at is not a field you may write: use /complete and /reopen |
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 · POST /companies/{id}/signals · DELETE /companies/{id}/signals/{signal_id} |
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; a company's legal_state, legal_state_since, legal_state_source and acquired_by set what it is (a state other than the four, an acquirer you cannot see, the company itself or a loop is a 422, and so is acquired_by on a company that is not acquired), and POST /companies/{id}/signals adds a dated entry — kind, noted_on (today if left out, never in the future) and source — with the caller as its author, where a read-only share is a 404 and a read/write one may; a company's web_links are its social profiles, kind social only, and a PATCH with them replaces the list; identifiers is a list of {scheme, value, label} (schemes wikidata, isni, linkedin, lei, register, crunchbase, opencorporates, other, or the key of a kind this instance defined for itself, which is checked as one of Postulo's is — though only Postulo's own take a pasted address in value; a kind of the instance's takes the value alone. A PATCH may send back an identifier exactly as it was listed, and it is kept as it is whatever has happened to its kind since: deleted, or changed so that the value no longer fits. Any other value is held to the kind as it is today), 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. A contact takes messaging_handles, a list of at most fifty {handle, service, label, is_primary}; service is a service's key (matrix, xmpp, signal, telegram, threema, or one a plugin adds), and the handle is then normalised and has to be one of that service's, or the call is a 422 saying what one looks like; other or blank takes any handle up to 255 characters, and needs a label naming the service, at most 60 characters. The first handle is the primary where none is marked. A longer list is a 422 and writes nothing; a contact is read back with messaging_handles, each with its service (blank for Other), label, handle and is_primary. The API does not read or write the handles on Your details. A contact takes web_links, a list of at most twenty {kind, url, service, label, is_primary}; a longer list is a 422 and writes nothing. kind is social, repository or website; service is a service's key (the address then has to be one of that service's, or the call is a 422 naming the address), other, or blank to have it worked out from the address's host; label is what to call the link under other, at most 60 characters once the space around it is gone. linkedin_url is still taken, as a social profile beside the list, and refuses nothing. Reads return each link's service, blank for Other. A contact's phone is checked against its country's numbering plan. Send it in the international form, starting with +, or the way it is written in its own country with that country in phone_country (an ISO 3166-1 alpha-2 code such as FR; ignored when the number starts with + or 00). A number that cannot exist there is a 422 that says why, in the words the page uses; so is a national number sent with neither a + nor phone_country, and a phone_country that is not a country. What is stored and returned is the international form (+33612345678), not what was sent; one of a country's short numbers, sent with its country, is kept as it was sent. Each entry of phone_numbers carries number as it is stored and formatted, the same number grouped the way its country writes it |
POST /letters |
Draft a cover letter |
PATCH /cvs/{id} |
Choose what a CV prints about you: show_contact_details (the master switch) and prints, an answer per kind (phone, email, social, repository, website, messaging and identifiers; messaging is the one whose default prints none) — {"choice": "default"}, {"choice": "none"}, or {"choice": "chosen", "id": …} with one of the ids GET /cvs/{id} lists under prints.<kind>.offered (ids for identifiers) — and the booleans location, form_of_address, pronouns, birth_date, birth_place, nationality, gender. A kind left out is left alone. An id that is not one of your own rows is a 422, in the same words whether it is somebody else's or does not exist, and so is an id sent beside default or none; a refusal stores nothing from the call. Nothing already sent changes. Answers with the CV as it now stands; offered is in that answer only when the token also holds read, so a token with write alone can pin a row whose id it was given and cannot list them |
PATCH /profile |
Change first_name, last_name, form_of_address, pronouns, birth_date, birth_place, birth_country, nationalities, nationality_scope, gender, headline or location; a field left out is left alone. Each is stripped of the space around it and then held to its length (150 for a name, 40 for a form of address or pronouns, 200 for the headline, 120 for the location); a NUL character is a 422 naming the field, as on the page. birth_date is 1990, 1990-03 or 1990-03-12, a real date that is not in the future and not before 1900, and birth_country is a two-letter country code from the address form's list, each a 422 naming the field when it is not; neither is in any list, webhook or log. nationalities is a list of at most ten country codes: case is folded and a repeat is dropped, an unknown code or an eleventh is a 422 naming the field, and an empty list clears it. nationality_scope is eu, other or empty and is stored only while the list is empty; listing a country clears it, since it is worked out from the countries. A form of address, pronouns or gender is sent as a concept's key (he, doctor_f, non_binary) or as any text, held to 40 characters: text that is a word of some language's list for a concept (Mme, iel) is stored as that concept, and any other text is stored as typed and read back with the key other; an empty location goes back to your primary address's town and country; a name may not be emptied. printed_location cannot be written. Answers with your details as they now stand, and moves updated_at whenever anything changed, the name included |
Filing into a listing's history¶
Needs listings:bind — or read to choose and write to record, which a token that holds
them already has. It is two calls, for a client that only attaches things: a mail client
filing the message you are reading into the listing it is about.
Choosing the listing. GET /listings/choices lists your listings as little of each as
recognising one takes: id, title, company_name, location, state, noted_at and
updated_at — no description, no salary, no applications. q narrows to the ones whose
title or company contains the words; state is all (the default), undecided, new,
shortlisted, discarded, applied or closed. It is paginated like every list, and
takes updated_since and after_id.
curl -H "Authorization: Bearer YOUR_TOKEN" \
"https://postulo.example.org/api/v1/listings/choices?q=research"
Recording the entry. POST /listings/{id}/events:
curl -X POST -H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"kind": "email_received", "summary": "Re: Research Engineer", "body": "We would like to talk.", "external_id": "<talk@blackmesa.example>"}' \
https://postulo.example.org/api/v1/listings/42/events
| Field | What it is |
|---|---|
kind |
note (the default), email_received, call, message, document or other. capture is written by the review screen, pointing at the capture, and is refused here |
summary |
The line the history shows, at most 250 characters |
body |
The message, the email's text, or what was said, at most 40,000 characters. Longer is a 422, not cut: send less rather than lose the end |
occurred_at |
When it happened; now, if left out |
contact_id |
Who it came from: one of your contacts |
document_id |
A file of yours it is about. A document entry needs one |
external_id |
What your client calls the thing — a message id, say, at most 250 characters. Sent again for the same listing, the first entry comes back with 200 and nothing is added, so a retry or a second press files it once |
It answers 201 with the entry — id, listing_id, kind, occurred_at, summary,
body, contact_id, document_id, capture_id, external_id, actor (the token's name,
as on a timeline) and created_at — or 200 with the one already filed under that
external_id. A contact_id or a document_id that is not yours is a 422, the same
answer as one that does not exist. The words are kept as text and shown as text: nothing in
an entry is ever read as markup or turned into a link, and none of it reaches the report's
spreadsheet or a calendar.
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 |
PUT |
/api/v1/captures/{id}/rendering |
captures |
Send a rendering of the page a capture was read from |
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 |
POST |
/api/v1/companies/{id}/signals |
write |
Add a dated hiring entry to a company |
DELETE |
/api/v1/companies/{id}/signals/{int:signal_id} |
write |
Remove a hiring entry |
GET |
/api/v1/cvs |
read |
List CVs |
GET |
/api/v1/cvs/{id} |
read |
One CV, with what it includes |
PATCH |
/api/v1/cvs/{id} |
write |
Change what a CV prints about you |
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, with the dates, as an iCalendar file to subscribe to |
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 |
GET |
/api/v1/listings/choices |
listings:bind or read |
List listings briefly, to choose one |
POST |
/api/v1/listings/{id}/events |
listings:bind or write |
Add an entry to a listing's history |
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/profile |
read |
Your details: the name and the contact block |
PATCH |
/api/v1/profile |
write |
Change your details |
GET |
/api/v1/reminders |
read |
List reminders |
GET |
/api/v1/reminders/calendar.ics |
read |
Every reminder still to do, and those done in the last six months, as iCalendar tasks (VTODO) to subscribe to |
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 |
POST |
/api/v1/reminders/{id}/reopen |
write |
Mark a reminder not 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 — or the scopes, where more than one would do. 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. A 409 of type
#not-kept is a rendering this capture will not take — its reason is switched-off,
decided, already-kept or no-room. 411 is an upload that did not say how large it
is, 413 one that is larger than this call takes, and 415 one of a kind it does not
keep. 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, five members, and the id of the request. That includes an
address that does not exist (401 without a live token, 404 with one) and a method an
address does not take (405, with its Allow header).
{
"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",
"request_id": "5f0c2a9e7b1d4c38a6e0f1b2c3d4e5f6",
"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, as a sentence for a person. In the language of the account that owns the token, whatever Accept-Language the client sent; without a token, or where the account has chosen no language, in the request's. The names of fields and values inside it are the API's own and are not translated. Never branch on it |
instance |
The address that refused |
request_id |
Which request this was: the same id as the X-Request-ID header, and the one every line that request wrote in the server's log carries. Quote it when reporting a refusal |
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, and scopes where more than one would do |
Ask for a token carrying scope — the narrowest that would work; scopes lists every one that would, narrowest first. This token 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 | |
#too-large |
413 | max_bytes |
Send something smaller. Said before the body was read |
#unsupported-media-type |
415 | accepted |
Send one of the media types in accepted |
#not-kept |
409 | reason |
Nothing to retry: the instance or the account does not keep it (switched-off), the capture was reviewed (decided), it has one (already-kept), or the account is full (no-room) |
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.
Changed since. detail is a translated sentence for every refusal, and in the account's
language: it had been translated for about one refusal in five, and in whatever language
the client asked for, which for a script is English and for a browser extension is the
browser's. Every refusal carries request_id. Where several scopes would do, detail lists
them with commas. A call made with a token is also carried out in its owner's language and
time zone, so an interview booked through the API is worded on the timeline, and its
reminder timed, the way one booked on the page is. None of this changes what a client does:
branch on type, show detail.