On this page
Webhooks
Webhooks push what happens inside InPlayGuru to your own software as it happens. When one of your strategies fires a pick, and again when that pick settles, we send a signed JSON POST to an HTTPS endpoint you control — ready for your own dashboards, notification pipelines, spreadsheets or research databases.
Overview #
Webhooks are part of the Ultra plan and available only on it, like everything documented here.
Two event types cover the full life of a pick. Each delivery is a single event — you will never receive batched payloads.
| Event | Fires | Typical timing |
|---|---|---|
| pick.created | The moment one of your strategies fires a pick. The payload carries a full snapshot of the match at that exact moment — score, timer, in-play stats and market quotes. | Within seconds of the pick |
| pick.settled | When the pick's outcome is known. The payload carries the final result, whether the pick hit, and full-time stats. | Shortly after the match ends |
Deliveries are per account: one endpoint URL receives the events of all your strategies. Configure it on the webhook settings page, which also shows a live delivery log of your last 20 attempts (kept 30 days).
Quick start #
-
Point us at your endpointSave an HTTPS URL on the webhook settings page. Any route on your server that accepts a POST with a JSON body works — no registration handshake, no challenge round-trip. Saving the URL also reveals your signing secret.
-
Acknowledge first, process afterYour endpoint has 5 seconds to answer with a 2xx status. Respond immediately, then do the real work — anything slow (database writes, downstream calls) belongs after the response:
const express = require('express'); const app = express(); // Keep the raw bytes — signature verification needs them (see below). app.post('/inplayguru/webhook', express.raw({ type: 'application/json' }), (req, res) => { res.sendStatus(200); // acknowledge first const event = JSON.parse(req.body); handleEvent(event); // then do the real work }); app.listen(8080); -
Verify the signatureEvery request is signed with your account's secret so you can prove it came from InPlayGuru. See Verifying signatures for ready-made snippets.
-
Branch on the event typeSwitch on the top-level type field and ignore any type you don't recognise — additional event types may be introduced over time, and an unknown type must never make your endpoint fail.
-
Watch your delivery logFire a pick with any strategy and watch it arrive. The settings page records every attempt with status, latency and retry trace — your first stop whenever something looks off.
Delivery & retries #
The request
Deliveries are POST requests with a JSON body and these headers:
| Header | Value |
|---|---|
| Content-Type | application/json |
| User-Agent | InPlayGuru-Webhooks/1.0 |
| X-InPlayGuru-Signature | Hex HMAC-SHA256 of the raw request body, keyed with your signing secret — see Verifying signatures. |
| X-InPlayGuru-Timestamp | Unix timestamp (seconds) of the moment this delivery attempt was sent. Each retry carries a fresh timestamp. |
| Cache-Control | no-store |
What counts as delivered
Any 2xx status returned within 5 seconds (3 seconds to establish the connection). The response body is ignored — only the status code matters. Since we don't read response bodies, don't return data in them — and never sensitive data.
Endpoints must be served over HTTPS with a valid TLS certificate. Keeping the endpoint reachable — DNS, certificates, firewall rules, hosting uptime — is your side of the contract; failed attempts are visible in your delivery log either way.
Retry policy
A delivery that fails for a reason that can clear on its own (a timeout, a connection error, a 5xx, a 408 or a 429) is retried automatically:
- 3 total attempts per event, with 2s, 3s pauses between them.
- Every attempt is signed independently and carries the same event_id, so retries are recognisable as the same event.
- Statuses that cannot change on a retry (400, 401, 403, 404, 405, 410, 422) are not retried. A rejected URL, key or payload is rejected again two seconds later, so the event is marked failed on the first response.
- After the final attempt the event is marked failed and is not sent again — there is no automatic replay of historical failed events. The full attempt trace stays visible in your delivery log, and events missed during endpoint downtime cannot be reconstructed afterwards.
Events dispatch from a dedicated delivery queue the moment they occur — pick.created normally reaches your endpoint within seconds of the strategy firing.
Events & payloads #
The envelope
Every event shares the same top-level shape:
| Field | Type | Description |
|---|---|---|
| event_id | string (uuid) | Unique per event and stable across retries — your deduplication key. |
| event_created_at | string (ISO 8601) | When the event was generated. Use this for ordering, not arrival time. |
| schema_version | string | Payload schema version — currently 1. |
| type | string | pick.created or pick.settled. |
| data | object | The event body: pick, strategy and match objects, documented below. |
pick.created
Sent the moment a strategy's conditions match. The data.match object is a snapshot of the match at pick time — the same numbers your strategy saw when it fired.
{
"event_id": "a9d04b5d-7caf-4cbb-bf87-e95f7ca7815d",
"event_created_at": "2026-02-14T21:17:30+00:00",
"schema_version": "1",
"type": "pick.created",
"data": {
"pick": {
"id": 738656899,
"created_at": "2026-02-14T21:17:29+00:00"
},
"strategy": {
"id": 960718,
"name": "⚡Late Goals from Building Pressure",
"created_at": "2026-01-29T22:43:05+00:00",
"note": "When late pressure builds but goals are still missing, it often explodes in the last 10–20 minutes",
"has_alerts_enabled": true,
"strike_rate": 92.9,
"strike_rate_league": 87.8
},
"match": {
"id": "RGFQZVdJbS82VG9mMFlEaDU2SmJFQT09",
"date": "2026-02-14T19:45:00.000Z",
"score": [1, 1],
"timer": { "min": 70, "live": true, "stage": 4, "ext": 0, "status": 1 },
"home": {
"id": "blVwS0RWOENsd009",
"name": "Inter Milan",
"cc": "it",
"image_id": "b0NCWi9FdjBmbWc9",
"form": ["W", "W", "W", "W", "W"],
"formation": "3-5-2",
"manager": { "id": "elMxREd5bjdCSTA9", "name": "Cristian Chivu", "cc": "ro" },
"market_value": 666800000,
"avg_player_age": 29.1,
"foreigners": 16,
"national_team_players": 15,
"youth_national_team_players": 1
},
"away": {
"id": "VWxIQllid2tKbjg9",
"name": "Juventus",
"cc": "it",
"image_id": "K1pSZjY4emRIcEE9",
"form": ["W", "D", "W", "L", "D"],
"formation": "4-2-3-1",
"manager": { "id": "dnJJaWFGc1czOFU9", "name": "Luciano Spalletti", "cc": "it" },
"market_value": 560200000,
"avg_player_age": 27.2,
"foreigners": 18,
"national_team_players": 17,
"youth_national_team_players": 2
},
"favorite": 0,
"league": {
"id": "RFJobnowRFU0Rm89",
"name": "Italy Serie A",
"cc": "it",
"home_pos": "1",
"away_pos": "4",
"round": "25"
},
"season": {
"name": "Serie A 25/26",
"round": "25",
"max_rounds": "38",
"date_start": "2025-08-23T00:00:00.000Z",
"date_end": "2026-05-24T23:59:59.000Z"
},
"stadium": {
"name": "Giuseppe Meazza",
"city": "Milan",
"country": "Italy",
"capacity": "80018",
"weather": {
"temp": 9.17,
"desc": "Light intensity drizzle",
"icon": "09n",
"humidity": 90,
"wind": 1.03
}
},
"stats": {
"action_areas": [24.9, 33.7],
"attacks": [84, 64],
"corners": [2, 0],
"crosses": [16, 6],
"crossing_accuracy": [13, 50],
"dangerous_attacks": [60, 39],
"fouls": [11, 7],
"goals": [1, 1],
"key_passes": [7, 6],
"momentum": [92, 8],
"off_target": [6, 1],
"offsides": [1, 0],
"on_target": [5, 6],
"passing_accuracy": [90, 87],
"penalties": [0, 0],
"possession": [61, 39],
"redcards": [0, 1],
"saves": [5, 0],
"shots_blocked": [2, 0],
"substitutions": [4, 3],
"xg": [0.75, 0.77],
"yellowcards": [2, 1]
},
"odds": {
"1": { "odd_home": 2.05, "odd_draw": 2, "odd_away": 13, "ss": "1-1", "suspended": false },
"2": { "odd_home": 9.5, "odd_draw": 1.063, "odd_away": 29, "ss": "1-1", "suspended": true },
"3_0.50": { "handicap": "0.50", "odd_over": 1.1, "odd_under": 7, "ss": "0-0", "suspended": true },
"3_1.50": { "handicap": "1.50", "odd_over": 1.111, "odd_under": 6.5, "ss": "1-0", "suspended": true },
"3_2.50": { "handicap": "2.50", "curr_line": true, "odd_over": 1.667, "odd_under": 2.2, "ss": "1-1", "suspended": false },
"3_3.50": { "handicap": "3.50", "odd_over": 4.333, "odd_under": 1.222, "ss": "1-1", "suspended": false },
"3_4.50": { "handicap": "4.50", "odd_over": 13, "odd_under": 1.04, "ss": "1-1", "suspended": false },
"3_5.50": { "handicap": "5.50", "odd_over": 26, "odd_under": 1.01, "ss": "1-1", "suspended": true },
"3_6.50": { "handicap": "6.50", "odd_over": 26, "odd_under": 1.01, "ss": "1-1", "suspended": true },
"4_0.50": { "handicap": "0.50", "odd_over": 1.727, "odd_under": 2, "ss": "0-0", "suspended": true },
"4_1.50": { "handicap": "1.50", "odd_over": 2.1, "odd_under": 1.667, "ss": "1-0", "suspended": true },
"4_2.50": { "handicap": "2.50", "odd_over": 8, "odd_under": 1.083, "ss": "1-1", "suspended": true },
"4_3.50": { "handicap": "3.50", "odd_over": 26, "odd_under": 1.01, "ss": "1-1", "suspended": true },
"4_4.50": { "handicap": "4.50", "odd_over": 26, "odd_under": 1.01, "ss": "1-1", "suspended": true },
"7_4.00": { "handicap": "4.00", "odd_over": 2.1, "odd_under": 1.7, "ss": "1-1", "suspended": true },
"7_4.50": { "handicap": "4.50", "odd_over": 1.95, "odd_under": 1.85, "ss": "1-1", "suspended": false },
"7_5.00": { "handicap": "5.00", "odd_over": 2.1, "odd_under": 1.7, "ss": "1-1", "suspended": true },
"7_5.50": { "handicap": "5.50", "odd_over": 2.05, "odd_under": 1.75, "ss": "1-1", "suspended": true },
"7_6.00": { "handicap": "6.00", "odd_over": 2.05, "odd_under": 1.75, "ss": "1-1", "suspended": true },
"7_6.50": { "handicap": "6.50", "odd_over": 2.05, "odd_under": 1.75, "ss": "1-1", "suspended": true },
"7_7.00": { "handicap": "7.00", "odd_over": 2.05, "odd_under": 1.75, "ss": "1-0", "suspended": true },
"7_7.50": { "handicap": "7.50", "odd_over": 2.025, "odd_under": 1.775, "ss": "1-0", "suspended": true },
"7_8.00": { "handicap": "8.00", "odd_over": 2.025, "odd_under": 1.775, "ss": "0-0", "suspended": true },
"7_8.50": { "handicap": "8.50", "odd_over": 2, "odd_under": 1.8, "ss": "0-0", "suspended": true },
"7_9.00": { "handicap": "9.00", "odd_over": 2.025, "odd_under": 1.775, "ss": "0-0", "suspended": true },
"8_0.50": { "handicap": "0.50", "odd_over": 2.1, "odd_under": 1.7, "ss": "1-1", "suspended": true },
"8_1.00": { "handicap": "1.00", "odd_over": 2.35, "odd_under": 1.575, "ss": "1-1", "suspended": true },
"8_1.50": { "handicap": "1.50", "odd_over": 2.85, "odd_under": 1.4, "ss": "1-1", "suspended": true },
"8_2.00": { "handicap": "2.00", "odd_over": 2.15, "odd_under": 1.675, "ss": "1-0", "suspended": true },
"8_2.50": { "handicap": "2.50", "odd_over": 2.1, "odd_under": 1.7, "ss": "1-0", "suspended": true },
"8_3.00": { "handicap": "3.00", "odd_over": 2.075, "odd_under": 1.725, "ss": "0-0", "suspended": true },
"8_3.50": { "handicap": "3.50", "odd_over": 2.075, "odd_under": 1.725, "ss": "0-0", "suspended": true },
"8_4.00": { "handicap": "4.00", "odd_over": 2.075, "odd_under": 1.725, "ss": "0-0", "suspended": true },
"9": { "odd_yes": 1.5, "odd_no": 2.5, "ss": "1-0", "suspended": true },
"10": { "odd_yes": 3.75, "odd_no": 1.25, "ss": "1-0", "suspended": true },
"11": { "odd_yes": 11, "odd_no": 1.05, "ss": "1-1", "suspended": false },
"13": { "odd_odd": 2.25, "odd_even": 1.571, "ss": "1-1", "suspended": false },
"14": { "odd_home": 1.111, "odd_away": 6.5, "ss": "1-1", "suspended": false }
}
}
}
}
Notice the shapes worth trusting your parser to: match, team and league IDs are opaque strings while pick and strategy IDs are integers; some numeric-looking fields (capacity, table positions, rounds) arrive as strings; and real stats maps can carry experimental keys beyond those shown — ignore what you don't recognise. This top-tier match also carries maximum enrichment — team form, formation, manager, market value, weather — none of which is guaranteed for smaller competitions.
Field reference
| Field | Type | Description |
|---|---|---|
| data.pick.id | integer | Your pick's unique id — matches the pick shown in your strategy history. |
| data.pick.created_at | string (ISO 8601) | When the pick was generated. |
| data.strategy.id / .name / .note | int / string / string | null | The strategy that fired, as configured on your strategies page. |
| data.strategy.strike_rate | number | null | The strategy's overall strike rate (%) at event time. |
| data.strategy.strike_rate_league | number | null | The strategy's strike rate (%) in this match's league. |
| data.match.id | string | Opaque match id — stable across both events of the same pick. Never parse or decode it; compare it byte-for-byte. |
| data.match.score | [int, int] | Current score as a [home, away] pair. |
| data.match.timer | object | Match clock: min (minute), live (boolean), stage (see the stage reference below) and injury-time info where the feed provides it. |
| data.match.home / .away | object | Team objects. Beyond id (opaque string) and name, treat every field as optional enrichment that may be absent: country code, image_id, recent form, formation, manager, market_value and squad indicators (average age, foreigners, national-team players). Coverage follows league tier. |
| data.match.favorite | 0 | 1 | null | Pre-match favorite — the side with the lowest closing 1X2 quote: 0 = home, 1 = away, null = none determined. One side is designated even when the quotes are close; it's a convenience field, so apply your own definition instead if yours differs. |
| data.match.league | object | League id (opaque string), name, country code — plus current table positions (home_pos / away_pos) and round as strings, when known. |
| data.match.date | string (ISO 8601) | Kickoff time (UTC). |
| data.match.season | object | null | Competition season: name, round, max_rounds, start and end dates. |
| data.match.stadium | object | null | Venue name, city, country, capacity — plus a weather object (temperature °C, description, humidity %, wind) when conditions are known. |
| data.match.stats | object | In-play stats at pick time. Every entry is a [home, away] pair — full key list below. |
| data.match.odds | object | Live market quotes at pick time, keyed by market id — reference below. |
Stat keys in data.match.stats
Each key maps to a [home, away] pair. An absent key simply means the stat isn't covered for that match — coverage depends on league tier and data availability, and lower-tier competitions naturally carry reduced depth. There is no predefined coverage guarantee per league.
Core — expected on covered matches
goals attacks dangerous_attacks corners on_target off_target yellowcards redcards
Optional — league dependent
xg momentum possession action_areas shots_blocked saves fouls penalties offsides freekicks injuries substitutions crosses crossing_accuracy key_passes passing_accuracy
Experimental — ignore these
Keys such as boiling_point, frustration, shot_quality and turbulence are proprietary metrics under active development. They may change behaviour, be recalibrated, or disappear without notice — don't build on them yet.
Stage values in data.match.timer.stage
| Stage | Meaning |
|---|---|
| -1 | Unknown |
| 0 | Upcoming |
| 1 | First half |
| 2 | First half, extra time |
| 3 | Half time |
| 4 | Second half |
| 5 | Second half, extra time |
| 6 | Ended |
Market IDs in data.match.odds
Keys are market IDs; some carry a line suffix — in 3_2.50 the part before _ is the base market id and the part after is the line value. Quote entries carry outcome-specific keys (odd_home / odd_draw / odd_away, odd_over / odd_under, odd_yes / odd_no) plus a suspended boolean. Entries may also include metadata such as handicap or the score at quote time (ss) — ignore keys you don't use. On over/under families, curr_line: true marks the line tracking the current match total. Only markets quoted at that moment are present.
| Market id | Market | Lines observed | Entry keys |
|---|---|---|---|
| 1 | 1X2 — full-time result | — | odd_home, odd_draw, odd_away |
| 2 | 1X2 at half time | — | odd_home, odd_draw, odd_away |
| 3_{line} | Match goals over/under | 0.50 – 8.50 | odd_over, odd_under |
| 4_{line} | 1st-half goals over/under | 0.50 – 5.50 | odd_over, odd_under |
| 7_{line} | Asian corners over/under — full match | 0.50 – 25.00, 0.5 steps | odd_over, odd_under |
| 8_{line} | Asian corners over/under — 1st half | 0.50 – 15.00, 0.5 steps | odd_over, odd_under |
| 9 | Both teams to score — full match | — | odd_yes, odd_no |
| 10 | Both teams to score — 1st half | — | odd_yes, odd_no |
| 11 | Both teams to score — 2nd half | — | odd_yes, odd_no |
| 13 | Total goals odd/even | — | odd_odd, odd_even |
| 14 | DNB (2-way) | — | odd_home, odd_away |
pick.settled
Sent when the pick's outcome is determined. Same envelope and structure as pick.created, with the match object reflecting the final state and three additional result fields:
| Field | Type | Description |
|---|---|---|
| data.pick.strike | boolean | null | true when the pick hit, false when it missed, null when there is no verdict — see below. |
| data.pick.outcome_id | integer | null | ID of the tracked outcome for the strategy (e.g. "Over 2.5 Match Goals") — null when no verdict was reached. |
| data.match.score_ht | [int, int] | The half-time score as a [home, away] pair. |
When there's no verdict
A strategy doesn't have to track an outcome — plenty exist purely to watch for a situation and alert. Picks from those strategies still receive pick.settled: settlement means the match finished and here is its final state, not a verdict exists. In that payload both result fields are null — "outcome_id": null, "strike": null — while everything else (final score, score_ht, full-time stats and quotes) is present as usual, so you can run whatever evaluation matters to you against the final numbers.
Parser rule: strike is three-state. Truthiness collapses it — if (strike) treats a miss and a no-verdict identically — so compare explicitly against true, false and null, and keep no-verdict picks out of strike-rate maths on your side (they're excluded from ours). Same family of trap as the favorite field — see Parsing & precision.
{
"event_id": "c4a91d7f-2e60-48b3-8f1a-6d20e5b79c44",
"event_created_at": "2026-02-14T21:39:12+00:00",
"schema_version": "1",
"type": "pick.settled",
"data": {
"pick": {
"id": 738656899,
"created_at": "2026-02-14T21:17:29+00:00",
"outcome_id": 21,
"strike": true
},
"strategy": { "id": 960718, "name": "⚡Late Goals from Building Pressure" },
"match": {
"id": "RGFQZVdJbS82VG9mMFlEaDU2SmJFQT09",
"score": [2, 1],
"score_ht": [1, 0],
"stats": { "goals": [2, 1], "corners": [4, 1] }
}
}
}
Triggers & the source of truth
- Webhook events come from the same strategy engine that powers Telegram alerts — the two channels always agree on what fired and when.
- Your live strategy configuration governs output: edit, enable, disable or duplicate a strategy and webhook events follow the new configuration from that moment on.
- The payload reflects our live feed at the moment of trigger. Match data originates from third-party providers and isn't guaranteed error-free; events are not retroactively corrected if upstream data changes after delivery.
- The payload structure is the same for every strategy — per-strategy custom payload shapes aren't offered. Filter and reshape on your side using the strategy fields in data.strategy.
Verifying signatures #
Every delivery carries X-InPlayGuru-Signature: the hex-encoded HMAC-SHA256 of the raw request body, keyed with the signing secret from your settings page. Recompute it and compare with a constant-time comparison — if they match, the payload is authentic and untampered.
const crypto = require('crypto');
function isAuthentic(rawBody, signature, secret) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/inplayguru/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.get('X-InPlayGuru-Signature');
if (!isAuthentic(req.body, signature, process.env.INPLAYGURU_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
res.sendStatus(200);
const event = JSON.parse(req.body);
// handle event...
});
<?php
$secret = getenv('INPLAYGURU_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_INPLAYGURU_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
http_response_code(200); // acknowledge first
$event = json_decode($rawBody, true);
// handle $event...
import hashlib, hmac, os
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["INPLAYGURU_WEBHOOK_SECRET"].encode()
@app.post("/inplayguru/webhook")
def webhook():
raw = request.get_data() # raw bytes, before any parsing
signature = request.headers.get("X-InPlayGuru-Signature", "")
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
return "", 401
event = request.get_json()
# handle event...
return "", 200
Managing your secret
- Your secret lives on the settings page. Store it like a password — an environment variable or secret store, never in your repository. If it ever leaks, rotate it immediately.
- Rotating the secret takes effect immediately. Each delivery attempt is signed at send time, so after a rotation even retries of in-flight events are signed with the new secret — update your endpoint first, then rotate.
- Signature verification is recommended but optional — deliveries are sent whether or not you verify, and skipping it never affects functionality. The X-InPlayGuru-Timestamp header tells you when each attempt was sent; as an extra layer you may choose to reject requests whose timestamp is older than a few minutes (say, five) to reduce the chance of replays.
Idempotency & ordering #
Deliveries can duplicate — deduplicate on event_id
If your endpoint processes an event but responds slowly or with an error, we retry — and you receive the same event again. Because event_id is stable across retries, exactly-once processing is yours for the price of a seen-ids check:
- Record each processed event_id; skip events you've seen. A day of retention is plenty — retries span seconds, not days.
- As a belt-and-braces key, (type, data.pick.id) is also unique per logical event.
Arrival order is not guaranteed
Events are dispatched in order, but retries and network timing mean they can arrive out of order. Order by event_created_at when sequence matters. A pick.settled always refers to an earlier pick.created with the same data.pick.id — hours may pass between the two, since settlement follows the final whistle.
Not guaranteed #
Integrations get burned by welding themselves to accidents of the current implementation — never build on:
- Source IP addresses. Deliveries can originate from any address and there is no stable range to allowlist. Authenticate requests by their signature, never by source IP.
- JSON formatting. Key order, whitespace and number formatting can change within what the JSON spec allows. The signature covers the exact bytes as sent — verify first, then parse; never re-serialise and expect equal bytes.
- Header name casing. HTTP/2 lower-cases header names. Read all headers case-insensitively.
- TLS details. Certificates, chains and cipher suites rotate. Don't pin fingerprints.
- Exact timing. "Within seconds" is typical behaviour, not a deadline we promise. Don't build logic that breaks when a delivery takes a minute.
- Volume patterns. Event volume follows your strategies and live data coverage; no baseline or ceiling of "normal" volume is promised.
If a behaviour matters to your integration and it isn't documented here, treat it as undefined — and tell us. If it's something we can commit to, it gets documented; if not, you'll know not to lean on it.
Limits & fair use #
Webhooks are push-only — you never need to poll, and normal strategy volumes are nowhere near any limit. The boundaries below exist to keep delivery reliable for everyone:
- Sustained rate: up to 200 deliveries per minute per account. Beyond that, events may be queued, delayed or temporarily throttled rather than dropped.
- Spikes queue briefly. When many matches trigger strategies at once, events can be queued for a short moment to protect platform stability — they still arrive, in dispatch order.
- Persistently failing endpoints may be paused. Constant timeouts or 5xx responses waste retries on both sides; delivery may be paused with notice until the endpoint is fixed.
- Don't work around the limits. Rotating endpoints to bypass caps or deliberately forcing retries can lead to suspension of webhook access.
Delivery is best-effort, without a formal SLA: brief maintenance windows can pause delivery momentarily, and a disruption at our upstream data providers can degrade or pause the event stream. In exceptional cases delivery may be throttled or temporarily disabled to protect platform stability, with notice where feasible.
Storing the data #
You're free to store the full JSON payloads. Analytics, internal modelling, automation, strategy development, historical research: that's exactly what the integration is for.
- Storing full payloads, for as long as you like
- Models, signals and internal analytics built on your stored events
- Automation, dashboards and execution engines that consume the data
- Historical research and strategy development
- Reselling or redistributing the raw feed
- Streaming or exposing payloads — or a substantially similar derivative feed — for others to consume as a data service
- Repackaging the data as a standalone, competing product
If your use case evolves toward something that might overlap commercially with the data or the InPlayGuru platform itself, talk to us first — alignment over enforcement, always.
Acting on events #
A pick is one thing only: your strategy's conditions evaluating true against the live feed at one moment in time. It is not advice, not a prediction, and not an instruction to do anything. What your systems do with that signal — and every consequence of it — is yours.
If events trigger automated actions anywhere downstream, put your own guardrails between the event and the action:
- Sanity-check the payload before acting — plausible score and timer, quote values inside ranges you'd accept, match not already hours old (see Working with the data for how live data misbehaves).
- Cap action rates so a burst of picks — or a duplicate delivery your dedupe missed — can't multiply into a burst of actions.
- Keep a kill switch you can flip without deploying, and start any new automation in observe-only mode until you've watched it behave across real match days.
The platform's role ends at delivering the signed event and recording the attempt. Everything after your 2xx — including acting on a payload that was delayed, duplicated, or reflected a feed error — runs at your own risk and judgement.
No-code tools #
You don't have to run a server to use webhooks. If you'd rather not write code, any automation platform with an inbound-webhook trigger can receive InPlayGuru events as-is — Zapier ("Webhooks by Zapier"), Make, n8n, Pipedream and Google Apps Script all work:
- Create an inbound webhook (often called a "catch hook") in your platform of choice and copy the URL it gives you.
- Paste that URL into the webhook settings page.
- Branch on the type field and map the payload fields you care about — a pick log in a spreadsheet, a Discord or Slack message per pick, a notification pipeline with your own filters.
Signature verification is available on platforms that allow a code step; on those that don't, treating the URL itself as a secret is the usual trade-off. Either way, this is simply an option — everything on this page works the same whether the receiver is your own code or a no-code tool.
Troubleshooting #
The delivery log labels every attempt with one of four outcomes:
| Outcome | Meaning | Usual fix |
|---|---|---|
| Delivered | Your endpoint answered 2xx in time. | Nothing to do. |
| Rejected | Your endpoint answered with a non-2xx status. Redirects land here too — they are not followed. | Check your endpoint's logs for the status shown; remove URL redirects; make sure unknown event types return 200 rather than an error. |
| Timed out | No response within 5 seconds. | Respond first, process after — move slow work out of the request cycle (see Quick start). |
| Unreachable | The connection never succeeded — DNS, refused connection, or TLS handshake failure. | Verify the hostname resolves publicly, the port is open to the internet, and the TLS certificate chain is complete. |
Not receiving events?
- Confirm an endpoint URL is saved and shows Live on the settings page.
- Webhook events fire when your strategies fire — a quiet day for your strategies is a quiet day for your endpoint. Cross-check your strategy history.
- Check the delivery log: attempts recorded there mean we're sending and the issue is on the receiving path; an empty log means no events have fired since you set up.
- Signature mismatches? Make sure you verify the raw body and the current secret — see Verifying signatures.
Support scope
The delivery log answers most "did it send?" questions on its own — check it first. When you do write in, include the event_id from the log, and expect this scope:
- Confirming your webhook is enabled and correctly configured
- Checking delivery attempts and their status in our log
- Providing the current schema, headers and example payloads
- Investigating platform-side incidents and outages
- Debugging your endpoint code, hosting, TLS or networking
- Debugging downstream systems — bots, databases, pipelines, third-party tools
- Writing or reviewing your integration code
- Reconstructing events missed while your endpoint was down
- Guarantees about strategy results or about external systems acting on events
A delivery marked Delivered in the log means our side worked — from there, the trail continues in your systems. Requests outside the scope above are answered with a link back to this page.
Stuck on something within scope? Contact support with the event id — it identifies the exact delivery on our side.