Custom Rewards API
Reward your community in your own currency— game points, XP, roles, in-app credit — using a grabs.gg group contest's scoring. Your members do the tasks in Telegram; your program decides what they get.
Turn it on in your contest settings under Custom rewards, where you'll also find this group's API key and signing secret.
Two ways to use it
Both deliver the same payload — pick whichever fits how your system already works. You can use both.
- Pull — you call us whenever you want. Simplest to build; you control the timing.
- Push — we call you once each period closes, signed. Nothing to schedule on your side; rewards land automatically.
Points → your score
Members earn points from the tasks you enabled. You set score per point, and we send score = points × that already computed, rounded to 4 decimal places.
So “1 score for every 10 points” is 0.1: a member on 120 points arrives as score: 12. Leave it at 1 to receive raw points.
The user field carries whichever identifier you chose — Telegram username or user ID. Both username and telegram_id are always included as well, so you can switch without a migration. Key on the ID if you can — usernames change.
Periods
A period is your contest's frequency: a day, a week, or a month. Boundaries are UTC midnight, weeks start Monday, and the end is exclusive — the same windows the bot uses to score and pay, so your numbers reconcile with its leaderboard.
period.label identifies the window (2026-07-25 for a day, the Monday for a week, 2026-07 for a month). It is stable — use it as your idempotency key.
Pull: GET the scores yourself
GET https://grabs.gg/api/rewards/v1/contest/<group_id>?period=last
Authorization: Bearer grw_…| Query | Meaning | |
|---|---|---|
| period=last | default | The most recently COMPLETED period. Final and stable — this is what you reward from. |
| period=current | The period in progress. Live standings for a dashboard; will keep changing. Do not reward from it. |
Rate limit: 60 requests per minute. Responses are never cached. Read-only — this endpoint cannot change anything about your contest.
const res = await fetch(
"https://grabs.gg/api/rewards/v1/contest/-1001234567890?period=last",
{ headers: { Authorization: `Bearer ${process.env.GRABS_API_KEY}` } }
);
const data = await res.json();
for (const p of data.participants) {
await grantScore(p.telegram_id, p.score);
}Push: we POST to your endpoint
Set your endpoint in the contest settings and we deliver the completed period's scores there — once per period, as JSON, with these headers:
| X-Grabs-Signature | string | sha256=<hex> — HMAC-SHA256 of the exact raw body, keyed with your signing secret. Verify this before you grant anything. |
| X-Grabs-Event | string | contest.period_completed |
| X-Grabs-Group | string | The Telegram group id. |
| X-Grabs-Period | string | Same value as period.label. |
Answer 2xx to acknowledge. Anything else — a 500, a timeout, an unreachable host, a redirect — is treated as not delivered and retried on the next hourly run, for as long as that period is the most recent completed one. A daily contest therefore gets up to ~23 more attempts; once the next period closes we move on to it. That means your endpoint must tolerate receiving the same period twice; deduplicate on group_id + period.label.
Your URL must be public https. We resolve it and refuse private/internal addresses, and we do not follow redirects — so point us at the final URL.
Payload
{
"event": "contest.period_completed",
"group_id": "-1001234567890",
"group_title": "My Community",
"frequency": "daily",
"period": {
"label": "2026-07-25",
"start": "2026-07-25T00:00:00.000Z",
"end": "2026-07-26T00:00:00.000Z"
},
"scoring": { "score_per_point": 0.1, "user_field": "username" },
"participant_count": 2,
"total_points": 195,
"participants": [
{
"user": "alice",
"telegram_id": 123456789,
"username": "alice",
"points": 120,
"tasks_done": 6,
"score": 12
},
{
"user": "bob",
"telegram_id": 987654321,
"username": "bob",
"points": 75,
"tasks_done": 4,
"score": 7.5
}
]
}| Field | Type | Notes |
|---|---|---|
| group_id | string | Telegram group id. Negative, sent as a string. |
| frequency | string | daily | weekly | monthly |
| period.label | string | Your idempotency key. |
| period.start / end | ISO 8601 | UTC; end is exclusive. |
| participants[].user | string | Your chosen identifier. Falls back to the id if a member has no username. |
| participants[].telegram_id | number | Always present, always stable. |
| participants[].points | number | Raw contest points for the period. |
| participants[].tasks_done | number | Distinct tasks the member completed. |
| participants[].score | number | points × score_per_point, 4dp. |
Sorted highest points first, capped at 1000 participants. Members below your contest's minimum points, and banned accounts, are already excluded.
Everyone who scored is included — unlike the on-chain payout, custom rewards have no wallet requirement, so members without a connected Solana wallet still reach you.
Verifying a delivery
Compute the HMAC over the raw request body and compare in constant time. Reject anything that doesn't match — the signature is what proves the request came from us.
import crypto from "node:crypto";
import express from "express";
const app = express();
// IMPORTANT: verify against the RAW body. Any re-serialisation (JSON.parse then
// JSON.stringify) changes bytes and the signature will not match.
app.post("/hooks/grabs", express.raw({ type: "application/json" }), (req, res) => {
const expected =
"sha256=" +
crypto.createHmac("sha256", process.env.GRABS_HOOK_SECRET).update(req.body).digest("hex");
const got = req.get("X-Grabs-Signature") || "";
const a = Buffer.from(expected);
const b = Buffer.from(got);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).end();
}
const payload = JSON.parse(req.body.toString("utf8"));
// period.label is your idempotency key — we retry hourly until you answer 2xx,
// so make granting the same label twice a no-op.
if (alreadyGranted(payload.group_id, payload.period.label)) return res.status(200).end();
for (const p of payload.participants) {
grantScore(p.user, p.score); // your own currency, your own rules
}
markGranted(payload.group_id, payload.period.label);
res.status(200).end(); // anything else = we retry on the next hourly tick
});Keys and security
- You get two values: an API key (for pulling) and a signing secret (for verifying pushes). They are independent — one cannot be derived from the other.
- Both are scoped to a single group and are read-only. Nothing about your contest, your prize wallet, or your funds can be changed through this API.
- Treat both as passwords: anyone holding the key can read your contest's participant list and scores. Keep them server-side, never in client code.
- They're derived rather than stored, so your settings panel can always show them to you again — and there is nothing sitting in a database to be leaked.
Responses
| 200 | Success. | |
| 400 | bad_group_id | The group id isn't a valid Telegram id. |
| 401 | unauthorized | Missing or wrong key. Returned before any lookup, so it never reveals whether a group exists. |
| 404 | not_found | No contest for that group under this bot. |
| 429 | rate_limited | Over 60/min. Honour the Retry-After header. |
| 503 | unavailable | Temporary. Retry with backoff. |
Does this replace the SOL payout?
It doesn't have to. Custom rewards run alongside the on-chain payout: you can pay SOL or USDC andgrant your own currency from the same scoring. If you only want your own rewards, just leave the prize wallet unfunded — an unfunded contest doesn't pay out, and this API keeps working.
Questions or something not covered here? Ask in the group where you set the contest up.