# Result webhooks If you do nothing with webhooks, you lose nothing — `GET /partner/results` is the contract, and a webhook only says *there is something new, go poll*. ## What it is, and what it is not [The results feed](/docs/results.md) is the only source of truth for what a match did. A webhook is a small signed message that carries **no result data at all** — no score, no outcome, no player, not even a match id — and **nothing to poll from**: no cursor either. A delivery that is missed, duplicated, late or out of order costs you nothing — your next poll returns the same rows regardless. The only thing a webhook buys you is *latency* — skip this page if your polling interval is already fine, or wire it up to react in a second rather than a minute. Keep the poll loop either way. ## Setting one up In the portal, per environment — a test key's events never reach a live endpoint, and vice versa. You give us a URL and we mint the secret: - **The URL is `https` only**, on a real dotted public hostname — no `localhost`, no bare service name, no IP literal in any encoding (see [invalid_webhook_url](/docs/errors.md)); a path and a query string are fine. Delivery re-resolves and re-checks the host every time, not just at registration. - **The secret is shown exactly once** — copy it in now. Rotating mints a new one, also shown once; the old secret stops signing immediately, so a rotation is a redeploy. Changing the URL does not change the secret. - **Turning delivery off keeps the endpoint on file.** Saving the URL again turns it back on and clears the failure count. Setting or rotating is an Admin-or-Developer action and needs a second-factor code. Switching delivery *off* needs no code at all. ## What arrives ``` POST /your/endpoint HTTP/1.1 Content-Type: application/json X-Peritus-Signature: t=1785000421,v1=4b1f…64 hex chars…9a02 {"type":"results.available","partnerId":"acme","at":1785000421} ``` `type` is `results.available`, the only event type today. `at` is when we sent it, Unix epoch seconds, and is the same value the signature's `t=` carries. The environment is *which endpoint received it*, never a field. **One nudge per partner, per row transition**: a match of yours starting, ending, ending as `no_contest`, or an existing row's contents changing (e.g. money fields recorded after the row was first served). Once a row is `ended` its `outcome` never moves again. Two seats of yours in one match send **one** nudge, not two. Nothing is sent for an environment with no endpoint configured, a disabled endpoint, or a deactivated account — none of those is an error on your side. > If you have no cursor yet, poll with none and start from the beginning of your history. ## Verifying the signature — against the raw bytes `X-Peritus-Signature` is `t=,v1=`, where the hex is **HMAC-SHA256 of the string `"."`**, keyed with your webhook secret. To verify: 1. Split the header on `,` and read `t` and `v1`. 2. Reject a `t` more than five minutes old — it's inside the signed content, so an attacker can't edit around it. 3. Recompute the HMAC over `t`, a literal `.`, and the exact bytes of the request body, keyed with your secret, hex-encoded. 4. Compare with `v1` in constant time. ``` import hmac, hashlib, time def verify(raw_body: bytes, header: str, secret: str) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) t, v1 = parts["t"], parts["v1"] if abs(time.time() - int(t)) > 300: # stale, or future return False signed = t.encode() + b"." + raw_body # raw bytes expected = hmac.new( secret.encode(), signed, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, v1) ``` A vector to test your verifier against before any traffic exists — secret `test-secret`, `t` `1700000000`, and the body bytes `{"type":"results.available"}` (28 bytes, no trailing newline) sign to: ``` 9e19f0763e0b177f599a4659d6ecc686 797cb2fe2dfb70eebb5836d98a15f608 ``` Once your endpoint is registered, **send yourself a real one**: the portal's webhooks page has a *Send test event* button on any endpoint that is switched on, delivering one properly signed `results.available` event through the same code path a real row transition uses. It appears in the attempt log within a few seconds. > A test event never counts toward the ten failures that switch you off, and a successful one does not reset an existing failure run — only a real delivery that lands does that. Every test attempt is recorded either way. One test event per endpoint every ten seconds. > **An endpoint that is already switched off has no test button** — the case you meet after ten failures switch you off automatically. Save its URL again (above) to re-enable it, then test. > **Verify against the raw bytes your HTTP layer received, never a re-serialised object.** Re-encoding the body — `json.dumps(json.loads(body))`, or any framework that hands your handler a decoded object — can change whitespace, key order or number formatting and invalidate the signature. It fails intermittently, so it survives testing. ## Answer quickly, and expect duplicates **Respond `2xx` and do the work afterwards.** A `2xx` is the only thing we count as delivered. Our budget for your endpoint is short — 2 seconds to connect, 5 seconds end to end — so acknowledge the nudge immediately and run your poll outside the request. You may get two nudges for one transition, or a nudge about rows you already processed — both harmless, under the `(matchId, playerId)` keying the feed requires. Building anything to deduplicate or reorder nudges means treating the nudge as data, which it isn't. **Do not redirect us.** We never follow redirects — a `3xx` is recorded as a failed attempt carrying that `3xx`, not as a delivery. Change the URL in the portal instead. ## When we retry, and when we stop **Three attempts per nudge: immediately, then after 1 second, then after 5 seconds.** Then we stop, for good — nothing is queued or replayed later, **the feed is the replay**. All three attempts carry the identical body and signature, `t` included. A `3xx`, or a `4xx` other than `408` or `429`, is a considered answer, not a retryable outage: we record it and stop. A missing response (DNS, TCP, TLS, timeout), a `408`, a `429`, or any `5xx` gets the ladder. Either way it counts as a failed delivery below. ## Ten failed deliveries switch the endpoint off After **10 consecutive failed deliveries** — deliveries, not attempts, so up to 30 consecutive failed calls — we disable the endpoint and stop sending to it. One delivery that lands resets the count to zero. > **We do not e-mail you when an endpoint is disabled.** If you depend on these deliveries, treat “we have stopped receiving webhooks” as a condition your own monitoring detects. You find out by looking: the endpoint's own state (disabled, with the time); the [event log](/docs/portal.md), attributed to *the platform* rather than your team, with the URL and failure count; and the attempt log below. ## Seeing what we saw Each environment's endpoint has a **delivery attempt log** in the portal, newest first. Every attempt records: - When it was made, and which try of the ladder it was — 1, 2 or 3. - Whether it was delivered. - The status code we received, or *no status at all* if the request never produced a response. - How long it took. - The first 200 characters of your response body. - Which match the nudge was about — the one thing the wire body never carries. Attempts are recorded from the first delivery onwards and outlive the endpoint's enable switch. **Attempts are kept for 30 days.** > The refusals this surface can return when you configure an endpoint — including `invalid_webhook_url` — are in [the error catalogue](/docs/errors.md), with every other code.