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 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); 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=<unix seconds>,v1=<hex>, where the hex is HMAC-SHA256 of the string "<t>.<raw body>", 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, 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, with every other code.