# grabs.gg > Task-based crypto contests. Members complete verifiable actions on X, Telegram, Discord and elsewhere; the best get paid in SOL or ETH. Integrators can run a contest on our site, inside a Telegram group, or headless in their own product via the partner API. Base URL: https://grabs.gg Auth: Authorization: Bearer gp__ Human docs: https://grabs.gg/developers ## Integration models ### Site contest You create a contest on grabs.gg and we escrow and pay the prize. - Funded by: The creator, into a grabs-controlled escrow, before the contest goes live. - Scored by: grabs — our verifiers check each task against the target the creator configured. - Paid by: grabs, automatically, in SOL or ETH. - Best for: A project that wants a campaign running today without writing code. - CANNOT: Pay out before the escrow deposit is confirmed on chain — an unfunded contest is not listed and its page 404s. - CANNOT: Award points for actions that happen on your own site. Only tasks in the grabs catalog score. ### Telegram group contest A contest inside your Telegram group, scored and paid by the Trendify bot. - Funded by: The group's own prize wallet, funded by the group owner. - Scored by: The Trendify bot, from activity it observes in the group. - Paid by: The Trendify bot, directly from the group wallet, in SOL or USDC. - Best for: A Telegram community rewarding its own members. - CANNOT: Be denominated in ETH. The bots settle these from a shared config field whose parser turns any ticker it does not recognise into SOL, so an ETH group contest would send SOL. SOL and USDC only. - CANNOT: Be paid by grabs — grabs never touches a group contest's money. - CANNOT: Score a member who has never spoken in the group; there is nothing to observe. ### Headless / partner-paid You render the contest in your own product, we verify the tasks, and you pay your own users. - Funded by: You. grabs escrows nothing and holds none of your money. - Scored by: grabs — you call our verify endpoint and we tell you whether the task was really done. - Paid by: You, in whatever asset you like, on your own schedule. - Best for: A platform with its own users, its own UI, and its own treasury. - CANNOT: Define your own tasks yet. Only ids from the grabs catalog verify; your own quests cannot score today. - CANNOT: Receive a user's Telegram id. You get a per-partner pseudonymous subject instead, so two partners cannot correlate the same person. - CANNOT: Read points totals from the verify response — it deliberately does not return points_awarded. ## Endpoints ### GET /api/partner/connect Send 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. Auth: none Rate limit: Bucket `partner-connect-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. - client_id (query, integer, required): 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_to (query, string (absolute URL), required): 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. - ERROR 400 missing client_id or return_to: client_id is absent, non-numeric, or <= 0, or return_to is empty. - ERROR 400 invalid return_to: return_to does not parse as a URL. - ERROR 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. - ERROR 404 unknown or revoked client: No active partner_clients row for that client_id. - ERROR 429 rate limited — try again shortly: The per-IP connect bucket filled. - NOTE: Errors come back as text/html with the body `Partner connect error: `, not JSON — a client that blindly JSON-parses a non-3xx response here will throw on the parse instead of reporting the real problem. - NOTE: 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 handed a signed-in member's identity to the partner with nothing shown; the consent screen is now unconditional. - NOTE: `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. - NOTE: 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. - NOTE: 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. - NOTE: 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. ### POST /api/partner/connect Mint 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. Auth: none Rate limit: Same `partner-connect-ip:` bucket as the GET — 60/minute per IP across both methods, no bypass. - client_id (body, integer (form field), required): Re-validated here, not trusted from the GET — this is the step that discloses identity. - return_to (body, string (form field), required): Re-validated against allowed_origins exactly as the GET does. - state (body, string (form field)): Appended to the final redirect when present. - ERROR 400 invalid request: The body was not form data (`multipart/form-data` or `application/x-www-form-urlencoded`). - ERROR 400 missing client_id or return_to: Same validation as the GET. - ERROR 400 invalid return_to: return_to does not parse as a URL. - ERROR 401 sign in to grabs.gg first: No valid grabs session cookie on the request. - ERROR 403 Cross-site request blocked: The POST did not come from grabs.gg itself. - ERROR 403 return_to is not allow-listed for this client: Origin not in allowed_origins. - ERROR 404 unknown or revoked client: No active client for that id. - ERROR 429 rate limited — try again shortly: The per-IP connect bucket filled. - NOTE: Success is a 303 (not 302) to `?code=…&state=…`, chosen so the browser follows with a GET and does not re-POST the member's form into your callback. - NOTE: 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`. - NOTE: 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. - NOTE: You cannot host this form. A cross-site POST is rejected before anything else runs, so the consent screen is the only possible submitter. ### GET /api/partner/connect/info Resolve 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. Auth: none Rate limit: Bucket `partner-info-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. - client_id (query, integer, required): Must be finite and > 0. The name and logo are keyed off THIS, never off return_to. - return_to (query, string (absolute URL), required): Checked against the client's allowed_origins before anything is echoed back. - ERROR 400 bad_params: client_id missing/non-positive, or return_to missing or unparseable. - ERROR 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. - ERROR 404 unknown_client: No active client with that id. - ERROR 429 rate_limited: Per-IP info bucket filled. This response also carries a `Retry-After` header. - NOTE: 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. - NOTE: `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. - NOTE: 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. - NOTE: 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. ### POST /api/partner/v1/token Exchange 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. Auth: api-key Rate limit: Two fixed 60-second windows: `partner-token-ip:` at a flat 60 requests/minute, and `partner::token` at your provisioned rate_limit_per_min. Nothing bypasses the IP bucket on this route. - Authorization (header, string, required): `Bearer gp_1_YOUR_SECRET`. Any active key works — this route checks no scope. - code (body, string, required): The `code` query parameter grabs appended to your return_to. Opaque, single-use, bound to your client id. - ERROR 400 bad_json: The request body was not parseable JSON. - ERROR 400 code_required: `code` was absent, empty, or not a string. - ERROR 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. - ERROR 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. - ERROR 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. - ERROR 500 internal_error: An unhandled exception inside the handler. Retry; if it persists it is a grabs-side configuration problem, not a bad request. - NOTE: 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. - NOTE: 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. - NOTE: 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. - NOTE: 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. - NOTE: `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. - NOTE: `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. ### GET /api/partner/v1/contests Pull 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. Auth: api-key Rate limit: Three buckets, all 60-second fixed windows. `partner-read-ip:` at 60/min is SKIPPED entirely when the Authorization header carries a key of the shape `gp__<20+ chars>`, so a provisioned partner is never cut to 60. `partner-badkey-ip:` at 60/min counts only requests that presented a well-formed key and failed to authenticate. `partner::read` runs at your provisioned rate_limit_per_min and is SHARED with /v1/contests/{id} and /v1/tasks. - Authorization (header, string, required): `Bearer gp_1_YOUR_SECRET`. The key needs the `read` scope. - ERROR 401 missing_bearer_key: No `Authorization` header, or one that is not `Bearer `. - ERROR 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. - ERROR 403 insufficient_scope: The key is valid but was not provisioned with `read`. - ERROR 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. - ERROR 500 internal_error: An unhandled exception inside the handler. Retry; if it persists it is a grabs-side configuration problem, not a bad request. - NOTE: 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. - NOTE: 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. - NOTE: 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. - NOTE: `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. - NOTE: `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. - NOTE: `creatorWallet` is masked: `wallet:AbCd…WxYz` for a site contest, `group:` for a Telegram-group contest. It is a label, never an address you can send anything to. - NOTE: 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. - NOTE: `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. - NOTE: 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. ### GET /api/partner/v1/contests/{id} Fetch 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:` 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. Auth: api-key Rate limit: Identical to /v1/contests: pre-auth `partner-read-ip:` at 60/min (skipped for a well-formed key), `partner-badkey-ip:` at 60/min on auth failures only, and the SHARED `partner::read` bucket at your provisioned rate_limit_per_min. - Authorization (header, string, required): `Bearer gp_1_YOUR_SECRET`. The key needs the `read` scope. - id (path, string, required): 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. - ERROR 401 missing_bearer_key: No `Authorization` header, or one that is not `Bearer `. - ERROR 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. - ERROR 403 insufficient_scope: The key is valid but lacks `read`. - ERROR 404 not_found: No contest with that id — OR the underlying contest query failed. A DB blip degrades to 404 here rather than 500. - ERROR 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. - ERROR 500 internal_error: An unhandled exception inside the handler. Retry; if it persists it is a grabs-side configuration problem, not a bad request. - NOTE: `leaderboard[].wallet` is not homogeneous: only `tg:` 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. - NOTE: `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. - NOTE: 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. - NOTE: A 404 is not proof the contest is gone: an unreachable database degrades to the same answer. Retry before you delete anything. - NOTE: 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. - NOTE: `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. - NOTE: Served from a 15-second cache, so a member's own completion will not appear on the board immediately after your verify call returns. - NOTE: 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. ### GET /api/partner/v1/tasks Read 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. Auth: api-key Rate limit: Same three buckets as the other read routes: pre-auth `partner-read-ip:` at 60/min (skipped for a well-formed key), `partner-badkey-ip:` at 60/min on failures, and the SHARED `partner::read` bucket at your provisioned rate_limit_per_min. - Authorization (header, string, required): `Bearer gp_1_YOUR_SECRET`. The key needs the `read` scope. - ERROR 401 missing_bearer_key: No `Authorization` header, or one that is not `Bearer `. - ERROR 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. - ERROR 403 insufficient_scope: The key is valid but lacks `read`. - ERROR 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. - ERROR 500 internal_error: An unhandled exception inside the handler. Retry; if it persists it is a grabs-side configuration problem, not a bad request. - NOTE: `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`. - NOTE: 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. - NOTE: `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. - NOTE: Tasks worth 0 points are skipped entirely rather than returned with a zero, so an id you know exists can be legitimately absent. - NOTE: `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'. - NOTE: `frequency` falls back to `onetime` for any spelling the server does not recognise, so an unfamiliar cadence never silently becomes a recurring one. - NOTE: The array is pre-sorted by `displayWeight` then label, and served from a 60-second cache. There is no pagination and no filtering. - NOTE: `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. ### POST /api/partner/v1/verify Submit 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. Auth: api-key+identity Rate limit: Three 60-second fixed windows: `partner-verify-ip:` at 60/min, SKIPPED when the Authorization header carries a well-formed key; `partner-badkey-ip:` at 60/min counted only on authentication failures; and `partner::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. - Authorization (header, string, required): `Bearer gp_1_YOUR_SECRET`. The key needs the `verify` scope. - task_id (body, string, required): 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_token (body, string, required): 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 `{}`. - ERROR 400 bad_json: The body was not parseable JSON. - ERROR 400 task_id_and_identity_token_required: Either field is missing, empty, or not a string. - ERROR 401 missing_bearer_key: No `Authorization` header, or one that is not `Bearer `. - ERROR 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. - ERROR 401 invalid_identity_token: The token expired (>15 min), was tampered with, or was issued to a DIFFERENT client. Indistinguishable on purpose. - ERROR 403 insufficient_scope: The key is valid but lacks `verify`. - ERROR 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."` - ERROR 413 image_too_large: `extra.image_base64` is longer than 8,000,000 characters. - ERROR 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. - ERROR 500 internal_error: An unhandled exception inside the handler. Retry; if it persists it is a grabs-side configuration problem, not a bad request. - NOTE: 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. - NOTE: `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. - NOTE: `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. - NOTE: 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. - NOTE: 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. - NOTE: 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. - NOTE: 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. - NOTE: 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. - NOTE: `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`. - NOTE: 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. - NOTE: 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. - NOTE: 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. - NOTE: 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. - NOTE: 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. ### POST /api/partner/v1/tasks/custom Declare 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. Auth: api-key Rate limit: Bucket `partner::tasks-custom`, your provisioned per-minute limit. A pre-auth per-IP wall applies to malformed keys. - slug (body, string, required): Your identifier for the task, unique within your account. Lowercase a-z0-9, dashes and underscores, 2-32 characters. The stored key becomes `p::`, which is why the length is bounded. - label (body, string, required): What the member sees. Up to 120 characters. - description (body, string): Optional longer explanation, truncated at 400 characters. - points (body, number, required): 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. - ERROR 400 invalid_slug: The slug is missing, too short or long, or contains anything outside a-z0-9, dash and underscore. - ERROR 400 invalid_label: The label is empty or longer than 120 characters. - ERROR 400 invalid_points: Points are missing, non-numeric, zero or negative. - ERROR 400 points_too_high: Points exceed the per-task ceiling of 1000. - ERROR 400 bad_json: The body was not valid JSON. - ERROR 404 unknown_task: A status toggle named a slug you have not declared. - ERROR 401 invalid_or_revoked_key: The key is unknown or revoked. - ERROR 403 insufficient_scope: The key lacks the verify scope. - ERROR 429 rate_limited: Your per-minute limit is exhausted. - NOTE: 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. - NOTE: 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. - NOTE: 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. - NOTE: 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. ### POST /api/partner/v1/award Record 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. Auth: api-key+identity Rate limit: Bucket `partner::award`, your provisioned per-minute limit. - slug (body, string, required): The slug you declared. Not the full p:: key. - identity_token (body, string, required): The token identifying the member, from /v1/token. You never send a Telegram id. - contest_id (body, integer, required): The numeric contest row id. It must be a contest BOUND to your account, otherwise this is refused. - ERROR 400 slug_contest_id_and_identity_token_required: Any of the three is missing, or contest_id is not a positive integer. - ERROR 400 bad_json: The body was not valid JSON. - ERROR 400 task_disabled: The task exists but you have disabled it. - ERROR 400 award_failed: The write failed and was rolled back. Nothing was recorded; retrying is safe. - ERROR 401 invalid_identity_token: The token is malformed, expired, or was issued to a different partner. - ERROR 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. - ERROR 403 member_not_eligible: The member is banned from contests platform-wide. - ERROR 404 unknown_task: You have not declared a task with that slug. - ERROR 429 rate_limited: Your per-minute limit is exhausted. - NOTE: 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. - NOTE: 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. - NOTE: Windows follow the platform's Europe/Berlin day boundary, matching every other cadence on grabs — NOT UTC, and not your server's timezone. - NOTE: 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. - NOTE: 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. - NOTE: 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. ## Tasks (mode as dispatched in a creator contest) - x_follow: "Follow on X" | category=x | basePoints=5 | frequency=onetime | mode=verify - likes: "Like raid posts" | category=x | basePoints=0.3 | frequency=onetime | mode=auto - retweets: "Repost raid posts" | category=x | basePoints=0.45 | frequency=onetime | mode=auto - comments: "Comment on raid posts" | category=x | basePoints=0.65 | frequency=onetime | mode=auto - bookmarks: "Bookmark raid posts" | category=x | basePoints=0.25 | frequency=onetime | mode=auto - x_post: "Post on X" | category=x | basePoints=15 | frequency=tridaily | mode=verify - x_thread: "Post an X thread" | category=x | basePoints=15 | frequency=weekly | mode=verify - x_post_engage: "Engage with project posts" | category=x | basePoints=5 | frequency=tridaily | mode=verify - x_engage_latest_post: "Engage with the latest post" | category=x | basePoints=5 | frequency=onetime | mode=verify - x_bio_link: "Add link to X bio" | category=x | basePoints=5 | frequency=daily | mode=verify - x_emoji_name: "Add emoji/ticker to X name" | category=x | basePoints=10 | frequency=daily | mode=verify - x_profile_picture: "Use the project X profile picture" | category=x | basePoints=15 | frequency=daily | mode=verify - join_x_community: "Join X Community" | category=x | basePoints=5 | frequency=onetime | mode=auto - chat_mssg: "Chat in the group" | category=telegram | basePoints=0.2 | frequency=daily | mode=bot - telegram_chat_streak: "Daily chat streak" | category=telegram | basePoints=2 | frequency=daily | mode=auto - daily_checkin: "Daily check-in" | category=telegram | basePoints=2.5 | frequency=daily | mode=auto - bio_link: "Add link to Telegram bio" | category=telegram | basePoints=5 | frequency=onetime | mode=verify - emoji_name: "Add emoji/ticker to name" | category=telegram | basePoints=10 | frequency=daily | mode=verify - profile_picture: "Use the project profile picture" | category=telegram | basePoints=15 | frequency=onetime | mode=verify - join_channel: "Join channel" | category=telegram | basePoints=5 | frequency=onetime | mode=verify - join_trendify: "Join a group" | category=telegram | basePoints=5 | frequency=onetime | mode=verify - boost_group: "Boost the group" | category=telegram | basePoints=5 | frequency=daily | mode=verify - tg_story: "Post a Telegram story" | category=telegram | basePoints=10 | frequency=bidaily | mode=verify - share_tg: "Share the group elsewhere" | category=telegram | basePoints=5 | frequency=daily | mode=auto - poll_answer: "Answer admin polls" | category=telegram | basePoints=1.5 | frequency=onetime | mode=unknown - join_discord: "Join Discord" | category=discord | basePoints=5 | frequency=onetime | mode=auto - discord_chat_mssg: "Chat in Discord" | category=discord | basePoints=0.35 | frequency=daily | mode=auto - discord_name_tag: "Add tag to Discord name" | category=discord | basePoints=5 | frequency=daily | mode=verify - boost_discord: "Boost the Discord server" | category=discord | basePoints=10 | frequency=daily | mode=verify - raid_regular_points: "Raid participation" | category=raids | basePoints=1 | frequency=onetime | mode=auto - raid_regular_bonus: "Raid completion bonus" | category=raids | basePoints=2 | frequency=onetime | mode=auto - vote_dexscreener: "Vote on DexScreener" | category=votes | basePoints=5 | frequency=daily | mode=claim - vote_coingecko: "Vote on CoinGecko" | category=votes | basePoints=2 | frequency=daily | mode=claim - vote_cmc: "Vote on CoinMarketCap" | category=votes | basePoints=2 | frequency=daily | mode=claim - vote_jup: "Vote on Jupiter" | category=votes | basePoints=3 | frequency=onetime | mode=auto - vote_rugcheck: "Vote on RugCheck" | category=votes | basePoints=2 | frequency=onetime | mode=claim - medium_article: "Write a Medium article" | category=content | basePoints=10 | frequency=weekly | mode=verify - video_post: "Post a video (YouTube/TikTok)" | category=content | basePoints=20 | frequency=weekly | mode=verify - threads_post: "Post on Threads" | category=content | basePoints=7.5 | frequency=tridaily | mode=verify - reddit_post: "Post on Reddit" | category=content | basePoints=15 | frequency=tridaily | mode=verify - reddit_comment: "Comment on Reddit" | category=content | basePoints=2 | frequency=daily | mode=verify - ig_story: "Post an Instagram story" | category=content | basePoints=3.5 | frequency=bidaily | mode=verify - ig_follow: "Follow on Instagram" | category=content | basePoints=3 | frequency=onetime | mode=verify - yt_subscribe: "Subscribe on YouTube" | category=content | basePoints=5 | frequency=onetime | mode=verify - spaces_code: "Attend X Spaces (code)" | category=x | basePoints=15 | frequency=daily | mode=verify - hold_tokens: "Hold tokens (daily)" | category=wallet | basePoints=2 | frequency=daily | mode=unknown - whale_status: "Maintain whale status" | category=wallet | basePoints=5 | frequency=daily | mode=unknown - bot_referral: "Refer members to the bot" | category=referral | basePoints=5 | frequency=onetime | mode=auto ## Rules that are easy to get wrong - A 200 with verified:false is a normal answer, not an error. The task genuinely was not completed. - Honour already_completed. Treating it as a fresh success credits the same action twice. - points_awarded is deliberately never returned by verify. Read totals from the contest leaderboard. - You never receive a Telegram user id. You get a subject that is stable for YOU and different for every other partner. - A task's portable flag differs between the library and a creator contest. Check it per contest before offering it. - ETH is available for site contests only. Telegram group contests settle in SOL or USDC.