Moxlade
Checking your account

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" }
}

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

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

↑ top