Webhooks
save_search takes a webhook_url. From then on, every posting that matches the filter is POSTed to it as JSON — this is the difference between asking the corpus and being told by it.
Matching starts immediately and back-fills recent postings that already match, so the first deliveries arrive within moments of saving rather than when the next posting appears. That surprises people who expect a quiet start.
What arrives
One POST per match, Content-Type: application/json, with a 10-second timeout. Anything outside 2xx counts as a failure and is retried.
{
"event_type": "search.matched",
"entity_type": "run",
"entity_id": 918273,
"from_state": null,
"to_state": "matched",
"occurred_at": "2026-09-03 21:14:07.221+00",
"payload": { "upwork_id": "0220955181...", "mission": "django-jobs" }
}event_type—search.matchedfor a saved search.entity_id— the match's own id. This is your idempotency key; see below.payload.upwork_id— the posting. Pass it toget_job,get_buyerorget_job_score: a saved-search match is in your feed, so those resolve without a search first.occurred_at— when the match happened, not when it was delivered. They differ after a retry.
Verifying a delivery
Every delivery carries X-Moxlade-Signature: t=<unix>,v1=<hex>. v1 is HMAC-SHA256 over "{t}." followed by the raw request body, keyed with the secret save_search returned to you once.
Verify it. A webhook URL ends up in logs, proxies and config files; without the check, anyone who learns yours can post a forged match into your pipeline, and a forged match is an attack on the exact thing this corpus is for.
import hashlib, hmac, time
SECRET = "whsec_..." # returned once by save_search
TOLERANCE = 300 # seconds
def verify(raw: bytes, header: str) -> bool:
"""raw is the REQUEST BODY AS RECEIVED — never a re-serialised dict."""
parts = dict(p.split("=", 1) for p in header.split(","))
ts, sig = int(parts["t"]), parts["v1"]
if abs(time.time() - ts) > TOLERANCE:
return False # too old: a replay
expected = hmac.new(
SECRET.encode(), f"{ts}.".encode() + raw, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, sig)Two details that are easy to get wrong and silently break verification. Hash the bytes you received, not a dict you re-serialised — JSON round-trips are not byte-stable. And reject a timestamp outside your tolerance, or a captured delivery stays replayable forever, which is the reason the timestamp is inside the signature at all.
The secret is shown once, at save_search. Re-saving the same search returns the same secret rather than rotating it — an idempotent call should not invalidate your receiver. Searches saved before signing existed carry no secret and arrive unsigned; re-save them to get one.
What to build for
| question | answer |
|---|---|
| Retries? | Yes. Up to 6 attempts, exponential backoff (2ⁿ seconds, capped at an hour), then the delivery is marked failed and abandoned. |
| Duplicates? | Assume yes. Delivery is at-least-once: a receiver that 2xxs slowly, or after a network cut, can be sent the same match again. De-duplicate on entity_id. |
| Ordering? | Best effort, not guaranteed. Deliveries go out in id order, but a failed one falls behind while its backoff runs, so a later match can arrive first. |
| While a plan is lapsed? | The search is paused, not deleted, and nothing is delivered. Paying resumes it. Matches that occurred while paused are not replayed. |
| Non-2xx from you? | Counted as a failure and retried on the schedule above. A 4xx is treated the same as a 5xx — we cannot tell a rejection from an outage. |
| Slow receiver? | 10 seconds, then the attempt fails. Acknowledge fast and do the work afterwards. |
What we refuse to call
save_search resolves the hostname and refuses any URL landing on a private, loopback, link-local or reserved address, and refuses plain http. A saved search is a URL we will fetch on a schedule, and we will not be aimed at an internal network.