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 is identity-only in v1. A Solana address is stored and
echoed back on profile reads, but Onsend does not:
- verify a Solana signature (this is a server-to-server call — there is no signing step in this flow at all);
- run on-chain quest verification against a Solana wallet — on-chain quest connectors (token holding, staking, LP) are EVM-only in v1;
- run sanctions screening against a Solana address (screening runs
for
chain: "EVM"wallets). 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.
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.
- 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 60/min across its user base, but no single user can consume more than 30/min of it.
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. |
| 401 | unauthorized | Missing, invalid, revoked, or expired API key. |
| 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. |
| 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. |
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.
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. |
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. |
Notable errors
404 not_found— no user with thatexternalIdin your project, or your project has no active campaign.
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).
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 (e.g. a quiz answer index). Validated against that quest type's own schema. |
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.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).