API Reference
The Onsend API (v1) — REST endpoints for user identity, quest verification, leaderboards, campaigns, squads, and PvP, with API-key auth and scoped server-to-server writes.
Onsend's REST API for partner backends. It lets your server read your community data (leaderboard, campaigns, quests, squads, PvP) and drive gameplay server-to-server — creating and updating users, verifying quests, joining squads, and playing PvP — without a wallet-signed browser session.
The API is versioned at /api/v1/. Changes within v1 are additive
only — new endpoints, new optional parameters, new response fields.
Anything that would break an existing integration ships under /api/v2
instead, so a v1 integration keeps working unchanged. See
Changelog.
Base URL
Your project is identified by your API key (see Authentication), not by
the host, so you do not need a project-specific URL — use the base URL
above for every request. Your project's own subdomain
(https://acme.onsend.xyz/api/v1) works identically if you prefer it.
Quickstart
The five calls below are a complete integration test: create a user, discover campaigns and quests, verify a quest, and read the result back. Run them in order with a write-scoped key and you have proven your integration end-to-end.
1. Create (or update) a user — keyed by externalId, your own user
id:
2. List campaigns:
3. List quests, with that user's completion state:
4. Verify a quest for the user (use a quest id from step 3):
5. Read the result back — the user's profile and the leaderboard:
Authentication
Every request requires an API key. A project admin mints one in the
dashboard under Settings → API Keys. The full key (onsend_...) is
shown only once at creation — Onsend stores only a hash and cannot show
it again. If a key is lost, revoke it and mint a new one.
Keep the key server-side. Never ship it in frontend code. The key must only ever live on your backend (environment variable or secrets manager). Anyone holding it can read your community data and — if it has the
writescope — award XP to arbitrary users. Do not embed it in a browser bundle, mobile app, or public repository; if it ever leaks, revoke it immediately and mint a replacement.
Pass the key in either header:
or
If both are present, Authorization: Bearer is used.
A missing, invalid, revoked, or expired key returns 401 unauthorized —
the same opaque message in all four cases; the API does not reveal
which keys exist.
Project scoping
Your key is bound to exactly one Onsend project (yours). Every response
contains only your project's data, and there is no parameter that can
read or write another project's data. The optional campaignId
parameter is checked against your project and returns 404 if it isn't
yours. Every write resolves the acting user by externalId within
your project — an externalId never crosses project boundaries.
Scopes
API keys carry a list of scopes, chosen when the key is minted. v1 enforces two tiers:
read— every GET endpoint. Any valid (non-revoked, non-expired) key for your project can call every read endpoint.write— every POST endpoint (user upsert, quest verify, squad create/join/leave, PvP challenge create/accept). The key's scopes must includewrite. Minting a write-scoped key is an explicit choice at creation time — use a write key for the backend service that pushes data, and read-only keys everywhere else.
A read-only key calling a write endpoint gets 403 insufficient_scope:
Identity model
The API is built around one idea: externalId is your own user id,
and it is the only key the API ever upserts a user by. You
authenticate your users however you already do — your auth system, not
Onsend's. Onsend maintains the mapping from your externalId to an
Onsend user inside your project. Every write endpoint that acts "as a
user" takes an externalId in its request body; there is no
wallet-signature or session-cookie step in this flow.
Two lookup endpoints exist and take different identifiers — don't conflate them:
GET /api/v1/users/{identifier}looks a user up by wallet address.GET /api/v1/users/{externalId}/profilelooks a user up by yourexternalId.
Token-identity projects
Most projects key participants on the wallet, and everything above applies unchanged. A project can instead be configured to key identity on the NFT token id a wallet holds — for communities where the membership token itself is the account, and standing transfers with the token when it is sold.
If your project is configured this way, one rule changes for you:
externalIdmust be the token identity, in the formtoken:<tokenId>— for exampletoken:1234. Your own user id has no leaderboard identity in a token-identity project, so it cannot be credited.
Reward-granting endpoints reject a non-token externalId with 409
identity_mode_mismatch. Read endpoints are unaffected. Leaderboard
entries additionally carry a tokenId field (see below).
Nothing here affects wallet-keyed projects: the field is absent, the error never fires, and the payloads are unchanged. If you are not sure which mode your project uses, ask your Onsend contact — it is set by your project's owner and cannot be changed from the API.
Wallets
POST /api/v1/users can attach one or more wallets to a user in the
same call:
-
chainisEVMorSOLANA. -
EVM addresses are validated against
^0x[a-fA-F0-9]{40}$and stored lowercased. The primary EVM wallet becomes the user's wallet identity across Onsend — if that user later signs in with the same wallet on your public leaderboard site, they are recognized as the same person, with the same XP, rank, and quest history. -
SOLANA wallets are read for SPL-token holding quests (the
hold_token_solanaquest type, added in v1.2) and are stored and echoed back on profile reads. Onsend still does not:- verify a Solana signature (this is a server-to-server call — there is no signing step in this flow at all);
- run sanctions screening against a Solana address (screening runs
for
chain: "EVM"wallets only); - verify staking, LP, or NFT quests on Solana — only token holding is read. Every other on-chain quest type is EVM-only, and within EVM reads Ethereum mainnet and Base only.
Which Solana wallet gets read: the user's primary Solana wallet, falling back to the earliest attached one when none is marked primary. Note there is no automatic primary fallback on attach for Solana (see Primary resolution below), so most Solana wallets attached through this API have no primary flag — the earliest-attached rule is what makes the common case work. If a user holds the token in a different wallet than the one that gets read, the quest fails; mark the right one
"primary": true. Solana addresses are validated against the base58 alphabet (^[1-9A-HJ-NP-Za-km-z]{32,44}$) and stored verbatim — base58 is case-sensitive, so send the address exactly as your wallet stack reports it and don't lowercase it first. -
A malformed address (either chain) returns
400 validation_errorwith adetails.addressfield — note this is a different error code from the generic400 invalid_params. -
Primary resolution: an explicit
"primary": truewins; if two wallets on the same chain both setprimary: truein one call, the last one in the array wins. If no wallet is marked primary and the user has no existing primary EVM wallet, the first EVM wallet attached becomes primary automatically. (There is no such fallback for Solana — a primary Solana wallet is only ever set explicitly.) -
Conflicts: a wallet already attached to a different user in your project returns
409 wallet_conflict. There is no auto-merge in v1 — resolve the conflict on your side (e.g. ask the user which account is canonical) before retrying. -
Re-attaching a wallet the same user already owns is a no-op, not an error.
Social accounts
Every X (Twitter) quest type verifies against the user's linked X
account: the tweet's author id must equal the id stored on their Onsend
profile. A user your backend created has no linked account, so until they
link one, every twitter_* quest fails with "Twitter account not linked".
POST /api/v1/users/{externalId}/social/x
is how you link one server-to-server. You send a Privy identity token
for the user; Onsend verifies it and reads the X account out of it.
Why a token and not an id. There is deliberately no field for sending
us an X user id. A linked account is the anti-spoof basis of every X
quest — tweet.authorId === user.twitterId is the whole check — so an id
you could choose would let any member claim any account's posts. The
identity token is a signed statement from Privy that this member linked
that X account, and the signature is what we check.
What you need:
- Privy's identity token for the member (
privy-id-token), not the access token. The access token carries only the Privy user id and says nothing about linked accounts. - "Return user data in an identity token" enabled in your Privy dashboard — without it Privy mints no identity token at all.
- Your Privy app id registered with Onsend against your project,
so we can check the token's audience. Send it to support with your
Privy public verification key when you start integrating. Until your
project has one registered the endpoint returns
503 privy_not_configuredrather than accepting anything.
The app id is checked per project, not platform-wide. The audience
check answers "did your Privy app sign this token", so a token minted by
any other Privy app — including another Onsend project's — is rejected
with 401 invalid_identity_token. One consequence worth knowing: if you
run separate Privy apps for staging and production, register both, or
staging tokens will be refused.
One X account, one member. If the X account is already linked to a
different member of your project, the call returns
409 x_account_already_linked and nothing is written. The link is never
moved. Quest XP is awarded at link time, so moving a link while the XP
stays put would let one X account cycle across any number of members for
a full payout each. Resolve it on your side — unlink the old member, or
use a different X account.
Discord and Telegram linking have no v1 endpoint. Those flows are
first-party only (an OAuth redirect and a Telegram bot conversation
respectively), so discord_* and telegram_* quests remain unavailable
to users your backend created.
Referrals
Onsend's referral engine runs the same way whether people sign up on a hosted Onsend page or through your own frontend. Three steps:
- Show each user their code. Call
GET /api/v1/users/{externalId}/referraland render thecodebehind your share button, in a link shaped however you like —https://yourapp.com/join?invite=k7m2p9qr. - Pass it back at signup. When someone signs up from that link, send
the code as
referredByonPOST /api/v1/users. Check thereferralfield on the response to tell the new user whether their invite applied. - Let the rewards run. Nothing else to call. The referrer's reward lands when the person they invited completes their first quest, and a share of everything they earn after that follows automatically.
Reward amounts are set by your admins, not through the API. In the Onsend dashboard: Campaigns → your campaign → Settings → Referral rewards, where you set the joining bonus for the new user, the bonus for the referrer, and the referrer's ongoing share. Changes apply to activity from that point on; they don't re-price rewards already earned.
One difference from the hosted flow. On an Onsend-hosted page a referral link is remembered for 7 days, so someone can click today and sign up next week. Through the API there is no such window — the code arrives with the signup call, so holding onto it between the click and the signup is your side's job.
Idempotency and retries
POST /api/v1/users is idempotent by construction: it is an upsert
keyed on your externalId. Calling it twice with the same externalId
updates the same user; it never creates a duplicate. Two concurrent
first-time upserts for the same externalId are also safe — the API
resolves the race internally, so you don't need client-side locking.
The user is idempotent; the referral field is not. It only
appears on the call that actually creates the user. If you send
referredBy and the request times out on your end, a retry lands on the
update branch — the user already exists — and comes back with no
referral key at all, which looks identical to a call that never sent
referredBy in the first place. Treat a missing referral key on a
"created": false response as unknown, not as a failed invite —
specifically, don't re-message that user about a failed invite on the
strength of that alone.
POST /api/v1/quests/{id}/verify is safe to retry. You do not send
an idempotency key — the completion key is derived server-side from the
quest + user pair (and additionally per UTC day for daily-style quests
such as daily check-in). If your first attempt timed out on your end
but actually succeeded, or you simply retry after a 500, the retried
call returns the original result with "alreadyCompleted": true
rather than creating a second completion or erroring. This also means a
retry loop can never double-award a quest — the same quest for the same
user completes at most once per idempotency window.
Recommended retry policy for all writes: retry on network errors,
500, and 429 (after the indicated delay); treat 4xx other than
429 as permanent for that request.
Rate limits
Two limits apply:
- 60 requests per minute, per API key (the outer bound). Applies to every endpoint, read or write — both count against the same bucket. There is no separate read allowance.
- 30 requests per minute, per (API key,
externalId) on write endpoints (the inner bound). This stops one end user from burning the whole key's budget — a backend serving many users in parallel still gets the full per-key budget across its user base, but no single user can consume more than 30/min of it.
Raising the per-key limit
The 60/min figure is the default, not a ceiling. It is configurable per API key: your Onsend contact can raise an individual key's budget, and the new limit takes effect on that key's next request — no key rotation, no change on your side.
The inner 30/min per-user write limit is not scaled by a raised key budget, and that is deliberate: a higher key ceiling buys you more concurrent users, not a faster single user. If one user legitimately needs to write faster than 30/min, say so explicitly — it is a separate conversation.
Budgets are per key, so if you want hard isolation between, say, a backfill job and live traffic, ask for two keys rather than one large one. A backfill that exhausts its own key then can't 429 your users.
Both return the same 429 shape, with a Retry-After header giving
the number of seconds to wait:
Respect Retry-After (or the equivalent retryAfterSeconds field) and
retry after that delay. If you expect sustained traffic above these
limits, talk to your Onsend contact.
Referral attribution limits
Referral attribution through the API is not subject to the per-IP limit the hosted site applies — every call arrives from your server, so your server's IP tells us nothing about the person signing up. Two limits apply instead:
- The standard per-key and per-user limits above.
- A cap of 50 attributions per referrer per rolling 24 hours, per
project. Beyond it,
referredByreturns{ "attributed": false, "reason": "rate_limited" }and the user is still created. This stops a single code being farmed at API speed; a genuine top referrer will not reach it.
Response envelope
Successful responses wrap the payload in data:
Errors use a consistent shape:
Validation errors (400) additionally include a details object
describing the offending parameters. The 429 error additionally
includes retryAfterSeconds.
Status codes and error codes
| HTTP | error.code | Meaning |
|---|---|---|
| 200 | (none) | Success. |
| 400 | invalid_params | A query/body parameter is missing, malformed, or out of range (schema validation failure). |
| 400 | invalid_json | The request body is not valid JSON (fails to parse before schema validation runs). |
| 400 | validation_error | Semantic validation failure — e.g. a malformed wallet address (details.address). |
| 400 | invalid_user_input | On quest verify: userInput failed the quest type's own validation. |
| 400 | x_account_not_linked | X linking: the identity token verified, but the member has no X account linked in Privy. |
| 401 | unauthorized | Missing, invalid, revoked, or expired API key. |
| 401 | invalid_identity_token | X linking: the Privy identity token is missing, malformed, expired, or was issued for a Privy app other than the one registered for your project. |
| 402 | insufficient | PvP: not enough balance to cover the stake. |
| 403 | insufficient_scope | A read-scoped key called a write endpoint. Mint a write-scoped key. |
| 403 | wallet_sanctioned | The EVM wallet you tried to attach failed sanctions screening. |
| 403 | quest_not_active | The quest exists but isn't currently accepting verifications (paused/archived/scheduled). |
| 403 | disabled | PvP: the feature isn't enabled for your project. |
| 404 | not_found | Unknown user (by wallet or externalId), quest, campaign, squad, or PvP challenge. |
| 409 | wallet_conflict | The wallet is already attached to a different user in your project. |
| 409 | deletion_in_progress | This user has an active data-deletion request; reward-granting actions are blocked. |
| 409 | identity_mode_mismatch | Token-identity project: externalId must be token:<tokenId>. See Identity model. |
| 409 | completion_limit_reached | The user already hit the quest's per-user completion cap. |
| 409 | x_account_already_linked | X linking: that X account is already linked to a different member of your project. |
| 409 | privy_identity_mismatch | X linking: this member is already bound to a different Privy account. |
| 409 | privy_identity_in_use | X linking: this Privy account is already bound to a different member of your project. |
| 429 | rate_limited | Over the 60/min per-key or 30/min per-(key,user) budget. See Retry-After. |
| 500 | internal_error | Unexpected server error. Safe to retry. |
| 503 | privy_not_configured | X linking: your project has no Privy app registered with Onsend yet. Contact support. |
Squad-specific codes (same { "error": { "code", "message" } } shape,
no details):
| HTTP | error.code | Where | Meaning |
|---|---|---|---|
| 400 | invalid_size | POST /squads | minMembersForActive exceeds maxMembers. |
| 403 | closed | POST /squads/{slug}/join | Squad's join policy is CLOSED. |
| 403 | invalid_invite | POST /squads/{slug}/join | Missing or wrong inviteToken for an INVITE_ONLY squad. |
| 404 | not_found | any squad endpoint | Squad slug or campaign not found. |
| 404 | not_member | POST /squads/{slug}/leave | The user isn't a current member of the squad. |
| 409 | slug_taken | POST /squads | Slug already in use in your project. |
| 409 | already_in_squad | POST /squads, .../join | User must leave their current squad before joining/creating another. |
| 409 | full | POST /squads/{slug}/join | Squad is at maxMembers. |
| 409 | leader_cannot_leave | POST /squads/{slug}/leave | The leader must transfer leadership before leaving. |
| 410 | archived | POST /squads/{slug}/join | Squad is archived. |
| 422 | campaign_invalid | POST /squads | campaignId doesn't belong to your project or isn't squad-enabled. |
PvP-specific codes (the GET endpoints don't throw these; they apply to the two write endpoints):
| HTTP | error.code | Meaning |
|---|---|---|
| 400 | invalid_opponent | opponentExternalId resolves to an invalid/ineligible opponent. |
| 400 | max_stake_exceeded | stake exceeds your project's configured PvP stake ceiling. |
| 402 | insufficient | The user doesn't have enough balance to cover the stake. |
| 403 | banned | User is banned. |
| 403 | blocked | User is blocked by anti-abuse systems. |
| 403 | sybil_blocked | The user's PvP participation is gated by anti-abuse systems. |
| 403 | directed_disabled | Directed (opponent-targeted) challenges are off for your project. |
| 403 | not_owner | The user isn't the challenge's creator/acceptor for this action. |
| 403 | not_operator_permitted | Action not permitted for this key. |
| 403 | disabled | PvP isn't enabled for your project. |
| 404 | not_found | Challenge doesn't exist (or doesn't belong to your project). |
| 409 | already_taken | Challenge was already accepted/settled by someone else. |
| 409 | bad_state | Action not valid for the challenge's current status. |
| 429 | loss_limit_reached | User hit the configured daily PvP loss limit. |
| 429 | rate_limited | User hit the configured PvP plays-per-hour limit (distinct from the API's own 429). |
Example error bodies:
Privacy
Responses never contain email addresses or any other personal data
beyond what you supplied about your own user (e.g. the email you pass
to POST /api/v1/users is stored but never echoed back on read
endpoints). The public identifiers on read endpoints are the wallet
address (which may be null for users without one), the display name
(which may also be null), and — on the externalId-keyed profile
endpoint only — your own externalId, since you supplied it.
Bulk export and webhooks
Two things partners regularly ask for that v1 does not have:
- No CSV / bulk export endpoint. Every read endpoint is JSON and
paginated. CSV export exists only inside the Onsend admin dashboard
(waitlist entries, redeem-code batches, claim allocations) and is
session-authenticated — an API key cannot reach it. To pull a full
leaderboard, page
GET /api/v1/leaderboardwithcursoruntilnextCursorisnull. - No API-managed webhooks. Onsend can push outbound webhooks to your
endpoint (quest-completion style events, HMAC-signed, with automatic
retry and backoff), but subscriptions are created in the admin
dashboard under Settings → Webhooks, not through this API. There is no
/api/v1/webhooksendpoint to create, list or delete them. Ask your Onsend contact to set one up.
Endpoint reference
GET /api/v1/leaderboard
Read. Your project's ranked leaderboard.
Query parameters
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
limit | integer | no | 50 | Page size. Range 1 to 100. Out of range returns 400. |
cursor | string | no | (none) | Opaque pagination cursor from a previous response's nextCursor. |
window | enum | no | all | Timeframe: all, 7d, or 30d (project-wide). |
campaignId | string | no | (none) | Scope to one campaign's leaderboard. Only valid with window=all. |
Notes:
window=allranks participants by their project-wide total points and is always up to date.window=7dandwindow=30dare computed daily; their entries are empty until the first daily computation after the window's data exists.campaignIdreturns that campaign's all-time leaderboard. CombiningcampaignIdwithwindow=7dorwindow=30dreturns400.
Example request
Example response
On a token-identity project each entry additionally carries
tokenId, the membership token the standing belongs to:
There, tokenId — not wallet — is the stable key: the wallet changes
when the token is sold, and the standing follows the token. The field is
omitted entirely on wallet-keyed projects, so existing payloads are
unchanged. See Token-identity projects.
Pagination
Request the first page, then keep passing the returned nextCursor
back as ?cursor= until nextCursor is null.
GET /api/v1/users/{identifier}
Read. A single user's standing, looked up by wallet address
(case-insensitive). Returns 404 if no such wallet participates in
your project. (To look up by your own user id, use
GET /api/v1/users/{externalId}/profile instead.)
Query parameters
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
window | enum | no | all | Timeframe: all, 7d, or 30d. |
campaignId | string | no | (none) | Standing within one campaign. Only valid with window=all. |
Example request
Example response
GET /api/v1/stats
Read. Aggregate, privacy-safe stats for your project.
Example request
Example response
POST /api/v1/users
Write. Upsert a user by externalId, optionally attaching wallets
in the same call. This is the entry point for a headless integration —
call it once per user before verifying quests or joining squads on
their behalf.
Your project's identity comes solely from the API key; a tenantId or
project field in the body is ignored.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
externalId | string | yes | 1–128 chars. Your own user id. The only upsert key. |
email | string | no | Valid email, max 254 chars. Stored, never echoed back on reads. |
displayName | string | no | 1–80 chars. |
avatarUrl | string | no | Valid URL, max 2048 chars. |
wallets | array | no | Up to 10 { chain: "EVM" | "SOLANA", address: string, primary?: boolean } entries. See Wallets. |
referredBy | string | no | A referral code from GET /api/v1/users/{externalId}/referral. Honoured only when this call creates the user — ignored on an upsert of an existing user. Never fails the request. See Referrals. |
Example request
Example response
tier is "NONE" until the user earns enough XP to reach the first
tier.
Calling this again with the same externalId (e.g. to update
displayName) returns "created": false and does not touch fields you
omit.
Referral attribution
Pass referredBy to attribute a new user to the referrer who invited
them. Attribution happens once, at signup — on a repeat call for the
same externalId the field is ignored, because the user already exists.
A referral that can't be attributed never fails the user creation.
The user is created either way and the outcome comes back as a referral
object:
When attribution fails, attributed is false and reason names the
cause:
reason | What happened |
|---|---|
invalid_code | No such code in your project, or it belongs to a campaign that can't accept it. |
self_referral | The code belongs to this same user, or to another account on the same wallet. |
referee_blocked | The new user failed an anti-abuse check, or the check couldn't be completed (a transient failure on our side) — the user is still created either way. |
rate_limited | The referrer has hit their daily attribution cap (see Rate limits). |
already_attributed | This user was already attributed to a referrer. |
referrals_disabled | Referrals are switched off for the code's campaign. The user is still created; no referral is recorded, and none is paid if referrals are switched back on later. |
The referral field is absent entirely when you don't send
referredBy — existing integrations see no change. It is also absent
when you send referredBy on a call that only updates an existing user
(a retry, or a genuine repeat call) — attribution only ever runs on the
call that creates the user, so there is nothing to report back. See
Idempotency and retries.
Rewards (how much XP the referrer and the new user each earn) are not set through the API. Your admins configure them per campaign in the Onsend dashboard under Campaigns → your campaign → Settings → Referral rewards.
Notable errors
403 wallet_sanctioned— the EVM wallet failed sanctions screening. The user is not created or updated in this case.409 wallet_conflict— the wallet already belongs to a different user in your project.400 validation_error— malformed wallet address for the given chain.
GET /api/v1/users/{externalId}/profile
Read. A user's full profile, looked up by your externalId
(not wallet address). Returns 404 if no user with that externalId
exists in your project.
The
{identifier}path segment is shared with the wallet-lookup endpoint above, but on this/profilepath its value is always interpreted as yourexternalId— do not send a wallet address here.
Example request
Example response
squad is null if the user isn't currently in a squad.
GET /api/v1/users/{externalId}/referral
Read. The user's own referral code and stats — what you render behind
your share button. Looked up by your externalId (not wallet
address).
The code is created on first read, so you can call this for a brand-new user who hasn't completed anything yet. Each user has one code per campaign; this returns the code for your project's currently active campaign.
You build the share link. Onsend doesn't return a URL, because the
link lives on your domain with your own routing. Take code and hand it
back to us as referredBy when the invited user signs up.
A code belongs to the campaign it was returned for (campaignId in the
response) — if your project's active campaign changes between the time
you cache a code and the time it's used at signup, the referral lands on
the campaign the code came from, not whatever is active by then. If you
cache codes for any length of time, re-fetch them when your active
campaign changes.
Example request
Example response
| Field | Meaning |
|---|---|
totalReferrals | People who signed up with this code and passed anti-abuse checks. |
successfulReferrals | Of those, how many have earned their referrer a reward. |
pending | Signed up, but haven't completed their first quest yet. |
confirmed | Completed a quest — rewards have started flowing. |
totalXpFromReferrals | Total XP this code has earned its owner, signup bonuses and ongoing share combined. |
If your admins have switched referrals off for the active campaign, this
endpoint returns 404 with error code referrals_disabled and does not
create a code. Hide your share button when you get it.
Notable errors
404 not_found— no user with thatexternalIdin your project, or your project has no active campaign.
POST /api/v1/users/{externalId}/social/x
Write. Links a member's X (Twitter) account from a Privy identity
token, so twitter_* quests can verify for users your backend created.
Read Social accounts first — it covers what the token is, why we don't accept a raw X id, and the one-account-one-member rule.
{externalId} is your own id for the member, the same value you sent to
POST /api/v1/users. The member must already exist: this endpoint links
an account to a member, it does not create one. A member who doesn't
exist returns 404 not_found rather than being created, so a typo'd
externalId can't quietly mint a second, XP-less identity for someone
who already exists under the right id.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
identityToken | string | yes | The member's Privy identity token (privy-id-token), max 8192 chars. Not the access token. |
Example request
Example response
| Field | Notes |
|---|---|
twitterId | X's numeric user id, as Privy reported it. This is what quest verification matches a tweet's author against. |
twitterUsername | The handle without the @, lowercased — X handles are case-insensitive and Onsend stores one canonical form. May be null if Privy reported no handle. |
alreadyLinked | true when this exact account was already linked and nothing changed. |
Idempotency
Safe to retry. Re-sending a token for an account that is already linked
returns 200 with "alreadyLinked": true and writes nothing. Sending a
token for a different X account re-links the member to the new
account (that is the member changing their own mind, not a conflict) —
alreadyLinked is false and the previous account is released.
Re-call this endpoint when a member renames their X handle. The
stored handle is what Onsend queries for follow checks, so a rename that
never reaches us makes twitter_follow check the wrong handle for that
member. Same account id with a new handle counts as a change:
alreadyLinked comes back false and the handle is refreshed. A call
that carries no handle at all never clears one we already have.
Note that linking does not itself complete any quest. After a
successful link, call
POST /api/v1/quests/{id}/verify for each X
quest as usual.
Verification order
Checks run in this order, and each one short-circuits:
- API key valid →
401 unauthorized - Key carries the
writescope →403 insufficient_scope - Per-(key, member) write limit →
429 rate_limited - Body schema →
400 invalid_params/400 invalid_json - Token signature, issuer, audience and expiry →
401 invalid_identity_token - Token carries an X account →
400 x_account_not_linked - Member exists →
404 not_found - X account not held by another member →
409 x_account_already_linked
Send the token that belongs to the member in the path
This is the one correctness obligation that sits on your side. We verify
that the token's Privy user controls the X account it names. We cannot
verify that that Privy user is the member your externalId names — only
your own session→member mapping knows that.
We do, however, remember it. The first successful link records the token's Privy user against that member. From then on:
- a token from a different Privy account for that member is refused
with
409 privy_identity_mismatch, and - a Privy account already bound to another member is refused with
409 privy_identity_in_use.
So a mapping slip fails fast on the second call rather than quietly linking the wrong account. It is still worth getting right the first time: the very first link for a member is the one we cannot check.
Take the identity token from the same authenticated session you derive
the externalId from, and never from a client-supplied parameter. If a
member genuinely changes Privy accounts, contact support to clear the old
binding — we don't let a token move a binding by itself, for the same
reason we don't let one move an X account.
The rate limit is checked before the signature, so a flood of garbage tokens is rejected cheaply.
Notable errors
401 invalid_identity_token— one opaque code for every token failure: malformed, bad signature, expired, wrong issuer, or issued for a different Privy app. Deliberately undifferentiated on the wire; if you need the reason while integrating, ask support to read it from our logs.400 x_account_not_linked— the token is good but the member hasn't linked X in Privy. Have them link it on your side, then call again.409 x_account_already_linked— see Social accounts.503 privy_not_configured— your project has no Privy app registered with Onsend. Contact support; this is a one-time setup step, and it is per project rather than platform-wide.
GET /api/v1/campaigns
Read. Campaign discovery for your project.
Query parameters
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
status | enum | no | ACTIVE | ACTIVE, PAUSED, COMPLETED, or all. |
limit | integer | no | 50 | 1–100. |
cursor | string | no | (none) | Opaque pagination cursor from a prior response. |
Example request
Example response
GET /api/v1/quests
Read. Your project's active, visible quests, with optional per-user completion state.
Query parameters
| Param | Type | Required | Description |
|---|---|---|---|
campaignId | string | no | Narrow to one campaign. |
user | string | no | Your externalId. When set, each quest gets a completion field. When omitted, the completion field is absent from every quest object (not null) and no completion lookup runs. |
Example request
Example response
completion is null for a quest the user hasn't completed or
whose most recent completion has reset for a daily quest (e.g. a daily
check-in from a prior UTC day reads as available again).
actionUrl
actionUrl (string | null) is the off-platform URL where the user
performs the quest — the tweet to repost, the profile to follow, the
Discord invite to open. Use it to render your own "Go do this" button
instead of hard-coding links per quest.
It is always present on every quest object (unlike completion), and it
is null whenever the quest has no off-site target. Treat null as
normal, not an error.
Which connectors return a URL:
connectorId | actionUrl |
|---|---|
twitter_follow | the target profile, e.g. https://x.com/acme |
twitter_retweet | the tweet to repost |
twitter_quote | the tweet to quote |
twitter_reply | the tweet to reply to |
twitter_like | the tweet to like (connector deprecated) |
discord_join | the Discord invite, when the project configured one |
telegram_join_group | the t.me group link, when the group has a public handle |
telegram_join_channel | the t.me channel link, when the channel has one |
stake_token_weekly | the project's own staking dashboard, when configured |
| everything else | null |
null is returned for on-chain quests (hold_token, hold_token_sum,
hold_token_solana, stake_token, hold_token_weekly, nft_hold,
lp_provision, the Euclid connectors — the action happens in the user's wallet;
stake_token_weekly is the one exception, and only when the project
configured a dashboard link), for quests completed inside Onsend
(daily_checkin, quiz, mystery_box, redeem_code,
squad_collective, creator_application), for twitter_post (the user
writes their own tweet, so there is no target), for discord_role (the
user must already be a member), and for any quest whose optional link
simply isn't configured.
The value is derived from the quest's configuration and validated before
it is returned: only https URLs on the expected host for that connector
are emitted, and anything else collapses to null. It is still a
project-authored link, so render it as untrusted input — don't
auto-navigate to it.
Notable errors
404 not_found— theuserparam doesn't resolve to a knownexternalIdin your project.
POST /api/v1/quests/{id}/verify
Write. Trigger quest verification for one of your users, server-to-server — no wallet session involved.
Notes:
- There is no idempotency key to send — retries are safe by default; see Idempotency and retries.
- The request's source IP is your server's, not the end user's, so it is deliberately not used as an anti-abuse device signal. Existing per-user anti-abuse scoring still applies to the result.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
externalId | string | yes | 1–128 chars. The acting user. |
userInput | object | no | Quest-type-specific payload. Validated against that quest type's own schema — see the table below. Omit it entirely for quest types that take no input. |
What this endpoint is, and is not
For every quest type except one, it is a trigger, not an assertion.
Calling it asks Onsend to verify the quest for that user and award XP if
verification passes; it does not tell Onsend the quest is done. Those
types still run their own verification (read the chain, call the social
provider, check the redeem code), and a quest whose conditions aren't
met comes back "status": "FAILED" no matter who called it.
The exception is partner_action, which exists precisely for
actions on your product that Onsend has no way to see — a first trade,
a deposit, a volume milestone. There, your call is the assertion.
See partner_action below.
Which quest types you can drive server-to-server
The endpoint accepts any quest id in your project, but the quest types fall into three groups.
Fully drivable from your backend — no Onsend-side session, no external dependency:
connectorId | userInput | Notes |
|---|---|---|
partner_action | { "eventId": "…", "occurredAt": "…", "metadata": { … } } | The partner-asserted type. Your backend decides when it's done. See partner_action. |
daily_checkin | (none) | One completion per UTC day. See Recurring quests. |
redeem_code | { "code": "ABCD1234" } | Case/format normalised server-side. Codes are created admin-side (see below). A batch may also carry a standing multiplier, granted on redemption alongside the XP — the response's proof.multiplierGranted reports it. |
creator_application | { "contentUrls": ["https://…", "…"] } | 1–10 URLs; the quest's own minUrls may require more than one. |
Drivable, but gated on something the user must have done in Onsend first:
connectorId | userInput | Gate |
|---|---|---|
twitter_post | { "tweetUrl": "https://x.com/…/status/…" } | The user must have a linked X account on their Onsend profile, and the tweet's author must match it. Link it with POST /api/v1/users/{externalId}/social/x (v1.2). |
twitter_quote | { "quoteTweetUrl": "https://x.com/…/status/…" } | Same linked-account requirement. |
twitter_reply | { "replyTweetUrl": "https://x.com/…/status/…" } | Same linked-account requirement. |
hold_token, hold_token_sum, stake_token, nft_hold, lp_provision, stake_token_weekly, hold_token_weekly, the Euclid connectors | (none) | Verified by reading the chain against the user's attached EVM wallet. Attach wallets via POST /api/v1/users. Your assertion is irrelevant to the result. |
hold_token_solana | (none) | Verified by reading Solana mainnet against the user's attached SOLANA wallet (primary, else earliest attached). Attach it via POST /api/v1/users. See Wallets. |
As of v1.2, twitter_follow, twitter_like and twitter_retweet also
belong in this group: they take no userInput at all (verification reads
X against the linked account), so once the member's X account is linked
you can drive them with a bare
POST /api/v1/quests/{id}/verify carrying only externalId.
Not drivable from your backend — they are the verify step of a stateful flow whose earlier steps are first-party, wallet-session routes an API key cannot reach:
connectorId | Why |
|---|---|
quiz | userInput is { "sessionId": "…" }, and sessions are created and answered on wallet-authenticated routes. |
mystery_box | Opening a box is its own first-party route, which carries the per-open token and returns the prize reveal. |
squad_collective | Awarded by a resolver that runs on a schedule, not on demand. |
discord_join, discord_role, telegram_join_group, telegram_join_channel | Take no userInput at all, and verification reads the provider against the user's linked Discord / Telegram account — which has no v1 linking endpoint (see Social accounts). |
Recurring quests: daily and weekly
Recurring quest types are not separate quests per period. There is one quest id, and the completion key the server derives includes the period — so you call this endpoint again each period with the same quest id, and the response tells you whether that period is already banked.
-
daily_checkin— one completion per UTC day. The streak bonus stacks over consecutive days. Call once per user per UTC day; a second call the same UTC day returns200with"alreadyCompleted": trueand the original completion (no second award, no error). The first call after 00:00 UTC starts a new day. -
stake_token_weekly/hold_token_weekly— paid in arrears for the previous full ISO week (Monday 00:00 → Monday 00:00 UTC), and only for that week; a missed week is gone and cannot be back-claimed. The claim window opens ~10 minutes after Monday 00:00 UTC — calling inside that settling window returns"status": "FAILED"with a reason telling the user to retry shortly, not an error. Call once per user per week, any time during the week that follows the week being claimed; a second call for the same week returns"alreadyCompleted": true. -
partner_action— the period is whatever the project configured on the quest:none(once per member, ever),daily, orweekly. Same rule, same responses. See below for the detail.
GET /api/v1/quests?user=… is the companion read: a recurring quest's
completion reads back as null once the period rolls over, which is
your signal that it is available again.
Redeem codes themselves are not created through this API — there is
no POST /api/v1/redeem-codes. Batches are generated in the admin
dashboard (and exported there as CSV); your backend's role is to collect
the code from the user and pass it as userInput.code here.
partner_action: partner-asserted quests
partner_action is the quest type for actions on your product.
Onsend cannot see a trade, a deposit or a volume milestone, so for this
one type we don't try to: your backend tells us it happened, and we
award the quest's XP.
Ask your Onsend contact (or your own admin) to create a quest of type Partner Action on a campaign. The quest carries:
| Config | Meaning |
|---|---|
actionLabel | What the member sees, e.g. "Make your first trade". |
period | none (default — once per member, ever), daily, or weekly. |
maxCompletionsPerPeriod | How many completions are allowed inside one period. Default 1. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
userInput.eventId | string | yes | 1–128 chars. Your id for the thing that happened. The dedup unit — see below. |
userInput.occurredAt | string | yes | RFC 3339 / ISO 8601 timestamp with offset, e.g. 2026-10-13T12:00:00Z. |
userInput.metadata | object | no | Free-form context. Stored on the completion and visible to the project's admins. Bounded: at most 20 keys and 2,048 characters serialized — it is a record, not a data store. A call that would otherwise award and breaches either limit returns 400 invalid_user_input; keep the payload your side and send an id we can quote back. |
What makes this safe to trust
Because nothing on our side corroborates the award, the controls sit on the call itself:
- Only a write-scoped API key can assert. The key is the identity;
it is read from the
Authorizationheader, never from the body. A logged-in member cannot complete one of these from the website — they see "tracked for you" and no Verify button. - The key's project must own the quest. A quest id belonging to
another project returns
404 not_foundbefore anything runs. - Your key's rate limits apply, including 30 writes/min per
externalId, so a leaked key cannot be drained at speed. - Every new award writes an audit entry recording the key that
asserted it, your
eventIdand youroccurredAt, alongside the completion itself. Your project's admins can see it.
One deliberate non-feature: occurredAt is recorded, not enforced.
Nothing branches on it. If it decided which period an event counted
toward, backdating would become the cheapest way to farm a recurring
quest — so periods are measured against Onsend's clock at the moment we
are called. Send events promptly; a late delivery counts toward the
period it arrives in.
Idempotency
eventId is unique per (quest, user). Re-sending an event you already
sent — a retry after a timeout, a replay from your queue, a double
delivery — returns 200 with "alreadyCompleted": true and the
original completion. It never awards twice.
What a new eventId does depends on the quest's config:
period | maxCompletionsPerPeriod | Behaviour |
|---|---|---|
none | 1 (default) | One completion ever. The first event wins; every later event — new id or not — returns alreadyCompleted. |
daily | 1 | One completion per UTC day. A second event the same day returns alreadyCompleted; the first event after 00:00 UTC awards again. |
weekly | 1 | One completion per ISO week (Monday 00:00 UTC). Same shape. |
| any | N > 1 | Each new eventId awards again, up to N in the period. Past N, you get "status": "FAILED" with a reason naming the window. Replays still collapse. |
At the default cap of 1 you do not need to deduplicate on your side at all: send the event whenever you see it and let the period absorb the repeats.
One consequence worth knowing: an already-banked period answers before
the body is validated. Once the completion exists, a repeat call
returns 200 alreadyCompleted even if its userInput is malformed or
over the metadata limit — the award is already a fact and we report
it rather than erroring. Body validation (400 invalid_user_input)
applies to calls that would otherwise create a completion. So don't use
a 400 as your signal that an event was rejected; read status and
alreadyCompleted.
Worked example — "Make your first trade"
A once-ever quest: period: "none", maxCompletionsPerPeriod: 1. Your
backend fires on the user's first fill and doesn't need to remember
whether it already did.
If the same user trades again tomorrow and your backend fires again
(different eventId), the quest is already banked:
Worked example — "Weekly volume milestone"
A repeating quest: period: "weekly", maxCompletionsPerPeriod: 1.
You decide when the milestone is hit — Onsend does not compute
volume. Run your own rollup, and when a user crosses the threshold,
send one event.
A useful eventId convention for this shape is
<metric>-<your-period>-<user>: it is naturally unique per user per
week, so your rollup can be safely re-run (after a crash, or on a
nightly reconcile) without any extra bookkeeping — the replay collapses
onto the award that already happened.
Crossing the threshold again the same week returns alreadyCompleted.
On Monday 00:00 UTC the period rolls and the next week's event awards
again.
Two things to keep in mind with weekly quests:
- The period is ours (ISO week, Monday 00:00 UTC), not your billing week. If your rollup runs on a different boundary, an event can land in the following Onsend week — which is fine for a cadence quest, but worth knowing if you are reconciling counts.
- A missed week is not recoverable by sending a late event with an older
occurredAt. It counts toward the week it arrives in.
Example request
Example response — verified
Example response — retried (idempotent replay)
Example response — deferred (asynchronous verification)
Some quest types verify asynchronously (e.g. social actions that
external providers confirm with a delay). Poll GET /api/v1/quests?user=...
for the final state.
Example response — failed
Notable errors
404 not_found— unknown quest id in your project, or unknownexternalId.403 quest_not_active— the quest is paused/archived/scheduled and not accepting new verifications right now.409 completion_limit_reached— the user already hit the quest's per-user completion cap.409 deletion_in_progress— this user has an active data-deletion request; reward-granting actions are blocked until it resolves.400 invalid_user_input—userInputfailed the quest type's validation.
GET /api/v1/squads
Read. Your project's squad directory.
Query parameters
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
status | enum | no | (all) | FORMING, ACTIVE, or ARCHIVED. |
limit | integer | no | 20 | 1–50. |
Example request
Example response
POST /api/v1/squads
Write. Create a squad; the creating user becomes its LEADER.
Request body
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
externalId | string | yes | — | The creating user. |
name | string | yes | — | 2–60 chars. |
slug | string | yes | — | Lowercase letters/digits/dashes, matching ^[a-z0-9][a-z0-9-]{1,40}[a-z0-9]$. |
description | string | no | — | Max 280 chars. |
avatarUrl | string | no | — | Valid URL. |
campaignId | string | no | — | Must be a squad-enabled campaign in your project. |
joinPolicy | enum | no | INVITE_ONLY | OPEN, INVITE_ONLY, or CLOSED. |
maxMembers | integer | no | 20 | 2–50. |
minMembersForActive | integer | no | 3 | 2–50; must not exceed maxMembers. Members needed before the squad activates. |
Example request
Example response
Notes:
- This endpoint returns the complete squad object; the list and detail GETs return a trimmed view.
inviteTokenis returned only here, at creation. Store it if the squad isINVITE_ONLY— members need it to join, and no read endpoint ever returns it again.memberCountin this immediate response may still read0; the creator's membership is visible on the detail GET right away.tierisnulluntil the first XP rollup runs for the squad.
Notable errors
409 slug_taken,409 already_in_squad,400 invalid_size,422 campaign_invalid,404 not_found(unknownexternalId).
GET /api/v1/squads/{slug}
Read. Squad detail + active roster. inviteToken is never included in
this or any other read response — it is returned once by
POST /api/v1/squads at creation and consumed only by POST .../join.
Example request
Example response
POST /api/v1/squads/{slug}/join
Write. Join a squad (open policy, or invite-only with a valid
inviteToken).
Request body
| Field | Type | Required | Description |
|---|---|---|---|
externalId | string | yes | The joining user. |
inviteToken | string | no | Required (and must match) for INVITE_ONLY squads. |
Example request
Example response
promotedToActive is true on the join that carries the squad past
minMembersForActive (subject to an anti-abuse review of the roster,
reflected in density).
Notable errors
410 archived,403 closed,403 invalid_invite,409 full,409 already_in_squad,404 not_found.
POST /api/v1/squads/{slug}/leave
Write. Leave a squad. The leader must transfer leadership before leaving (leadership transfer is a dashboard action in v1 — there is no transfer endpoint yet).
Request body
| Field | Type | Required | Description |
|---|---|---|---|
externalId | string | yes | The leaving user. |
Example request
Example response
Notable errors
409 leader_cannot_leave,404 not_member,404 not_found.
GET /api/v1/squads/leaderboard
Read. Squad-level ranking (distinct from the per-user leaderboard).
Query parameters
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
window | enum | no | all | all, 7d, or 30d. |
limit | integer | no | 50 | Capped at 100. |
Example request
Example response
Note: the response's window field echoes canonical window names
(SEVEN_DAY / THIRTY_DAY / ALL_TIME), not the request's
7d/30d/all aliases.
POST /api/v1/pvp/challenges
Write. Create a PvP challenge — open, or directed at a specific
opponent via opponentExternalId. Returns 403 disabled if PvP isn't
enabled for your project (PvP is enabled per project — ask your Onsend
contact if this is unexpected).
Request body
| Field | Type | Required | Description |
|---|---|---|---|
externalId | string | yes | The challenge creator. |
stake | integer | yes | Positive integer. Capped by your project's configured max stake. |
opponentExternalId | string | no | Directs the challenge at a specific opponent (directed challenges must be enabled for your project). |
Example request
Example response
commitHash is the fairness commitment: the server commits to its
random seed before the challenge is accepted, and reveals it after
settlement so either side can independently verify the outcome.
Notable errors
403 disabled,402 insufficient,400 max_stake_exceeded,400 invalid_opponent,403 directed_disabled,403 banned/blocked/sybil_blocked.
GET /api/v1/pvp/challenges/{id}
Read. A single challenge's public view. The server seed is never
exposed pre-settlement: before settlement fairnessProof contains only
commitHash; after settlement it contains the full reveal
(serverSeed, clientSeed, acceptNonce, roll).
Example request
Example response — pre-settlement
Example response — settled
If PvP is disabled for your project: { "data": { "enabled": false } }.
POST /api/v1/pvp/challenges/{id}/accept
Write. Accept an open (or directed-at-you) challenge. The acceptor's stake is debited and the challenge settles inline — this call returns the already-settled challenge view.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
externalId | string | yes | The accepting user. |
clientSeed | string | no | 1–128 chars. Contributes to the fairness proof; randomised server-side if omitted. |
Example request
Example response
Same shape as the settled GET example above.
Notable errors
409 already_taken,409 bad_state,402 insufficient,404 not_found.
GET /api/v1/pvp/jackpot
Read. The jackpot pool view. Optional ?user=<externalId> resolves
that user's ticket count; omitted or unresolved → myTickets: 0.
Example request
Example response
If PvP or the jackpot feature is off for your project:
{ "data": { "enabled": false } }.
Versioning
The API is versioned in the URL (/api/v1/). Within v1, changes are
additive only: new endpoints, new optional request parameters, and
new response fields may appear at any time, and your integration should
tolerate unknown response fields. Breaking changes — removing or
renaming fields, changing types or semantics — will only ever ship
under a new version prefix (/api/v2/), with a migration period during
which v1 keeps working.
Changelog
v1.2 — October 2026
POST /api/v1/users/{externalId}/social/x— link a member's X (Twitter) account from a Privy identity token, so everytwitter_*quest type can verify for users your backend created. Previously there was no v1 way to link a social account at all. See Social accounts.- New quest type
hold_token_solana— holding an SPL token on Solana mainnet, read against the member's attachedSOLANAwallet. Solana wallets were identity-only before this; staking, LP and NFT quests remain EVM-only. See Wallets. partner_actionquest type — partner-asserted quests for actions on your own product, withnone/daily/weeklyrepeat periods. Seepartner_action.- Per-key rate limits are now configurable above the 60/min default. See Raising the per-key limit.
v1.1 — August 2026
- Headless (server-to-server) surface: user upsert by
externalIdwith EVM + Solana wallet attachment, user profile byexternalId, campaign + quest listings with per-user completion state, quest verification, squads (create/join/leave/directory/detail/ leaderboard), and PvP (challenges, accept, jackpot). writescope introduced for all POST endpoints; per-(key, user) write rate limit added.- Headless referrals: optional
referredByonPOST /api/v1/usersto attribute a new user to the referrer who invited them, plusGET /api/v1/users/{externalId}/referralto read a user's own referral code and stats. A cap of 50 attributions per referrer per rolling 24 hours applies. See Referrals.
v1.0 — June 2026
- Initial release: leaderboard, user standing by wallet address, and project stats (read-only).