Onsend Docs

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

https://onsend.xyz/api/v1

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:

curl -s -X POST https://onsend.xyz/api/v1/users \
  -H "Authorization: Bearer onsend_your_write_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "externalId": "acme-user-42",
    "displayName": "Player Forty-Two",
    "wallets": [
      { "chain": "EVM", "address": "0x1234567890AbcdEF1234567890aBcdef12345678", "primary": true }
    ]
  }'
# → { "data": { "userId": "...", "externalId": "acme-user-42", "created": true, ... } }

2. List campaigns:

curl -s https://onsend.xyz/api/v1/campaigns \
  -H "Authorization: Bearer onsend_your_key_here"
# → { "data": { "items": [ { "id": "...", "name": "Acme Launch", ... } ], "nextCursor": null } }

3. List quests, with that user's completion state:

curl -s "https://onsend.xyz/api/v1/quests?user=acme-user-42" \
  -H "Authorization: Bearer onsend_your_key_here"
# → { "data": { "quests": [ { "id": "qst_...", "title": "...", "baseXp": 50, "completion": null, ... } ] } }

4. Verify a quest for the user (use a quest id from step 3):

curl -s -X POST https://onsend.xyz/api/v1/quests/QUEST_ID/verify \
  -H "Authorization: Bearer onsend_your_write_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "externalId": "acme-user-42" }'
# → { "data": { "status": "VERIFIED", "completion": { "xpAwarded": 50, ... } } }

5. Read the result back — the user's profile and the leaderboard:

curl -s https://onsend.xyz/api/v1/users/acme-user-42/profile \
  -H "Authorization: Bearer onsend_your_key_here"
# → { "data": { "externalId": "acme-user-42", "totalXp": 50, "rank": 1, ... } }
 
curl -s "https://onsend.xyz/api/v1/leaderboard?limit=10" \
  -H "Authorization: Bearer onsend_your_key_here"

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 write scope — 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:

Authorization: Bearer onsend_your_key_here

or

X-API-Key: onsend_your_key_here

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 include write. 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:

{
  "error": {
    "code": "insufficient_scope",
    "message": "This endpoint requires the 'write' scope. Mint a write-scoped API key in the admin dashboard."
  }
}

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}/profile looks a user up by your externalId.

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:

externalId must be the token identity, in the form token:<tokenId> — for example token: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:

{ "chain": "EVM", "address": "0x1234567890AbcdEF1234567890aBcdef12345678", "primary": true }
  • chain is EVM or SOLANA.
  • 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_error with a details.address field — note this is a different error code from the generic 400 invalid_params.
  • Primary resolution: an explicit "primary": true wins; if two wallets on the same chain both set primary: true in 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:

  1. Show each user their code. Call GET /api/v1/users/{externalId}/referral and render the code behind your share button, in a link shaped however you like — https://yourapp.com/join?invite=k7m2p9qr.
  2. Pass it back at signup. When someone signs up from that link, send the code as referredBy on POST /api/v1/users. Check the referral field on the response to tell the new user whether their invite applied.
  3. 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:

HTTP/1.1 429 Too Many Requests
Retry-After: 42
{
  "error": {
    "code": "rate_limited",
    "message": "Rate limit exceeded. Retry after the indicated number of seconds.",
    "retryAfterSeconds": 42
  }
}

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, referredBy returns { "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:

{ "data": { } }

Errors use a consistent shape:

{ "error": { "code": "string", "message": "string" } }

Validation errors (400) additionally include a details object describing the offending parameters. The 429 error additionally includes retryAfterSeconds.

Status codes and error codes

HTTPerror.codeMeaning
200(none)Success.
400invalid_paramsA query/body parameter is missing, malformed, or out of range (schema validation failure).
400invalid_jsonThe request body is not valid JSON (fails to parse before schema validation runs).
400validation_errorSemantic validation failure — e.g. a malformed wallet address (details.address).
400invalid_user_inputOn quest verify: userInput failed the quest type's own validation.
401unauthorizedMissing, invalid, revoked, or expired API key.
402insufficientPvP: not enough balance to cover the stake.
403insufficient_scopeA read-scoped key called a write endpoint. Mint a write-scoped key.
403wallet_sanctionedThe EVM wallet you tried to attach failed sanctions screening.
403quest_not_activeThe quest exists but isn't currently accepting verifications (paused/archived/scheduled).
403disabledPvP: the feature isn't enabled for your project.
404not_foundUnknown user (by wallet or externalId), quest, campaign, squad, or PvP challenge.
409wallet_conflictThe wallet is already attached to a different user in your project.
409deletion_in_progressThis user has an active data-deletion request; reward-granting actions are blocked.
409identity_mode_mismatchToken-identity project: externalId must be token:<tokenId>. See Identity model.
409completion_limit_reachedThe user already hit the quest's per-user completion cap.
429rate_limitedOver the 60/min per-key or 30/min per-(key,user) budget. See Retry-After.
500internal_errorUnexpected server error. Safe to retry.

Squad-specific codes (same { "error": { "code", "message" } } shape, no details):

HTTPerror.codeWhereMeaning
400invalid_sizePOST /squadsminMembersForActive exceeds maxMembers.
403closedPOST /squads/{slug}/joinSquad's join policy is CLOSED.
403invalid_invitePOST /squads/{slug}/joinMissing or wrong inviteToken for an INVITE_ONLY squad.
404not_foundany squad endpointSquad slug or campaign not found.
404not_memberPOST /squads/{slug}/leaveThe user isn't a current member of the squad.
409slug_takenPOST /squadsSlug already in use in your project.
409already_in_squadPOST /squads, .../joinUser must leave their current squad before joining/creating another.
409fullPOST /squads/{slug}/joinSquad is at maxMembers.
409leader_cannot_leavePOST /squads/{slug}/leaveThe leader must transfer leadership before leaving.
410archivedPOST /squads/{slug}/joinSquad is archived.
422campaign_invalidPOST /squadscampaignId 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):

HTTPerror.codeMeaning
400invalid_opponentopponentExternalId resolves to an invalid/ineligible opponent.
400max_stake_exceededstake exceeds your project's configured PvP stake ceiling.
402insufficientThe user doesn't have enough balance to cover the stake.
403bannedUser is banned.
403blockedUser is blocked by anti-abuse systems.
403sybil_blockedThe user's PvP participation is gated by anti-abuse systems.
403directed_disabledDirected (opponent-targeted) challenges are off for your project.
403not_ownerThe user isn't the challenge's creator/acceptor for this action.
403not_operator_permittedAction not permitted for this key.
403disabledPvP isn't enabled for your project.
404not_foundChallenge doesn't exist (or doesn't belong to your project).
409already_takenChallenge was already accepted/settled by someone else.
409bad_stateAction not valid for the challenge's current status.
429loss_limit_reachedUser hit the configured daily PvP loss limit.
429rate_limitedUser hit the configured PvP plays-per-hour limit (distinct from the API's own 429).

Example error bodies:

{ "error": { "code": "unauthorized", "message": "Invalid API key" } }
{ "error": { "code": "not_found", "message": "user not found" } }
{
  "error": {
    "code": "invalid_params",
    "message": "Invalid request parameters",
    "details": { "fieldErrors": { "limit": ["Number must be less than or equal to 100"] } }
  }
}
{
  "error": {
    "code": "wallet_conflict",
    "message": "Wallet 0x1234... is already attached to a different user in this tenant"
  }
}

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

ParamTypeRequiredDefaultDescription
limitintegerno50Page size. Range 1 to 100. Out of range returns 400.
cursorstringno(none)Opaque pagination cursor from a previous response's nextCursor.
windowenumnoallTimeframe: all, 7d, or 30d (project-wide).
campaignIdstringno(none)Scope to one campaign's leaderboard. Only valid with window=all.

Notes:

  • window=all ranks participants by their project-wide total points and is always up to date.
  • window=7d and window=30d are computed daily; their entries are empty until the first daily computation after the window's data exists.
  • campaignId returns that campaign's all-time leaderboard. Combining campaignId with window=7d or window=30d returns 400.

Example request

curl -s "https://onsend.xyz/api/v1/leaderboard?limit=2" \
  -H "Authorization: Bearer onsend_your_key_here"

Example response

{
  "data": {
    "entries": [
      { "rank": 1, "wallet": "0x1234567890abcdef1234567890abcdef12345678", "displayName": "Player Forty-Two", "avatarUrl": null, "points": 1280 },
      { "rank": 2, "wallet": "0xfeedfacefeedfacefeedfacefeedfacefeedface", "displayName": null, "avatarUrl": null, "points": 960 }
    ],
    "total": 421,
    "nextCursor": "dTEyMzQ1"
  }
}

On a token-identity project each entry additionally carries tokenId, the membership token the standing belongs to:

{ "rank": 1, "wallet": "0x1234...5678", "displayName": null, "avatarUrl": null, "points": 1280, "tokenId": "1234" }

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

ParamTypeRequiredDefaultDescription
windowenumnoallTimeframe: all, 7d, or 30d.
campaignIdstringno(none)Standing within one campaign. Only valid with window=all.

Example request

curl -s https://onsend.xyz/api/v1/users/0x1234567890AbcdEF1234567890aBcdef12345678 \
  -H "Authorization: Bearer onsend_your_key_here"

Example response

{
  "data": {
    "wallet": "0x1234567890abcdef1234567890abcdef12345678",
    "displayName": "Player Forty-Two",
    "avatarUrl": null,
    "rank": 12,
    "points": 640,
    "window": "all"
  }
}

GET /api/v1/stats

Read. Aggregate, privacy-safe stats for your project.

Example request

curl -s https://onsend.xyz/api/v1/stats \
  -H "Authorization: Bearer onsend_your_key_here"

Example response

{
  "data": {
    "totalParticipants": 421,
    "totalCampaigns": 7,
    "totalPointsDistributed": 188400,
    "totalQuestsCompleted": 5120
  }
}

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

FieldTypeRequiredDescription
externalIdstringyes1–128 chars. Your own user id. The only upsert key.
emailstringnoValid email, max 254 chars. Stored, never echoed back on reads.
displayNamestringno1–80 chars.
avatarUrlstringnoValid URL, max 2048 chars.
walletsarraynoUp to 10 { chain: "EVM" | "SOLANA", address: string, primary?: boolean } entries. See Wallets.
referredBystringnoA 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

curl -s -X POST https://onsend.xyz/api/v1/users \
  -H "Authorization: Bearer onsend_your_write_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "externalId": "acme-user-42",
    "email": "player@example.com",
    "displayName": "Player Forty-Two",
    "wallets": [
      { "chain": "EVM", "address": "0x1234567890AbcdEF1234567890aBcdef12345678", "primary": true },
      { "chain": "SOLANA", "address": "So11111111111111111111111111111111111111112" }
    ]
  }'

Example response

{
  "data": {
    "userId": "clx1a2b3c4d5e6f7g8h9",
    "externalId": "acme-user-42",
    "walletAddress": "0x1234567890abcdef1234567890abcdef12345678",
    "wallets": [
      { "chain": "EVM", "address": "0x1234567890abcdef1234567890abcdef12345678", "isPrimary": true },
      { "chain": "SOLANA", "address": "So11111111111111111111111111111111111111112", "isPrimary": false }
    ],
    "totalXp": 0,
    "tier": "NONE",
    "created": true
  }
}

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:

{
  "data": {
    "userId": "clx1a2b3c4d5e6f7g8h9",
    "externalId": "acme-user-42",
    "created": true,
    "totalXp": 0,
    "tier": "NONE",
    "wallets": [],
    "walletAddress": null,
    "referral": { "attributed": true }
  }
}

When attribution fails, attributed is false and reason names the cause:

reasonWhat happened
invalid_codeNo such code in your project, or it belongs to a campaign that can't accept it.
self_referralThe code belongs to this same user, or to another account on the same wallet.
referee_blockedThe 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_limitedThe referrer has hit their daily attribution cap (see Rate limits).
already_attributedThis 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 /profile path its value is always interpreted as your externalId — do not send a wallet address here.

Example request

curl -s https://onsend.xyz/api/v1/users/acme-user-42/profile \
  -H "Authorization: Bearer onsend_your_key_here"

Example response

{
  "data": {
    "userId": "clx1a2b3c4d5e6f7g8h9",
    "externalId": "acme-user-42",
    "walletAddress": "0x1234567890abcdef1234567890abcdef12345678",
    "wallets": [
      { "chain": "EVM", "address": "0x1234567890abcdef1234567890abcdef12345678", "isPrimary": true }
    ],
    "displayName": "Player Forty-Two",
    "avatarUrl": null,
    "totalXp": 640,
    "tier": "SILVER",
    "rank": 12,
    "questsCompletedCount": 9,
    "dailyCheckinStreak": 3,
    "multipliers": [
      { "type": "referral_bonus", "value": 1.1, "expiresAt": null, "scope": null }
    ],
    "combinedMultiplier": 1.1,
    "badges": [
      {
        "badgeTypeSlug": "early_supporter",
        "displayName": "Early Supporter",
        "badgeTier": "BRONZE",
        "iconUrl": null,
        "earnedAt": "2026-06-01T12:00:00.000Z",
        "prestigeLevel": 0
      }
    ],
    "squad": {
      "slug": "night-owls",
      "name": "Night Owls",
      "status": "ACTIVE",
      "memberCount": 5,
      "totalXp": 3200,
      "tier": "GOLD"
    }
  }
}

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

curl -s https://onsend.xyz/api/v1/users/acme-user-42/referral \
  -H "Authorization: Bearer onsend_your_key_here"

Example response

{
  "data": {
    "externalId": "acme-user-42",
    "campaignId": "clx9z8y7x6w5v4u3t2s1",
    "code": "k7m2p9qr",
    "stats": {
      "totalReferrals": 12,
      "successfulReferrals": 5,
      "pending": 7,
      "confirmed": 5,
      "totalXpFromReferrals": 3200
    }
  }
}
FieldMeaning
totalReferralsPeople who signed up with this code and passed anti-abuse checks.
successfulReferralsOf those, how many have earned their referrer a reward.
pendingSigned up, but haven't completed their first quest yet.
confirmedCompleted a quest — rewards have started flowing.
totalXpFromReferralsTotal XP this code has earned its owner, signup bonuses and ongoing share combined.

Notable errors

  • 404 not_found — no user with that externalId in your project, or your project has no active campaign.

GET /api/v1/campaigns

Read. Campaign discovery for your project.

Query parameters

ParamTypeRequiredDefaultDescription
statusenumnoACTIVEACTIVE, PAUSED, COMPLETED, or all.
limitintegerno501–100.
cursorstringno(none)Opaque pagination cursor from a prior response.

Example request

curl -s "https://onsend.xyz/api/v1/campaigns?status=all&limit=1" \
  -H "Authorization: Bearer onsend_your_key_here"

Example response

{
  "data": {
    "items": [
      {
        "id": "cmp_9f8e7d",
        "name": "Acme Launch",
        "slug": "acme-launch",
        "description": "Kick off the Acme community campaign.",
        "mode": "OPEN",
        "type": "EVERGREEN",
        "status": "ACTIVE",
        "startsAt": "2026-06-01T00:00:00.000Z",
        "endsAt": null,
        "multiplier": 1,
        "questCount": 6
      }
    ],
    "nextCursor": null
  }
}

GET /api/v1/quests

Read. Your project's active, visible quests, with optional per-user completion state.

Query parameters

ParamTypeRequiredDescription
campaignIdstringnoNarrow to one campaign.
userstringnoYour 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

curl -s "https://onsend.xyz/api/v1/quests?user=acme-user-42" \
  -H "Authorization: Bearer onsend_your_key_here"

Example response

{
  "data": {
    "quests": [
      {
        "id": "qst_1a2b3c",
        "campaignId": "cmp_9f8e7d",
        "connectorId": "twitter_follow",
        "title": "Follow us on X",
        "description": "Follow @acme to earn XP.",
        "iconUrl": null,
        "order": 1,
        "baseXp": 50,
        "multiplier": 1,
        "verificationMode": "AUTOMATIC",
        "startsAt": null,
        "endsAt": null,
        "completion": { "status": "VERIFIED", "xpAwarded": 50, "verifiedAt": "2026-07-01T09:00:00.000Z" }
      },
      {
        "id": "qst_4d5e6f",
        "campaignId": "cmp_9f8e7d",
        "connectorId": "daily_checkin",
        "title": "Daily check-in",
        "description": "Check in once per day.",
        "iconUrl": null,
        "order": 2,
        "baseXp": 10,
        "multiplier": 1,
        "verificationMode": "AUTOMATIC",
        "startsAt": null,
        "endsAt": null,
        "completion": null
      }
    ]
  }
}

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 — the user param doesn't resolve to a known externalId in 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

FieldTypeRequiredDescription
externalIdstringyes1–128 chars. The acting user.
userInputobjectnoQuest-type-specific payload (e.g. a quiz answer index). Validated against that quest type's own schema.

Example request

curl -s -X POST https://onsend.xyz/api/v1/quests/qst_1a2b3c/verify \
  -H "Authorization: Bearer onsend_your_write_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "externalId": "acme-user-42" }'

Example response — verified

{
  "data": {
    "status": "VERIFIED",
    "completion": {
      "id": "qc_7g8h9i",
      "xpAwarded": 50,
      "verifiedAt": "2026-08-06T15:04:05.000Z",
      "rewardsHeld": false,
      "pendingXpAward": null
    }
  }
}

Example response — retried (idempotent replay)

{
  "data": {
    "status": "VERIFIED",
    "alreadyCompleted": true,
    "completion": { "id": "qc_7g8h9i", "xpAwarded": 50, "verifiedAt": "2026-08-06T15:04:05.000Z" }
  }
}

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.

{
  "data": {
    "status": "PENDING_VERIFICATION",
    "completionId": "qc_2j3k4l",
    "nextCheckAt": "2026-08-06T15:19:05.000Z"
  }
}

Example response — failed

{
  "data": {
    "status": "FAILED",
    "reason": "twitter_follow_not_detected",
    "completionId": null
  }
}

Notable errors

  • 404 not_found — unknown quest id in your project, or unknown externalId.
  • 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_inputuserInput failed the quest type's validation.

GET /api/v1/squads

Read. Your project's squad directory.

Query parameters

ParamTypeRequiredDefaultDescription
statusenumno(all)FORMING, ACTIVE, or ARCHIVED.
limitintegerno201–50.

Example request

curl -s "https://onsend.xyz/api/v1/squads?status=ACTIVE" \
  -H "Authorization: Bearer onsend_your_key_here"

Example response

{
  "data": {
    "squads": [
      {
        "id": "sqd_5m6n7o",
        "slug": "night-owls",
        "name": "Night Owls",
        "description": "We grind after dark.",
        "avatarUrl": null,
        "status": "ACTIVE",
        "joinPolicy": "OPEN",
        "memberCount": 5,
        "maxMembers": 20,
        "totalXp": 3200,
        "tier": "GOLD"
      }
    ]
  }
}

POST /api/v1/squads

Write. Create a squad; the creating user becomes its LEADER.

Request body

FieldTypeRequiredDefaultDescription
externalIdstringyesThe creating user.
namestringyes2–60 chars.
slugstringyesLowercase letters/digits/dashes, matching ^[a-z0-9][a-z0-9-]{1,40}[a-z0-9]$.
descriptionstringnoMax 280 chars.
avatarUrlstringnoValid URL.
campaignIdstringnoMust be a squad-enabled campaign in your project.
joinPolicyenumnoINVITE_ONLYOPEN, INVITE_ONLY, or CLOSED.
maxMembersintegerno202–50.
minMembersForActiveintegerno32–50; must not exceed maxMembers. Members needed before the squad activates.

Example request

curl -s -X POST https://onsend.xyz/api/v1/squads \
  -H "Authorization: Bearer onsend_your_write_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "externalId": "acme-user-42",
    "name": "Night Owls",
    "slug": "night-owls",
    "joinPolicy": "OPEN"
  }'

Example response

{
  "data": {
    "id": "sqd_5m6n7o",
    "tenantId": "ten_1a2b3c",
    "campaignId": null,
    "name": "Night Owls",
    "slug": "night-owls",
    "description": null,
    "avatarUrl": null,
    "leaderUserId": "clx1a2b3c4d5e6f7g8h9",
    "joinPolicy": "OPEN",
    "maxMembers": 20,
    "minMembersForActive": 3,
    "status": "FORMING",
    "formedAt": null,
    "archivedAt": null,
    "memberCount": 0,
    "totalXp": 0,
    "tier": null,
    "lastSybilDensity": null,
    "lastSybilDensityCheck": null,
    "inviteToken": "fIF4gPZsmwvRP-jZ4d6lXQ",
    "createdAt": "2026-08-06T15:04:05.000Z",
    "updatedAt": "2026-08-06T15:04:05.000Z"
  }
}

Notes:

  • This endpoint returns the complete squad object; the list and detail GETs return a trimmed view.
  • inviteToken is returned only here, at creation. Store it if the squad is INVITE_ONLY — members need it to join, and no read endpoint ever returns it again.
  • memberCount in this immediate response may still read 0; the creator's membership is visible on the detail GET right away.
  • tier is null until 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 (unknown externalId).

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

curl -s https://onsend.xyz/api/v1/squads/night-owls \
  -H "Authorization: Bearer onsend_your_key_here"

Example response

{
  "data": {
    "squad": {
      "id": "sqd_5m6n7o",
      "slug": "night-owls",
      "name": "Night Owls",
      "description": null,
      "avatarUrl": null,
      "status": "ACTIVE",
      "joinPolicy": "OPEN",
      "memberCount": 2,
      "maxMembers": 20,
      "totalXp": 900,
      "tier": "SILVER"
    },
    "members": [
      {
        "role": "LEADER",
        "joinedAt": "2026-08-06T15:04:05.000Z",
        "contributionXp": 640,
        "user": { "displayName": "Player Forty-Two", "walletAddress": "0x1234567890abcdef1234567890abcdef12345678", "externalId": "acme-user-42" }
      },
      {
        "role": "MEMBER",
        "joinedAt": "2026-08-06T15:20:00.000Z",
        "contributionXp": 260,
        "user": { "displayName": null, "walletAddress": null, "externalId": "acme-user-77" }
      }
    ]
  }
}

POST /api/v1/squads/{slug}/join

Write. Join a squad (open policy, or invite-only with a valid inviteToken).

Request body

FieldTypeRequiredDescription
externalIdstringyesThe joining user.
inviteTokenstringnoRequired (and must match) for INVITE_ONLY squads.

Example request

curl -s -X POST https://onsend.xyz/api/v1/squads/night-owls/join \
  -H "Authorization: Bearer onsend_your_write_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "externalId": "acme-user-77" }'

Example response

{
  "data": {
    "ok": true,
    "status": "ACTIVE",
    "promotedToActive": true,
    "density": { "density": 0, "flaggedCount": 0, "totalCount": 3, "threshold": 0.3, "pass": true }
  }
}

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

FieldTypeRequiredDescription
externalIdstringyesThe leaving user.

Example request

curl -s -X POST https://onsend.xyz/api/v1/squads/night-owls/leave \
  -H "Authorization: Bearer onsend_your_write_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "externalId": "acme-user-77" }'

Example response

{ "data": { "ok": true } }

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

ParamTypeRequiredDefaultDescription
windowenumnoallall, 7d, or 30d.
limitintegerno50Capped at 100.

Example request

curl -s "https://onsend.xyz/api/v1/squads/leaderboard?window=7d" \
  -H "Authorization: Bearer onsend_your_key_here"

Example response

{
  "data": {
    "window": "SEVEN_DAY",
    "rows": [
      {
        "rank": 1,
        "squad": { "slug": "night-owls", "name": "Night Owls", "avatarUrl": null, "memberCount": 5, "status": "ACTIVE", "tier": "GOLD" },
        "xpInWindow": 1400,
        "activeMembers": 4
      }
    ]
  }
}

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

FieldTypeRequiredDescription
externalIdstringyesThe challenge creator.
stakeintegeryesPositive integer. Capped by your project's configured max stake.
opponentExternalIdstringnoDirects the challenge at a specific opponent (directed challenges must be enabled for your project).

Example request

curl -s -X POST https://onsend.xyz/api/v1/pvp/challenges \
  -H "Authorization: Bearer onsend_your_write_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "externalId": "acme-user-42", "stake": 100 }'

Example response

{
  "data": {
    "id": "pvp_8p9q0r",
    "commitHash": "3f5e...c1a9"
  }
}

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

curl -s https://onsend.xyz/api/v1/pvp/challenges/pvp_8p9q0r \
  -H "Authorization: Bearer onsend_your_key_here"

Example response — pre-settlement

{
  "data": {
    "id": "pvp_8p9q0r",
    "status": "OPEN",
    "stake": 100,
    "isDirected": false,
    "creatorUserId": "clx1a2b3c4d5e6f7g8h9",
    "opponentUserId": null,
    "winnerUserId": null,
    "winnerPayout": 0,
    "houseCut": 0,
    "expiresAt": "2026-08-06T16:04:05.000Z",
    "createdAt": "2026-08-06T15:04:05.000Z",
    "settledAt": null,
    "fairnessProof": { "commitHash": "3f5e...c1a9" }
  }
}

Example response — settled

{
  "data": {
    "id": "pvp_8p9q0r",
    "status": "SETTLED",
    "stake": 100,
    "isDirected": false,
    "creatorUserId": "clx1a2b3c4d5e6f7g8h9",
    "opponentUserId": "clx2b3c4d5e6f7g8h9i0",
    "winnerUserId": "clx1a2b3c4d5e6f7g8h9",
    "winnerPayout": 190,
    "houseCut": 10,
    "expiresAt": "2026-08-06T16:04:05.000Z",
    "createdAt": "2026-08-06T15:04:05.000Z",
    "settledAt": "2026-08-06T15:07:12.000Z",
    "fairnessProof": {
      "commitHash": "3f5e...c1a9",
      "serverSeed": "9a8b...0f1e",
      "clientSeed": "7c6d",
      "acceptNonce": "1",
      "roll": 42
    }
  }
}

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

FieldTypeRequiredDescription
externalIdstringyesThe accepting user.
clientSeedstringno1–128 chars. Contributes to the fairness proof; randomised server-side if omitted.

Example request

curl -s -X POST https://onsend.xyz/api/v1/pvp/challenges/pvp_8p9q0r/accept \
  -H "Authorization: Bearer onsend_your_write_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "externalId": "acme-user-77" }'

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

curl -s "https://onsend.xyz/api/v1/pvp/jackpot?user=acme-user-42" \
  -H "Authorization: Bearer onsend_your_key_here"

Example response

{
  "data": {
    "poolAmount": 5400,
    "currentRound": 3,
    "myTickets": 12,
    "totalTickets": 340,
    "nextDrawAt": "2026-08-13T00:00:00.000Z",
    "recentWinners": [
      { "winnerUserId": "clx9z8y7x6w5v4u3t2s1", "poolAmount": 4200, "drawnAt": "2026-07-30T00:00:00.000Z", "round": 2 }
    ]
  }
}

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 externalId with EVM + Solana wallet attachment, user profile by externalId, campaign + quest listings with per-user completion state, quest verification, squads (create/join/leave/directory/detail/ leaderboard), and PvP (challenges, accept, jackpot).
  • write scope introduced for all POST endpoints; per-(key, user) write rate limit added.
  • Headless referrals: optional referredBy on POST /api/v1/users to attribute a new user to the referrer who invited them, plus GET /api/v1/users/{externalId}/referral to 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).

On this page

Base URLQuickstartAuthenticationProject scopingScopesIdentity modelToken-identity projectsWalletsReferralsIdempotency and retriesRate limitsReferral attribution limitsResponse envelopeStatus codes and error codesPrivacyEndpoint referenceGET /api/v1/leaderboardQuery parametersExample requestExample responsePaginationGET /api/v1/users/{identifier}Query parametersExample requestExample responseGET /api/v1/statsExample requestExample responsePOST /api/v1/usersRequest bodyExample requestExample responseReferral attributionNotable errorsGET /api/v1/users/{externalId}/profileExample requestExample responseGET /api/v1/users/{externalId}/referralExample requestExample responseNotable errorsGET /api/v1/campaignsQuery parametersExample requestExample responseGET /api/v1/questsQuery parametersExample requestExample responseNotable errorsPOST /api/v1/quests/{id}/verifyRequest bodyExample requestExample response — verifiedExample response — retried (idempotent replay)Example response — deferred (asynchronous verification)Example response — failedNotable errorsGET /api/v1/squadsQuery parametersExample requestExample responsePOST /api/v1/squadsRequest bodyExample requestExample responseNotable errorsGET /api/v1/squads/{slug}Example requestExample responsePOST /api/v1/squads/{slug}/joinRequest bodyExample requestExample responseNotable errorsPOST /api/v1/squads/{slug}/leaveRequest bodyExample requestExample responseNotable errorsGET /api/v1/squads/leaderboardQuery parametersExample requestExample responsePOST /api/v1/pvp/challengesRequest bodyExample requestExample responseNotable errorsGET /api/v1/pvp/challenges/{id}Example requestExample response — pre-settlementExample response — settledPOST /api/v1/pvp/challenges/{id}/acceptRequest bodyExample requestExample responseNotable errorsGET /api/v1/pvp/jackpotExample requestExample responseVersioningChangelogv1.1 — August 2026v1.0 — June 2026