New features, changes & important service notices — announced first on our Telegram channel. Updates & important notices — first on Telegram.

Join @inplayguru_info
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.

schema.sql — SQLite (Postgres: swap INTEGER PRIMARY KEY for BIGINT, TEXT for JSONB where you like) SQL
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
);
writer.py — idempotent, order-tolerant Python
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:

discord.js — verify, dedupe, relay Node.js
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.

telegram.js — verify, dedupe, relay Node.js
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, '&amp;').replace(/</g, '&lt;');

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 #

Google Apps Script web apps don't work as a direct endpoint. Their /exec URLs answer POSTs with a 302 redirect, and our deliveries never follow redirects — every attempt would log as Rejected. Use one of the routes below instead.

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.