Ir para o conteúdo

The API

Esta página ainda não foi traduzida, por isso é mostrada em inglês.

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:

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, 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:

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

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.