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
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/958959Either /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
| Situation | What happens |
|---|---|
| Body will not parse, or carries no message | Acknowledged with 200 {"ok":true} and ignored |
Text does not start with /cards | Same — acknowledged, no reply |
| Fewer than two arguments | Usage message in the chat, still a 200 |
| A URL that yields no ID | Invalid event URL / Invalid player URL in the chat, still a 200 |
| Player ID not in the database | Player 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
getWebhookInfofor alast_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 inplayers. Open the player page and check the trailing number.