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

```json
{
  "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.matched` for a saved search.
- `entity_id` — the match's own id. **This is your idempotency key**; see below.
- `payload.upwork_id` — the posting. Pass it to `get_job`, `get_buyer` or `get_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.

> **Warning** — **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.

```python
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.
