StatsHub Docs

Set up the Telegram cards bot

Register a bot with BotFather, point its webhook at /api/bot/telegram, and understand the one command it answers.

The cards bot answers exactly one command in a Telegram chat: /cards, given a fixture URL and a player URL, replies with the card model's fair odds for that player in that fixture. It is served by api at POST /api/bot/telegram, implemented in apps/statshub-api/internal/api/bot_telegram.go.

The webhook is unauthenticated

There is no secret-token check, in the original Next.js route or in the Go port. Anyone who can reach the URL can make the bot post into any chat it belongs to by naming that chat's ID in the payload. This was kept faithful to the original deliberately and is tracked separately — do not treat the endpoint as trusted.

Create the bot

Message @BotFather, send /newbot, and answer the two prompts (a display name and a username ending in bot). BotFather replies with an API token.

Give the API the token

apps/statshub-api/.env
TELEGRAM_BOT_TOKEN=123456789:AA...

Without it the endpoint logs bot/telegram: TELEGRAM_BOT_TOKEN not set and answers 500 Bot not configured.

Point Telegram at the webhook

curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
  -d "url=https://statshub.com/api/bot/telegram"

Telegram requires HTTPS. Confirm it took:

curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getWebhookInfo"

Add the bot to the chat

Add it to the group and allow it to read messages. Group privacy mode hides non-command messages from bots, which is fine here — /cards is a command.

Using it

Both arguments are StatsHub URLs, not names. The handler pulls the numeric IDs out of them with a regex; a name will not resolve.

/cards https://www.statshub.com/fixture/wolves-vs-arsenal/14025018 https://www.statshub.com/player/jurrien-timber/958959

Either /events/<id> or /fixture/<slug>/<id> works for the fixture. The player argument must be /player/<slug>/<id>.

The reply carries the model's fair odds for over 0.5 and over 1.5 cards, the expected card count, the player's and team's recent averages, and each bookmaker's price with its edge. It is built from GET /api/player/<id>/expected-cards?eventId=<id>, which the handler calls over HTTP rather than in process.

Behaviour worth knowing before you debug it

SituationWhat happens
Body will not parse, or carries no messageAcknowledged with 200 {"ok":true} and ignored
Text does not start with /cardsSame — acknowledged, no reply
Fewer than two argumentsUsage message in the chat, still a 200
A URL that yields no IDInvalid event URL / Invalid player URL in the chat, still a 200
Player ID not in the databasePlayer not found in the chat, still a 200

Every rejection is reported into the chat and still acknowledged, because a bad command is not a delivery failure and Telegram retries anything it does not get a 200 for.

One quirk exists for tests: a chat ID between -100000 and -10000 is treated as a harness rather than a real chat, and the formatted reply comes back in the HTTP response body instead of going out over the bot.

Troubleshooting

  • No reply at all. Check getWebhookInfo for a last_error_message, then the API logs. A 500 from the endpoint means Telegram will retry.
  • Usage message every time. The command needs two arguments separated by whitespace, both of them URLs.
  • Player not found. The ID parsed out of the player URL is not in players. Open the player page and check the trailing number.

On this page