API reference
Base URL https://grabs.gg. Authenticate with Authorization: Bearer gp_<client_id>_<secret>. Every field below is derived from the running handler, not from a changelog.
/api/partner/connectNo authSend a member to the grabs consent screen
Validates your client id and return_to, then 307-redirects the member to the grabs-hosted consent screen at /partner/connect. It mints nothing: the code is only issued when the member presses Continue there. Navigate the browser to this URL — do not fetch it from your backend.
Rate limit: Bucket `partner-connect-ip:<ip>` — 60 requests per minute per client IP, fixed window, SHARED with the POST on the same path. Nobody bypasses it: there is no key to present on this route.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
client_idrequired | query | integer | Your numeric client id — the same digits embedded in your key (`gp_1_YOUR_SECRET` → 1). Must be finite and greater than 0. It is public. |
return_torequired | query | string (absolute URL) | Where the member lands afterwards. Must parse as a URL and its ORIGIN must be an exact entry in your allowed_origins; the path and query are yours to choose. |
state | query | string | Opaque value forwarded to the consent screen and appended to the final redirect. Never validated, never length-checked — put your own CSRF nonce here. |
Errors
| Status | error | When |
|---|---|---|
| 400 | missing client_id or return_to | client_id is absent, non-numeric, or <= 0, or return_to is empty. |
| 400 | invalid return_to | return_to does not parse as a URL. |
| 403 | return_to is not allow-listed for this client | The URL parsed, but its origin is not in your allowed_origins. Ask a grabs admin to add it — you cannot. |
| 404 | unknown or revoked client | No active partner_clients row for that client_id. |
| 429 | rate limited — try again shortly | The per-IP connect bucket filled. |
Worth knowing before you build
- Errors come back as text/html with the body `Partner connect error: <message>`, not JSON — a client that blindly JSON-parses a non-3xx response here will throw on the parse instead of reporting the real problem.
- A GET never mints a code, even for a member who is already signed in to grabs. It used to, which meant a plain link or an <img> handed a signed-in member's identity to the partner with nothing shown; the consent screen is now unconditional.
- `state` is echoed verbatim and checked by nobody. If you do not generate and verify your own nonce, your callback accepts a code delivered by anyone.
- The allow-list is compared on the ORIGIN of return_to, so one allow-listed origin covers every path and query you want to send members back to.
- The redirect target is built from the canonical public origin, not from the incoming request URL — behind a proxy the request URL is http://localhost:3100, which is where members actually got sent before this was fixed.
- The consent screen does not bounce signed-out members away: it polls for sign-in for five minutes and continues by itself, so a member who signs in mid-flow still completes without you re-issuing the link.
/api/partner/connectNo authMint the auth code when the member presses Continue
The form target of the grabs consent screen, and the only thing in the system that issues an auth code. It needs the member's grabs session cookie AND a same-site POST, so no partner and no third-party page can trigger it. Documented so you know what produced the `?code=` on your callback — you never call this yourself.
Rate limit: Same `partner-connect-ip:<ip>` bucket as the GET — 60/minute per IP across both methods, no bypass.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
client_idrequired | body | integer (form field) | Re-validated here, not trusted from the GET — this is the step that discloses identity. |
return_torequired | body | string (form field) | Re-validated against allowed_origins exactly as the GET does. |
state | body | string (form field) | Appended to the final redirect when present. |
Errors
| Status | error | When |
|---|---|---|
| 400 | invalid request | The body was not form data (`multipart/form-data` or `application/x-www-form-urlencoded`). |
| 400 | missing client_id or return_to | Same validation as the GET. |
| 400 | invalid return_to | return_to does not parse as a URL. |
| 401 | sign in to grabs.gg first | No valid grabs session cookie on the request. |
| 403 | Cross-site request blocked | The POST did not come from grabs.gg itself. |
| 403 | return_to is not allow-listed for this client | Origin not in allowed_origins. |
| 404 | unknown or revoked client | No active client for that id. |
| 429 | rate limited — try again shortly | The per-IP connect bucket filled. |
Worth knowing before you build
- Success is a 303 (not 302) to `<return_to>?code=…&state=…`, chosen so the browser follows with a GET and does not re-POST the member's form into your callback.
- The code lives about 90 seconds and is single-use: exchange it on the first request that hits your callback, and do not retry the exchange after a network error without expecting `invalid_or_expired_code`.
- The code arrives in your callback's query string, so it lands in your access logs, your Referer headers and the member's history. Treat it as a credential: exchange it immediately, then never persist it.
- You cannot host this form. A cross-site POST is rejected before anything else runs, so the consent screen is the only possible submitter.
/api/partner/connect/infoNo authResolve who a connect link is really for, from the server rather than the link
Public branding for the consent screen: the partner's display name and logo, looked up by client_id in the database and returned only after the link's return_to passes the same allow-list check the mint enforces. grabs' own consent page calls this; it is documented because its 403 is the reason a member sometimes sees an unbranded refusal instead of your name.
Rate limit: Bucket `partner-info-ip:<ip>` — 60 requests per minute per IP, fixed window, no bypass. It exists so client ids cannot be enumerated at speed, not because the data is secret.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
client_idrequired | query | integer | Must be finite and > 0. The name and logo are keyed off THIS, never off return_to. |
return_torequired | query | string (absolute URL) | Checked against the client's allowed_origins before anything is echoed back. |
Response
{
"ok": true,
"name": "Example Partner",
"logoUrl": "/api/uploads/contest-art/examplepartner-1a2b3c4d5e6f7a8b.png",
"host": "app.example.com"
}Errors
| Status | error | When |
|---|---|---|
| 400 | bad_params | client_id missing/non-positive, or return_to missing or unparseable. |
| 403 | return_to_not_allowed | The link names a real partner but points at an origin that partner never registered. Deliberately distinct from unknown_client — this is the tampered-link case. |
| 404 | unknown_client | No active client with that id. |
| 429 | rate_limited | Per-IP info bucket filled. This response also carries a `Retry-After` header. |
Worth knowing before you build
- The body shape here is `{ok, error}`, unlike the /v1 routes which return a bare `{error}` — a shared error parser across both surfaces will read `undefined` on one of them.
- `logoUrl` is null until the grabs owner has run the partner-logo migration and set one; it is re-sanitised on every read and only ever a grabs-hosted upload path, so an external image URL can never reach the consent screen.
- If this call 403s, the consent page refuses to show your name at all and tells the member the link was stopped. That is a mis-registered return_to on your side, not an outage.
- Any other failure (offline, 429) leaves the page unbranded but still functional — the mint endpoint is what enforces the rule, so an unbranded screen is not a security failure.
/api/partner/v1/tokenAPI keyExchange a consent code for an identity token
Turns the ~90-second code from the redirect into a 15-minute identity token plus the permanent, per-partner `subject` you should store against your own user record. Server-to-server only, so the token comes back in the response body and never touches a URL, a Referer or browser history.
Rate limit: Two fixed 60-second windows: `partner-token-ip:<ip>` at a flat 60 requests/minute, and `partner:<clientId>:token` at your provisioned rate_limit_per_min. Nothing bypasses the IP bucket on this route.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Authorizationrequired | header | string | `Bearer gp_1_YOUR_SECRET`. Any active key works — this route checks no scope. |
coderequired | body | string | The `code` query parameter grabs appended to your return_to. Opaque, single-use, bound to your client id. |
Response
{
"identity_token": "TOKEN_OPAQUE_BASE64URL_STRING",
"expires_in": 900,
"subject": "kQ2vR7nT4xB1mE8sW0zJ6yH3pL5aC9dF"
}Errors
| Status | error | When |
|---|---|---|
| 400 | bad_json | The request body was not parseable JSON. |
| 400 | code_required | `code` was absent, empty, or not a string. |
| 401 | invalid_or_revoked_key | No Bearer header at all, a malformed one, an unknown client id, a wrong secret, or a revoked client — this route cannot tell you which. |
| 401 | invalid_or_expired_code | The code was tampered with, has expired (~90s), was issued to a different client, or has ALREADY been exchanged. All four collapse to this one answer. |
| 429 | rate_limited | Any of the buckets described under `rateLimit` filled. The body carries `retryAfterSeconds`; only the pre-auth buckets also set a `Retry-After` header. |
| 500 | internal_error | An unhandled exception inside the handler. Retry; if it persists it is a grabs-side configuration problem, not a bad request. |
Worth knowing before you build
- This is the one route that never got the well-formed-key exemption the read and verify routes have, so code exchange is hard-capped at 60/minute per IP no matter what rate_limit_per_min you were sold — if your backend egresses through a single NAT address, that is your real ceiling on new connections.
- The 401 is always `invalid_or_revoked_key`, never `missing_bearer_key`: this route resolves the key itself instead of going through the shared auth helper, so a missing header and a wrong secret look identical.
- The single-use replay guard is an in-process map, so it is authoritative only while grabs runs one PM2 fork; under cluster mode a code would become replayable once per worker.
- There is no refresh token and no way to re-issue from an existing identity token. A member who idles more than 15 minutes between connecting and completing a task has to be sent back through the browser consent flow.
- `subject` is returned here as well as on verify, and it is the same value — bind it to your account at this step, before any task is attempted, rather than waiting for a first successful verify that may never come.
- `subject` is salted per client, so it is stable for one person on YOUR integration forever and is useless for comparing notes with another partner. It is not a Telegram id and cannot be turned back into one.
/api/partner/v1/contestsAPI keyPull the catalog of contests you can surface to your users
Every contest that can actually pay, each task annotated with `portable` so you know which ones your users can complete on your site. Reads no query string at all — there is no filtering, sorting, pagination or since-cursor.
Rate limit: Three buckets, all 60-second fixed windows. `partner-read-ip:<ip>` at 60/min is SKIPPED entirely when the Authorization header carries a key of the shape `gp_<digits>_<20+ chars>`, so a provisioned partner is never cut to 60. `partner-badkey-ip:<ip>` at 60/min counts only requests that presented a well-formed key and failed to authenticate. `partner:<clientId>:read` runs at your provisioned rate_limit_per_min and is SHARED with /v1/contests/{id} and /v1/tasks.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Authorizationrequired | header | string | `Bearer gp_1_YOUR_SECRET`. The key needs the `read` scope. |
Response
{
"contests": [
{
"id": "group-9",
"ongoing": false,
"payoutMode": "end",
"dailyRewardSol": 0,
"remainingPool": 0,
"title": "Summer Quest",
"description": "Community contest from Telegram group bot.",
"logoUrl": "/api/uploads/contest-art/summerquest-9f3c1a2b7d4e5f60.png",
"creatorWallet": "group:-1004321641356",
"creatorName": "Summer Collective",
"status": "active",
"externalPayer": false,
"totalPrizeSol": 12,
"totalPrizeUsd": 924,
"payoutType": "fair_split",
"durationDays": 14,
"startDate": "2026-07-20T00:00:00.000Z",
"endDate": "2026-08-03T00:00:00.000Z",
"indefinite": false,
"tasks": [
{
"taskId": "x_follow",
"enabled": true,
"label": "Follow on X",
"description": "Follow the project's account on X",
"category": "x",
"icon": "Twitter",
"basePoints": 5,
"frequency": "onetime",
"howTo": "Open the account and tap Follow, then come back and verify.",
"config": {
"x_handle": "@summerquest"
},
"portable": true
},
{
"taskId": "boost_group",
"enabled": true,
"label": "Boost the group",
"description": "Boost the project's Telegram group",
"category": "telegram",
"icon": "Send",
"basePoints": 20,
"frequency": "onetime",
"config": {
"group_link": "https://t.me/summerquest"
},
"portable": true
},
{
"taskId": "join_trendify",
"enabled": true,
"label": "Join a group",
"description": "Join the Telegram group set for this contest",
"category": "telegram",
"icon": "Send",
"basePoints": 10,
"frequency": "onetime",
"config": {
"group_link": "https://t.me/trendifygg"
},
"sponsor": true,
"portable": true
}
],
"participantCount": 312,
"socialLinks": {
"xHandle": "@summerquest",
"telegramGroup": "https://t.me/summerquest"
},
"featured": false,
"siteJoinEnabled": false
}
]
}Errors
| Status | error | When |
|---|---|---|
| 401 | missing_bearer_key | No `Authorization` header, or one that is not `Bearer <key>`. |
| 401 | invalid_or_revoked_key | The key parsed but did not resolve to an active client: unknown client id, wrong secret half, a revoked client — or the partner tables are missing entirely. All four are indistinguishable by design. |
| 403 | insufficient_scope | The key is valid but was not provisioned with `read`. |
| 429 | rate_limited | Any of the buckets described under `rateLimit` filled. The body carries `retryAfterSeconds`; only the pre-auth buckets also set a `Retry-After` header. |
| 500 | internal_error | An unhandled exception inside the handler. Retry; if it persists it is a grabs-side configuration problem, not a bad request. |
Worth knowing before you build
- The per-client read budget is shared across all three read endpoints, so polling contest detail spends the same allowance as listing the catalog — size your poll interval against the sum, not per endpoint.
- A database failure answers 200 with `{"contests": []}`, not a 500. An empty array means 'nothing payable OR nothing readable', so never take it as proof a contest was deleted and never prune your local state on it.
- Only contests that can actually pay are listed — a contest that drops out because its pool ran dry is still fetchable by id, so a link you already gave your users keeps working.
- `portable` here is the CREATOR-CONTEST answer, which is not the answer /v1/tasks gives for the same task id. Filter on `enabled && portable` from this payload, never by joining against the library.
- `endDate` is the empty string — not null, not a synthesised date — whenever `indefinite` is true. Parsing it as a date yields Invalid Date, so branch on `indefinite` first.
- `creatorWallet` is masked: `wallet:AbCd…WxYz` for a site contest, `group:<telegram group id>` for a Telegram-group contest. It is a label, never an address you can send anything to.
- The list is capped at 200 contests and served from a 10-second cache, so polling faster than that returns identical bytes and still costs you a request from the shared bucket.
- `customPoints` is declared on the contest-task type but this builder never sets it; the effective weight is `basePoints`, which already reflects the creator's own override when they set one.
- Error responses (401/403/500 and the pre-auth 429) deliberately carry no CORS headers, so a browser-side caller sees an opaque failure rather than the status. Call this from a backend.
/api/partner/v1/contests/{id}API keyFetch one contest with its leaderboard, telegram ids replaced by your subjects
The same contest shape as the list, plus up to 100 leaderboard rows. Every `tg:<id>` wallet is rewritten to the per-client `subject` before it leaves the server, so the rows line up with the subjects you stored at token exchange.
Rate limit: Identical to /v1/contests: pre-auth `partner-read-ip:<ip>` at 60/min (skipped for a well-formed key), `partner-badkey-ip:<ip>` at 60/min on auth failures only, and the SHARED `partner:<clientId>:read` bucket at your provisioned rate_limit_per_min.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Authorizationrequired | header | string | `Bearer gp_1_YOUR_SECRET`. The key needs the `read` scope. |
idrequired | path | string | The contest id string exactly as the list returned it — e.g. `group-9`, or `rys-official` for the official RefundYourSOL contest. Passed through with no coercion. |
Response
{
"contest": {
"id": "group-9",
"ongoing": false,
"payoutMode": "end",
"dailyRewardSol": 0,
"remainingPool": 0,
"title": "Summer Quest",
"description": "Community contest from Telegram group bot.",
"logoUrl": "/api/uploads/contest-art/summerquest-9f3c1a2b7d4e5f60.png",
"creatorWallet": "group:-1004321641356",
"creatorName": "Summer Collective",
"status": "active",
"externalPayer": false,
"totalPrizeSol": 12,
"totalPrizeUsd": 924,
"payoutType": "fair_split",
"durationDays": 14,
"startDate": "2026-07-20T00:00:00.000Z",
"endDate": "2026-08-03T00:00:00.000Z",
"indefinite": false,
"tasks": [
{
"taskId": "x_follow",
"enabled": true,
"label": "Follow on X",
"description": "Follow the project's account on X",
"category": "x",
"icon": "Twitter",
"basePoints": 5,
"frequency": "onetime",
"howTo": "Open the account and tap Follow, then come back and verify.",
"config": {
"x_handle": "@summerquest"
},
"portable": true
},
{
"taskId": "boost_group",
"enabled": true,
"label": "Boost the group",
"description": "Boost the project's Telegram group",
"category": "telegram",
"icon": "Send",
"basePoints": 20,
"frequency": "onetime",
"config": {
"group_link": "https://t.me/summerquest"
},
"portable": true
},
{
"taskId": "join_trendify",
"enabled": true,
"label": "Join a group",
"description": "Join the Telegram group set for this contest",
"category": "telegram",
"icon": "Send",
"basePoints": 10,
"frequency": "onetime",
"config": {
"group_link": "https://t.me/trendifygg"
},
"sponsor": true,
"portable": true
}
],
"participantCount": 312,
"socialLinks": {
"xHandle": "@summerquest",
"telegramGroup": "https://t.me/summerquest"
},
"featured": false,
"siteJoinEnabled": false
},
"leaderboard": [
{
"rank": 1,
"wallet": "kQ2vR7nT4xB1mE8sW0zJ6yH3pL5aC9dF",
"displayName": "@someone",
"points": 420,
"tasksCompleted": 7,
"payoutMode": "fair_split"
},
{
"rank": 2,
"wallet": "pR9tY2wQ6vN4kM8xL1zA5cB3dF7gH0jS",
"displayName": "User …1234",
"points": 385,
"tasksCompleted": 6,
"payoutMode": "fair_split"
}
]
}Errors
| Status | error | When |
|---|---|---|
| 401 | missing_bearer_key | No `Authorization` header, or one that is not `Bearer <key>`. |
| 401 | invalid_or_revoked_key | The key parsed but did not resolve to an active client: unknown client id, wrong secret half, a revoked client — or the partner tables are missing entirely. All four are indistinguishable by design. |
| 403 | insufficient_scope | The key is valid but lacks `read`. |
| 404 | not_found | No contest with that id — OR the underlying contest query failed. A DB blip degrades to 404 here rather than 500. |
| 429 | rate_limited | Any of the buckets described under `rateLimit` filled. The body carries `retryAfterSeconds`; only the pre-auth buckets also set a `Retry-After` header. |
| 500 | internal_error | An unhandled exception inside the handler. Retry; if it persists it is a grabs-side configuration problem, not a bad request. |
Worth knowing before you build
- `leaderboard[].wallet` is not homogeneous: only `tg:<id>` rows are rewritten to a subject. A site contest carries a real Solana address in that field and it is passed through untouched, because masking it would corrupt a payout-relevant value. Never assume the field is a subject before checking it against one you issued.
- `displayName` is NOT anonymised — it carries the member's public Telegram @username, or `User …1234` when they have none. The numeric id is masked and the username is not, so treat this field as personal data even though `wallet` is opaque.
- The board is capped at 100 rows with no pagination and no lookup by subject, so a contest with more than 100 participants gives you no way to find your own user below the cut.
- A 404 is not proof the contest is gone: an unreachable database degrades to the same answer. Retry before you delete anything.
- This route deliberately skips the can-it-pay filter the list applies, so an unfunded-but-active contest 404s from the catalog yet still resolves here — direct links you have already sent keep working.
- `estimatedPayout` appears only on the official RefundYourSOL contest's snapshot path; for every creator contest the field is simply absent, so code that requires it will read undefined.
- Served from a 15-second cache, so a member's own completion will not appear on the board immediately after your verify call returns.
- Per-user task progress is never included — the internal payload carries a third field for it and the partner surface drops it, because personal progress cannot ride a shared cache.
/api/partner/v1/tasksAPI keyRead the whole task library so you can render a task the way grabs does
Every task grabs knows about — label, description, icon, category, base points, cadence, how-to and the input fields a creator fills in — plus the fixed category list for grouping them. Reads no query string; the entire library comes back in one response.
Rate limit: Same three buckets as the other read routes: pre-auth `partner-read-ip:<ip>` at 60/min (skipped for a well-formed key), `partner-badkey-ip:<ip>` at 60/min on failures, and the SHARED `partner:<clientId>:read` bucket at your provisioned rate_limit_per_min.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Authorizationrequired | header | string | `Bearer gp_1_YOUR_SECRET`. The key needs the `read` scope. |
Response
{
"tasks": [
{
"id": "x_follow",
"label": "Follow on X",
"description": "Follow the project's account on X",
"category": "x",
"icon": "Twitter",
"basePoints": 5,
"frequency": "onetime",
"howTo": "Open the account and tap Follow, then come back and verify.",
"inputs": [
{
"key": "x_handle",
"label": "X handle",
"type": "x_handle",
"placeholder": "e.g. @grabsgg",
"helperText": "The account members must follow.",
"required": true
}
],
"scoring": "ready",
"displayWeight": 10,
"portable": true
},
{
"id": "boost_group",
"label": "Boost the group",
"description": "Boost the project's Telegram group",
"category": "telegram",
"icon": "Send",
"basePoints": 20,
"frequency": "onetime",
"displayWeight": 40,
"portable": false,
"portableInContest": true
},
{
"id": "stryke_waitlist",
"label": "Join the Stryke waitlist",
"description": "Sign up for the Stryke waitlist",
"category": "engagement",
"icon": "Flame",
"basePoints": 15,
"frequency": "onetime",
"creatorOfferable": false,
"creatorUnofferableReason": "A fixed partner signup, not the creator's.",
"displayWeight": 9999,
"portable": true
}
],
"categories": [
{
"id": "x",
"label": "X / Twitter",
"icon": "Twitter",
"color": "#1d9bf0"
},
{
"id": "telegram",
"label": "Telegram",
"icon": "Send",
"color": "#26a5e4"
}
]
}Errors
| Status | error | When |
|---|---|---|
| 401 | missing_bearer_key | No `Authorization` header, or one that is not `Bearer <key>`. |
| 401 | invalid_or_revoked_key | The key parsed but did not resolve to an active client: unknown client id, wrong secret half, a revoked client — or the partner tables are missing entirely. All four are indistinguishable by design. |
| 403 | insufficient_scope | The key is valid but lacks `read`. |
| 429 | rate_limited | Any of the buckets described under `rateLimit` filled. The body carries `retryAfterSeconds`; only the pre-auth buckets also set a `Retry-After` header. |
| 500 | internal_error | An unhandled exception inside the handler. Retry; if it persists it is a grabs-side configuration problem, not a bad request. |
Worth knowing before you build
- `portable` here is the CONTEXT-FREE answer and it disagrees with a contest in both directions. `boost_group` and `tg_story` read `portable: false` yet verify fine inside a contest — they carry `portableInContest: true` to say so. In the other direction `x_like`, `x_retweet`, `x_follow_cheetah`, `premium_emoji_status`, `wallet_autoclean`, `join_strykegg`, `stryke_waitlist`, `trendify_waitlist`, `join_trendify_community`, `join_trendify_flash`, `hold_rys` and `stake_rys` read `portable: true` here and are refused with `409 not_portable` the moment you send a `contest_id`.
- Because of that, this library is for RENDERING tasks, not for deciding what to submit. Take the task list you actually verify against from the contest payload.
- `basePoints` here is the global weight; a creator can override it per contest, so a task shown from the library may be worth a different number of points in the contest your user is actually in. Prefer the contest's value when you have one.
- Tasks worth 0 points are skipped entirely rather than returned with a zero, so an id you know exists can be legitimately absent.
- `creatorOfferable: false` tasks are still in the payload on purpose — they are real, they are scored, they are just never sold to a new creator. Do not treat the flag as 'deprecated'.
- `frequency` falls back to `onetime` for any spelling the server does not recognise, so an unfamiliar cadence never silently becomes a recurring one.
- The array is pre-sorted by `displayWeight` then label, and served from a 60-second cache. There is no pagination and no filtering.
- `howTo` is dropped whenever the only copy available names RefundYourSOL, because a branded instruction on someone else's contest is worse than none — so an absent `howTo` is expected, not an error.
/api/partner/v1/verifyAPI key + identity tokenSubmit one of your users' completions for grabs to verify and award
Runs the same native verifiers grabs runs on its own site for the member behind an identity token, and delegates the award to the bot's gated award engine. grabs never writes points directly and a partner can never award grabs points itself.
Rate limit: Three 60-second fixed windows: `partner-verify-ip:<ip>` at 60/min, SKIPPED when the Authorization header carries a well-formed key; `partner-badkey-ip:<ip>` at 60/min counted only on authentication failures; and `partner:<clientId>:verify` at your provisioned rate_limit_per_min. The verify bucket is separate from the shared read bucket, so catalog polling does not eat your verify budget.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Authorizationrequired | header | string | `Bearer gp_1_YOUR_SECRET`. The key needs the `verify` scope. |
task_idrequired | body | string | A task id from the catalog. Only checked for portability in the derived contest context — it is not validated against any list, so a typo reads as a non-portable task. |
identity_tokenrequired | body | string | The token from /v1/token. Bound to your client id and valid 15 minutes. |
contest_id | body | string | number | The contest key: `"group-5"`, `"5"` or `5` all work. Anything that does not match `^(?:group-)?\d+$` (or a finite number > 0) resolves to null and the call proceeds as if you had omitted it. |
extra | body | object | Free-form artifact bag handed to the verifier (post URLs, screenshots, codes). Anything that is not an object is silently replaced with `{}`. |
Response
{
"verified": true,
"already_completed": false,
"message": "Verified — you're following on X.",
"detail": {
"username": "someone",
"targetUsername": "summerquest"
},
"subject": "kQ2vR7nT4xB1mE8sW0zJ6yH3pL5aC9dF"
}Errors
| Status | error | When |
|---|---|---|
| 400 | bad_json | The body was not parseable JSON. |
| 400 | task_id_and_identity_token_required | Either field is missing, empty, or not a string. |
| 401 | missing_bearer_key | No `Authorization` header, or one that is not `Bearer <key>`. |
| 401 | invalid_or_revoked_key | The key parsed but did not resolve to an active client: unknown client id, wrong secret half, a revoked client — or the partner tables are missing entirely. All four are indistinguishable by design. |
| 401 | invalid_identity_token | The token expired (>15 min), was tampered with, or was issued to a DIFFERENT client. Indistinguishable on purpose. |
| 403 | insufficient_scope | The key is valid but lacks `verify`. |
| 409 | not_portable | The task is not server-verifiable in the derived context. The body also carries `message: "This task type can only be completed in the Telegram bot."` |
| 413 | image_too_large | `extra.image_base64` is longer than 8,000,000 characters. |
| 429 | rate_limited | Any of the buckets described under `rateLimit` filled. The body carries `retryAfterSeconds`; only the pre-auth buckets also set a `Retry-After` header. |
| 500 | internal_error | An unhandled exception inside the handler. Retry; if it persists it is a grabs-side configuration problem, not a bad request. |
Worth knowing before you build
- HTTP 200 does not mean the task passed. A rejection is a 200 with `verified: false` and a human message — the non-2xx codes are for malformed or unauthorised REQUESTS, not for failed verifications.
- `already_completed: true` means 'yes, and they were already credited' — zero points were awarded for this call. If you award your own points on `verified: true` you MUST gate on `already_completed === false`, or every retry double-credits your ledger while grabs awards nothing.
- `points_awarded` exists internally and is deliberately not returned, so you cannot learn what a completion was worth. Do not derive your own award amount from this response.
- The catalog can hand you a contest id this endpoint cannot parse: `/v1/contests` returns `rys-official`, which fails the `^(?:group-)?\d+$` test and silently resolves to null. That is not an error — the call proceeds as an uncontested verify, against a different portability set, with the award filed groupless. Guard the round-trip yourself.
- Omitting `contest_id` is meaningful, never neutral: it flips the portability answer AND files the award with no contest group, so it lands on the global ledger and is invisible to every contest leaderboard.
- Check order matters when you are debugging: body parse, then the size cap, then the identity token, then portability. A stale token on a non-portable task answers 401, not 409 — fix the token before concluding the task is unsupported.
- If the grabs server's upstream award key is unset, a genuinely successful verification comes back as `verified: false` with `message: "Task verification not configured"`. That is a grabs-side outage, not your user failing the task; it is safe to retry because the award endpoint dedups.
- The `verified: false` reasons are free English prose, not stable codes. Two of them — 'We couldn't load this contest just now' and 'We couldn't read this task's settings for this contest' — are retryable transients that must not be recorded as a failed attempt, and today the only way to tell them apart is to match on the text.
- `detail` is verifier-specific and absent entirely for many verifiers, which is exactly why `subject` is returned at the top level. Never key bookkeeping off anything inside `detail`.
- A non-object `extra` is coerced to `{}` without complaint, so sending your artifact as a JSON string loses it and the verifier fails for a reason that looks nothing like the real cause.
- There is no idempotency key. `already_completed` is the closest substitute and it only holds within the task's current frequency window — once a daily task's window resets, the same submission is a fresh completion again.
- Any active verify-scoped key may submit against any contest id: there is no association between a partner and a contest, and no per-key contest allow-list.
- Many portable tasks require the member to have linked X, Discord, Reddit or a wallet ON grabs.gg, and nothing exposes which identities a subject has linked. You discover it as a failed verify, sometimes with `detail.notLinked` and sometimes with no detail at all.
- There is no sandbox, dry-run flag or test key — every call is real against the live shared ledger. The only safe rehearsal is a deliberately non-portable task id, which is guaranteed to 409 without touching anything.
/api/partner/v1/tasks/customAPI keyDeclare a task of your own
Register an action that happens on YOUR platform — a daily visit, a referral, a purchase — so it can score inside a contest bound to you. grabs cannot check these and does not pretend to: they are ATTESTED, meaning you assert the member did it and we record that you said so. Every response carries `attested: true`, and members see them labelled that way.
Rate limit: Bucket `partner:<client_id>:tasks-custom`, your provisioned per-minute limit. A pre-auth per-IP wall applies to malformed keys.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
slugrequired | body | string | Your identifier for the task, unique within your account. Lowercase a-z0-9, dashes and underscores, 2-32 characters. The stored key becomes `p:<client_id>:<slug>`, which is why the length is bounded. |
labelrequired | body | string | What the member sees. Up to 120 characters. |
description | body | string | Optional longer explanation, truncated at 400 characters. |
pointsrequired | body | number | Points per completion. Must be greater than 0 and at most 1000 — the same ceiling a creator gets, so one attested call cannot outrank every verified task on the board. |
frequency | body | string | onetime (default), daily, bidaily, tridaily, weekly or per_action. An unrecognised value becomes onetime rather than something recurring. |
status | body | string | Send disabled or active WITH slug and nothing else to toggle an existing task instead of declaring one. |
Response
{
"slug": "visit-kaching",
"task_key": "p:1:visit-kaching",
"label": "Visit Kaching daily",
"description": "",
"points": 15,
"frequency": "daily",
"status": "active",
"attested": true,
"created": true
}Errors
| Status | error | When |
|---|---|---|
| 400 | invalid_slug | The slug is missing, too short or long, or contains anything outside a-z0-9, dash and underscore. |
| 400 | invalid_label | The label is empty or longer than 120 characters. |
| 400 | invalid_points | Points are missing, non-numeric, zero or negative. |
| 400 | points_too_high | Points exceed the per-task ceiling of 1000. |
| 400 | bad_json | The body was not valid JSON. |
| 404 | unknown_task | A status toggle named a slug you have not declared. |
| 401 | invalid_or_revoked_key | The key is unknown or revoked. |
| 403 | insufficient_scope | The key lacks the verify scope. |
| 429 | rate_limited | Your per-minute limit is exhausted. |
Worth knowing before you build
- Declaring is idempotent on (your client, slug), so it is safe to re-run your whole setup on every deploy. `created` tells you which happened — it is true only on the first declaration, and a re-declare returns 200 rather than 201.
- Re-declaring an existing slug with different points changes what FUTURE awards are worth. It does not retroactively change points already given, and no endpoint does.
- There is no delete, deliberately. Removing a task would orphan the ledger rows referencing its key and leave leaderboard entries nobody can explain to the member who earned them. Send status disabled instead; awards are then refused while history stays intact.
- GET on this path lists only YOUR tasks. The client id is taken from the key and never read from the request, so there is no way to name another partner's account.
/api/partner/v1/awardAPI key + identity tokenRecord an attested completion of your own task
Tell grabs that one of your users completed one of YOUR declared tasks. The points come from the declaration, never from this request. This is the counterpart to /v1/verify: verify is grabs checking something and being sure; award is you telling us something we cannot check.
Rate limit: Bucket `partner:<client_id>:award`, your provisioned per-minute limit.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
slugrequired | body | string | The slug you declared. Not the full p:<id>:<slug> key. |
identity_tokenrequired | body | string | The token identifying the member, from /v1/token. You never send a Telegram id. |
contest_idrequired | body | integer | The numeric contest row id. It must be a contest BOUND to your account, otherwise this is refused. |
Response
{
"awarded": true,
"already_awarded": false,
"points": 15,
"task_key": "p:1:visit-kaching",
"subject": "g4IPtv-r9CfLl06RMOmdjqCRe1qVmbM-",
"attested": true
}Errors
| Status | error | When |
|---|---|---|
| 400 | slug_contest_id_and_identity_token_required | Any of the three is missing, or contest_id is not a positive integer. |
| 400 | bad_json | The body was not valid JSON. |
| 400 | task_disabled | The task exists but you have disabled it. |
| 400 | award_failed | The write failed and was rolled back. Nothing was recorded; retrying is safe. |
| 401 | invalid_identity_token | The token is malformed, expired, or was issued to a different partner. |
| 403 | contest_not_yours | That contest is not bound to your account. This is the authorisation boundary — no key can award into a contest it does not own. |
| 403 | member_not_eligible | The member is banned from contests platform-wide. |
| 404 | unknown_task | You have not declared a task with that slug. |
| 429 | rate_limited | Your per-minute limit is exhausted. |
Worth knowing before you build
- awarded:false with already_awarded:true is a SUCCESS, not an error — it means this cadence window was already recorded. Treat it as a no-op. Treating it as a failure and retrying achieves nothing; treating it as a fresh award double-counts on your side.
- Awards are idempotent per cadence window, enforced by a unique database index rather than a read-then-write, so two concurrent calls cannot both land.
- Windows follow the platform's Europe/Berlin day boundary, matching every other cadence on grabs — NOT UTC, and not your server's timezone.
- Points are read from your declaration. There is no per-call amount, deliberately: accepting one would make every request a fresh opportunity to mint points.
- A contest must be bound to you before any of this works. That binding is set by grabs, not through the API — ask us to attach a contest to your client id.
- Attested points count on the leaderboard exactly like verified ones, which is why they are labelled. Do not present them to members as grabs-verified.