Table of Contents
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:// unless you know why not. 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.
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— stable across retries and across passes: the same thing announced twice carries the same key, and Postulo itself never delivers one key twice to one address. Keep it if your receiver must be idempotent against a retried request.occurred_at— when the thing happened, which is not when it was delivered.language— whattitleandbodyare written in: your own Postulo language.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.
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.
Postulo
Running it
- Installing Postulo
- Configuration
- Accounts and invitations
- Backups and your data
- Hardening
- Accessibility
- Health, metrics and logs
- Troubleshooting
Using it
- Getting started
- Listings
- Tracking applications
- Capturing postings
- Insights and the dashboard
- Reports
- Your career record
- CVs and portfolios
- Letters
- Files and what you sent
Building on it
Project