On this page
Cookbook
Small, complete, boring on purpose. Each recipe stands alone, follows every practice from the webhooks reference — acknowledge first, verify the signature, deduplicate on event_id — and is meant to be copied, then bent to your needs.
Store everything #
The foundation recipe. Whatever you're building, start by writing every event to disk verbatim — storage is cheap, and a full archive means any future idea can be tested against your own history. The schema makes correctness structural: the primary key is the dedupe key, so duplicate deliveries become no-ops instead of bugs.
CREATE TABLE events (
event_id TEXT PRIMARY KEY, -- dedupe happens here, structurally
type TEXT NOT NULL,
created_at TEXT NOT NULL, -- event_created_at, ISO 8601
payload TEXT NOT NULL -- the full JSON body, verbatim
);
CREATE TABLE picks (
pick_id INTEGER PRIMARY KEY,
strategy_id INTEGER,
match_id INTEGER,
created_at TEXT,
settled_at TEXT, -- NULL until pick.settled arrives
strike INTEGER -- NULL open · 1 hit · 0 miss
);
import json, sqlite3
def handle_event(raw_body: bytes):
event = json.loads(raw_body)
db = sqlite3.connect("inplayguru.db")
with db:
# 1. Archive the event. rowcount 0 means we've seen this event_id
# before — a retried delivery — and we stop here.
inserted = db.execute(
"INSERT OR IGNORE INTO events (event_id, type, created_at, payload)"
" VALUES (?, ?, ?, ?)",
(event["event_id"], event["type"],
event["event_created_at"], raw_body.decode()),
).rowcount
if not inserted:
return
pick = event["data"]["pick"]
# 2. Ensure the pick row exists no matter which event arrives first.
# pick.settled can beat pick.created to your door — a bare UPDATE
# would then update nothing and lose the result silently.
db.execute(
"INSERT OR IGNORE INTO picks (pick_id, strategy_id, match_id, created_at)"
" VALUES (?, ?, ?, ?)",
(pick["id"], event["data"]["strategy"]["id"],
event["data"]["match"]["id"], pick["created_at"]),
)
# 3. Settlement fills in the result.
if event["type"] == "pick.settled":
db.execute(
"UPDATE picks SET settled_at = ?, strike = ? WHERE pick_id = ?",
(event["event_created_at"], 1 if pick["strike"] else 0, pick["id"]),
)
What it teaches: dedupe as a primary-key property, and settlement handling that survives out-of-order arrival. Both lessons transfer to any database you actually use.
Discord notifier #
One embed per pick in a channel of your choice. Create a webhook in your Discord server (Channel settings → Integrations → Webhooks), put its URL and your InPlayGuru signing secret in the environment, and run:
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET = process.env.INPLAYGURU_WEBHOOK_SECRET;
const DISCORD_URL = process.env.DISCORD_WEBHOOK_URL;
const seen = new Set(); // in-memory dedupe — fine for one process
app.post('/inplayguru/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.get('X-InPlayGuru-Signature') || '';
const expected = crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
if (sig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
return res.sendStatus(401);
}
res.sendStatus(200); // acknowledge first, relay after
const event = JSON.parse(req.body);
if (seen.has(event.event_id)) return; // duplicate delivery
seen.add(event.event_id);
if (event.type !== 'pick.created') return;
const m = event.data.match;
fetch(DISCORD_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embeds: [{
title: `${m.home.name} v ${m.away.name}`,
description: `**${event.data.strategy.name}** fired at ${m.timer.min}' — score ${m.score.join('-')}`,
footer: { text: `Pick #${event.data.pick.id} · ${m.league.name}` },
color: 0x327e11,
}],
}),
}).catch(() => {}); // a lost embed is not worth a retry loop
});
app.listen(8080);
What it teaches: the full receiver discipline — signature, ack-first, dedupe — in the smallest program that does something fun. Swap the fetch call for Slack, Teams or anything else that accepts an inbound webhook.
Telegram notifier #
InPlayGuru already delivers native Telegram alerts — that's a core platform feature, and for most people the built-in pipeline is the answer. This recipe is for the rest: if you want alerts under your control — your own bot, your own formatting, your own routing rules — the Bot API takes one HTTP call.
Setup is two steps: create a bot with @BotFather and keep the token it gives you; then send your bot a message and read your chat_id from https://api.telegram.org/bot<TOKEN>/getUpdates.
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET = process.env.INPLAYGURU_WEBHOOK_SECRET;
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN; // from @BotFather
const CHAT_ID = process.env.TELEGRAM_CHAT_ID; // your user or group chat
const seen = new Set(); // in-memory dedupe — fine for one process
// Telegram HTML parse mode: real team names carry & and < — escape or the
// API answers 400 and your alert silently never arrives.
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<');
app.post('/inplayguru/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.get('X-InPlayGuru-Signature') || '';
const expected = crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
if (sig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
return res.sendStatus(401);
}
res.sendStatus(200); // acknowledge first, relay after
const event = JSON.parse(req.body);
if (seen.has(event.event_id)) return; // duplicate delivery
seen.add(event.event_id);
if (event.type !== 'pick.created') return;
const m = event.data.match;
const text = [
`<b>${esc(event.data.strategy.name)}</b> fired`,
`${esc(m.home.name)} v ${esc(m.away.name)}`,
`${m.timer.min}' — score ${m.score.join('-')} · ${esc(m.league.name)}`,
].join('\n');
fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: CHAT_ID, text, parse_mode: 'HTML' }),
}).catch(() => {}); // a lost alert is not worth a retry loop
});
app.listen(8080);
What it teaches: compare with the Discord recipe — only the final block changed. That's the shape of every notifier you'll ever build on these events. The escaping helper is the part people skip and regret: the first team name with an ampersand kills unescaped HTML-mode messages.
Google Sheets #
Route A — no code. Create a catch-hook trigger in Make, Zapier or n8n, paste its URL into the webhook settings page, and connect its "add a spreadsheet row" action. Map event_id, type, the team names, timer and score to columns. Add the platform's filter step for type = pick.created if you only want fired picks in the sheet.
Route B — through your own receiver. If you already run the storage recipe, append to Sheets from there with your language's Sheets client library, using the service-account flow. Your receiver stays the single endpoint; Sheets becomes one more consumer of events you've already verified, stored and deduplicated — which is exactly the architecture that scales to the next consumer after Sheets.
What it teaches: when a destination can't speak webhook properly, bridge it — don't bend your endpoint to it.