Onsend Docs

API Reference

The Onsend public REST API (v1) — read-only leaderboard, user standing, and stats endpoints with API-key authentication.

Onsend's external, client-facing REST API. Read-only. All endpoints are GET and return JSON.

The API is versioned at /api/v1/. Future shape changes will ship under /api/v2, so a v1 integration will keep working unchanged.

Base URL

https://onsend.xyz/api/v1

Your tenant is determined by your API key (see Authentication), not by the host, so you do not need a tenant-specific URL. Use the apex base URL above for every request.

Your tenant dashboard subdomain works identically if you prefer it (https://<your-tenant>.onsend.xyz/api/v1), since the key still selects the tenant either way.

Quick start

curl -s https://onsend.xyz/api/v1/stats \
  -H "Authorization: Bearer onsend_your_key_here"
{
  "data": {
    "totalParticipants": 421,
    "totalCampaigns": 7,
    "totalPointsDistributed": 188400,
    "totalQuestsCompleted": 5120
  }
}

Authentication

Every request requires an API key. A tenant admin mints one under Settings > API Keys (/admin/settings/api-keys). The full key (onsend_...) is shown only once at creation. Store it securely; Onsend keeps only a hash and cannot show it again.

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.

Tenant scoping

Your key is bound to exactly one tenant. Every response contains only that tenant's data. There is no parameter that can read another tenant's data; the optional campaignId parameter is checked against your tenant and returns 404 if it does not belong to you.

Scopes

API keys carry a scopes list, but v1 does not enforce per-scope access: any valid (non-revoked, non-expired) key for your tenant can call all three v1 endpoints. See Future enhancements.

Rate limiting

Each API key is limited to 60 requests per minute.

When you exceed the limit you receive 429 with a Retry-After header giving the number of seconds to wait before retrying:

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 retryAfterSeconds) and retry after that delay.

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 parameter is missing, malformed, or out of range.
401unauthorizedMissing, invalid, revoked, or expired API key.
404not_foundUnknown user, or a campaignId that is not in your tenant.
429rate_limitedOver the 60/minute per-key budget. See Retry-After.
500internal_errorUnexpected server error.

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"] } }
  }
}

Privacy

Responses never contain email addresses or any other personal data. The only public identifiers are the wallet address (which may be null for participants who joined with email only) and the user-chosen display name (which may also be null).


GET /api/v1/leaderboard

Your tenant'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 (tenant-wide).
campaignIdstringno(none)Scope to one campaign's leaderboard. Only valid with window=all.

Notes:

  • window=all ranks participants by their tenant-wide total points and is always up to date.
  • window=7d and window=30d are computed by a daily job; their entries are empty until that job has run for the window.
  • campaignId returns that campaign's all-time leaderboard. Combining campaignId with window=7d or window=30d returns 400.

Response

{
  "data": {
    "entries": [
      {
        "rank": 1,
        "wallet": "0xabc123...",
        "displayName": "alice",
        "avatarUrl": null,
        "points": 1280
      }
    ],
    "total": 421,
    "nextCursor": "dTEyMzQ1"
  }
}
FieldTypeDescription
entries[]arrayThe page of ranked entries.
entries[].rankinteger1-based rank within the selected window.
entries[].walletstring | nullWallet address, or null for email-only users.
entries[].displayNamestring | nullChosen handle, or null.
entries[].avatarUrlstring | nullAvatar URL, or null.
entries[].pointsintegerPoints for the selected window.
totalintegerTotal ranked participants for the selection.
nextCursorstring | nullPass back as cursor for the next page. null when there are no more pages.

Pagination

Request the first page, then keep passing the returned nextCursor back as ?cursor= until nextCursor is null:

curl -s "https://onsend.xyz/api/v1/leaderboard?limit=50" \
  -H "Authorization: Bearer onsend_your_key_here"
# then
curl -s "https://onsend.xyz/api/v1/leaderboard?limit=50&cursor=dTEyMzQ1" \
  -H "Authorization: Bearer onsend_your_key_here"

GET /api/v1/users/{identifier}

A single user's standing. {identifier} is the user's wallet address (case-insensitive). Returns 404 if no such wallet participates in your tenant.

Query parameters

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

Response

{
  "data": {
    "wallet": "0xabc123...",
    "displayName": "alice",
    "avatarUrl": null,
    "rank": 12,
    "points": 640,
    "window": "all"
  }
}
FieldTypeDescription
walletstring | nullThe user's wallet address.
displayNamestring | nullChosen handle, or null.
avatarUrlstring | nullAvatar URL, or null.
rankinteger | nullRank within the window. null when the user exists but has no activity in a 7d/30d window.
pointsintegerPoints for the selected window.
windowstringEchoes the resolved window (all, 7d, or 30d).
campaignIdstringPresent only when the request was scoped to a campaign.

Example:

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

GET /api/v1/stats

Aggregate, privacy-safe stats for your tenant.

Response

{
  "data": {
    "totalParticipants": 421,
    "totalCampaigns": 7,
    "totalPointsDistributed": 188400,
    "totalQuestsCompleted": 5120
  }
}
FieldTypeDescription
totalParticipantsintegerDistinct participants in the tenant.
totalCampaignsintegerCampaigns in the tenant.
totalPointsDistributedintegerSum of all participants' points.
totalQuestsCompletedintegerVerified quest completions in the tenant.

Future enhancements

  • Per-scope gating. Keys carry a scopes list that v1 does not enforce. A future version may require specific scopes (for example read:leaderboard) per endpoint. v1 keys will keep working.

On this page