Webhooks
Postulo can post every event it announces to an address you name, as signed JSON, for an automation or a script to act on — n8n, Home Assistant, a shell script behind a small receiver. It is a notifier like Email or Browser, added under Settings → Connections → Webhook, and it is the one made for a machine: the events about what you did — a status change, an interview scheduled or moved, an offer recorded — are on for it and off for every notifier that reaches a person.
Nothing is posted from inside the request that caused the event. Deliveries are queued and sent by the scheduler (see Configuration), retried with backoff if the receiver is down — five, ten, twenty minutes and so on, eight times, about ten hours — and given up on after that, or at once on a client error such as a 404. A receiver that is down for an afternoon gets everything when it comes back.

The connection¶
| Field | What it is |
|---|---|
| Receiver address | Where the JSON is posted. https:// only: plain http:// is accepted solely for a receiver on a private or local network, which needs the operator to have set POSTULO_CONNECTIONS_ALLOW_PRIVATE. A private or local address is refused unless the operator has set POSTULO_CONNECTIONS_ALLOW_PRIVATE, and the check runs again at every delivery. |
| Signing secret | At least 16 characters. Stored encrypted, never sent; the receiver uses it to check the signature below. |
| Events | One switch per event. The three about what you did are on here and off elsewhere. |
Test posts one signed request with "event": "test" and reports the answer.
What the connection shows on Settings → Connections is how the last delivery went, not whether an event was queued. After a receiver answers 2xx it reads worked with the time; otherwise it says what the receiver answered, or why nothing was sent, a private address refused at delivery included. It goes on saying so until a delivery succeeds, however many events are queued in between.
The request¶
POST <your address>
Content-Type: application/json
Postulo-Event: status_changed
Postulo-Delivery: 1234
Postulo-Signature: t=1758540000,v1=5f8a…
{
"version": 1,
"event": "status_changed",
"key": "status:42:917",
"occurred_at": "2026-09-22T14:03:11+00:00",
"language": "en-GB",
"title": "Test Engineer at Aperture Science: Interviewing",
"body": "",
"url": "https://postulo.example.org/applications/42/",
"data": {
"application_id": 42,
"event_id": 917,
"from_status": "applied",
"to_status": "interviewing",
"actor": ""
}
}
version— the shape of this document; check it before reading the rest.event— one of the events below.key— names one announcement, not one object: a retry of an announcement carries the same key, and an object announced again on purpose (a reminder moved to a new time, a closing date that moved, an application that went quiet again) gets a new one. Keep it to deduplicate; Postulo never delivers one key twice to one address.occurred_at— when the thing happened, which is not when it was delivered.language— whattitleandbodyare written in: your own Postulo language, as a BCP 47 tag in its canonical form (pt-BR,en-GB). A tag says the same thing in any case, so compare it without regard to case.title,body,url— the message a person would have read, and where it points.data— ids and dates for a machine. Read it with a default for every field; it is the sender's vocabulary, and a later Postulo may add to it. Never a document or the text of one.
The answer Postulo expects is any 2xx. A 4xx other than 408 and 429 is taken as final;
anything else is retried.
Events¶
event |
When | In data |
|---|---|---|
reminder_due |
A reminder falls due | reminder_id, due_at, application_id |
capture_received |
A posting arrives through the capture API | capture_id, where |
went_quiet |
Applications have gone quiet | application_ids, count |
posting_closing |
A listing you are considering closes soon | posting_ids, closes_at, count |
status_changed |
An application's status changed | application_id, event_id, from_status, to_status, actor |
interview_scheduled |
An interview was scheduled or moved | interview_id, application_id, kind, starts_at, ends_at, moved |
offer_recorded |
An offer was recorded or revised | offer_id, application_id, terms, revised |
actor names the API token or import that made a change; it is empty when you did it
yourself.
The human wording of a notification (title and body) is written for the recipient, in their
language and time zone: an interview_scheduled body shows the start time as they would read it,
not an ISO timestamp. A machine reads data.starts_at, which stays ISO 8601, and a
status_changed event carries to_status for the status, not a pre-translated label.
Verifying the signature¶
The header is t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 over the string
<t>.<body> — the timestamp, a full stop, and the request body exactly as received — keyed
by your signing secret. Refuse anything whose t is more than five minutes from your clock,
so a captured request cannot be replayed later, and compare digests in constant time.
Python:
import hmac, hashlib, time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
parts = dict(piece.split("=", 1) for piece in header.split(",") if "=" in piece)
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Node:
const crypto = require("node:crypto");
function verify(secret, header, body, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(body).digest("hex");
const given = Buffer.from(parts.v1 || "", "hex");
return given.length === 32 && crypto.timingSafeEqual(Buffer.from(expected, "hex"), given);
}
Shell, for a quick look (not constant-time; fine at a terminal, not in a receiver):
t=1758540000; secret='…'; body='…'
printf '%s.%s' "$t" "$body" | openssl dgst -sha256 -hmac "$secret"
A sample receiver¶
Twenty lines of Python that verify and print. Run it somewhere Postulo can reach, and put its address on the connection.
from http.server import BaseHTTPRequestHandler, HTTPServer
import json, os
SECRET = os.environ["POSTULO_WEBHOOK_SECRET"]
class Receiver(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
if not verify(SECRET, self.headers.get("Postulo-Signature", ""), body):
self.send_response(401); self.end_headers(); return
event = json.loads(body)
print(event["event"], event["title"], event["data"])
self.send_response(204); self.end_headers()
HTTPServer(("", 8080), Receiver).serve_forever()
Answer quickly and do the work afterwards: Postulo waits ten seconds and then counts the delivery as failed, and retries it.