1 Webhooks
Tiago Águeda edited this page 2026-09-22 15:26:23 +02:00

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 — what title and body are 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.