Partner API
Read live campaigns, enrol traders into your clan, and mint a member's referral link.
Five endpoints, JSON over HTTPS, server-side only. Read live campaigns, put traders into your clan without making them click a link, and hand each member their own referral link inside your own product.
| Setting | Value |
|---|---|
| Base URL | https://api.zaps.wtf/zaps/v1 |
| Auth header | x-api-key: zaps_pk_live_… |
| Rate limit | 120 requests a minute, per key |
Getting a key
Keys are issued by hand, one per clan. Ask in the Telegram group and say which clan you run and what you want to build.
Ask for a keyAuthentication
Send the key in a header. Either form works:
x-api-key: zaps_pk_live_...
Authorization: Bearer zaps_pk_live_...A key in a query string is rejected with partner_key_missing. Keys are environment-bound: a zaps_pk_test_ key does not resolve against mainnet.
Endpoints
| Method | Path | Scope | Purpose |
|---|---|---|---|
| GET | /partner/me | campaigns:read | Your key, clan and scopes |
| GET | /partner/campaigns | campaigns:read | All campaigns you may show |
| GET | /partner/campaigns/{slug} | campaigns:read | One campaign |
| POST | /partner/enroll | clan:enroll | Put traders in your clan |
| POST | /partner/invite-link | clan:referrals | A member's referral link |
GET /partner/me
Tells you what your key is and what it can do. It returns the clan the key is attached to, your invite code, the scopes the key holds, and the rate limit it is given.
Call it once when your service starts. Use it to check the key is live before you depend on it, and to read inviteCode and clanUrl rather than hard-coding either. It takes no parameters and writes nothing.
{
"partner": {
"name": "Your clan",
"keyPrefix": "zaps_pk_live_1a2b3c4d",
"clanSlug": "yourclan",
"clanName": "Your clan",
"clanUrl": "https://app.zaps.wtf/creator/yourclan?invite=zWcURb89",
"clanDisbanded": false,
"inviteCode": "zWcURb89",
"scopes": ["campaigns:read", "clan:enroll", "clan:referrals"],
"rateLimitPerMinute": 120
}
}keyPrefix is safe to log. Put it in your own error reports to identify the key without sending it.
GET /partner/campaigns
Every campaign your key may show, with its current phase, its reward, its live progress figures, and how your own clan is doing in it. This is the endpoint your integration polls to render a campaign and keep its numbers current.
The list route returns them all. The detail route, GET /partner/campaigns/{slug}, returns one by slug in the identical shape, for when you already know which campaign you are rendering.
The list returns { partner, campaigns[], meta }. The detail route returns { campaign } with an identical object, or 404 campaign_not_found. Both send Cache-Control: private, max-age=30.
{
"id": "afa636f0-4869-43c4-b6d1-7bc759c5ea43",
"slug": "the-aerodrome-campaign",
"name": "The Aerodrome campaign",
"summary": "10,000,000 $AEROSTRAT, shared out among everyone who has ever traded AERO.",
"url": "https://app.zaps.wtf/campaign/the-aerodrome-campaign",
"registerUrl": "https://app.zaps.wtf/campaign/the-aerodrome-campaign?invite=zWcURb89",
"status": "registration",
"statusLabel": "Registration open",
"phase": {
"current": "registration",
"registrationOpensAt": "2026-09-08T22:44:39.107Z",
"liveAt": "2026-09-13T22:30:57.000Z",
"endsAt": "2026-09-20T22:30:57.000Z"
},
"sponsor": { "name": "Aerodrome" },
"chain": { "name": "base", "chainId": 8453 },
"token": { "address": "0x940181a9…fd98631", "symbol": "AERO", "decimals": 18 },
"reward": {
"symbol": "AEROSTRAT",
"poolAmount": "10000000",
"isPlaceholder": false,
"disclaimer": null
},
"split": { "leaderPct": 20 },
"eligibility": { "mode": "lifetime_aero", "description": "Anyone who has ever traded AERO on Base." },
"progress": { "registeredTraders": 944, "unlockedPct": 0 },
"yourClan": { "registered": 4, "traders": 0, "rank": null, "note": null },
"meta": { "cacheSeconds": 30 }
}| Field | Type | Notes |
|---|---|---|
id | uuid | Stable. Use as your join key, not slug. |
slug | string | URL-safe name. This is what you pass as campaign to enrol. |
summary | string | One sentence, safe to render as-is. |
url | string | Campaign page, no attribution. |
registerUrl | string | The link to publish. Same page, your invite code attached. |
status | enum | approved · registration · live · verifying · ended |
statusLabel | string | The same value in words. |
phase.* | ISO 8601 or null | UTC. Any date may be null before it is set. |
reward.poolAmount | string | Whole tokens as a decimal string. It does not always fit a JS number. |
reward.isPlaceholder | bool | true means the reward token is a stand-in. Show the disclaimer, or do not publish the campaign. |
reward.disclaimer | string or null | The line to show when isPlaceholder is true. |
split.leaderPct | int | Percentage of the pool paid to clan leaders. |
eligibility.description | string | Plain sentence, printable as-is. mode is the machine value. |
progress.registeredTraders | int | Headcount across all clans. Real from the first sign-up. |
progress.unlockedPct | number | 0–100. |
yourClan.registered | int | Your people who have registered. Show this during registration. |
yourClan.traders | int | Your people who have traded and been scored. 0 until live. |
yourClan.rank | int or null | null until someone registers. |
yourClan.note | string or null | Plain-English fallback when there is nothing to rank. |
meta.cacheSeconds | int | How long the payload is held. Polling faster returns identical bytes. |
Poll once a minute or less often. Nothing in this feed returns a wallet address.
POST /partner/enroll
Puts traders into your clan, and into a campaign if you name one. Give it a wallet address and it finds or creates that person's account, joins them to your clan, and registers them.
Use it when you already hold the addresses, so your users do not have to click a link to take part. One call takes a single person or up to 100. It is idempotent, so re-running the same call is safe and creates nothing twice.
Request
Send exactly one of wallet or users. campaign sits on the envelope and applies to every person in the call.
// one person
{ "wallet": "0x7b21…b194", "xHandle": "someone", "campaign": "the-aerodrome-campaign" }
// up to 100
{ "campaign": "the-aerodrome-campaign",
"users": [ { "wallet": "0x7b21…b194", "xHandle": "someone" }, { "wallet": "0x9f04…1234" } ] }| Field | Required | Notes |
|---|---|---|
wallet | yes* | ^0x[0-9a-fA-F]{40}$. *Or users, never both. |
users[] | yes* | 1–100 items, each { wallet, xHandle? }. |
xHandle | no | 1–64 chars. Stored as a label only; never resolved into an identity. An X handle alone cannot enrol anyone. |
campaign | no | A campaign slug, 1–128 chars. Omit to enrol into the clan only. |
Response
Always 200 with the same shape, single or batch. Results are positional, so results[i] answers users[i], and wallet is echoed lowercased.
{
"enrolled": 1,
"failed": 1,
"clanSlug": "yourclan",
"campaign": "the-aerodrome-campaign",
"results": [
{ "wallet": "0x7b21…b194", "ok": true,
"createdAccount": true, "joinedClan": true,
"primaryClan": "set", "registered": true,
"campaignClan": "ours", "confirmed": false },
{ "wallet": "0x9f04…1234", "ok": false,
"error": "own_clan",
"message": "That wallet belongs to the clan leader" }
]
}| Field | Values | Notes |
|---|---|---|
ok | bool | Everything below is present only when true. |
createdAccount | bool | false means the address already had an account and we used it untouched. |
joinedClan | bool | true also on a re-run of an existing member. |
primaryClan | set · already_ours · left_alone | left_alone means it already pointed at another clan and was not taken. |
registered | bool | Absent if you named no campaign. |
campaignClan | ours · someone_else · none | Which clan this campaign registration is credited to. Read this, not registered, to know whether the campaign counts for you. |
confirmed | bool | false until that person signs in themselves. Unconfirmed members are on your roster but are not scored. |
Errors
Request-level failures are 400 in the standard envelope and nothing is enrolled.
| code | Cause |
|---|---|
wallet_required | Neither wallet nor users sent. |
wallet_and_users | Both sent. |
wallet_invalid | An address is not 0x + 40 hex. Rejects the whole call. |
batch_empty | users was an empty array. |
batch_too_large | More than 100 items. |
batch_over_rate_limit | The call costs more than your whole per-minute limit. Not charged, so a resized retry has the full budget. |
invalid_request | Another field failed validation. The message names it. |
campaign_not_found | No campaign with that slug. |
campaign_closed | That campaign is not taking registrations. |
partner_has_no_clan | Your key is not attached to a clan. |
partner_clan_disbanded | The clan your key belongs to has been disbanded. |
Everything else is per item. The call is 200 even if every item failed. The outcome lives in each ok.
| error | Cause | Action |
|---|---|---|
own_clan | The address is your own clan leader's. | Drop it. |
burn_address | The address is 0x0000…0000. | Drop it, or fill it in. |
clan_join_pending | Your clan is private; the join is queued. | Approve it, or make the clan public and re-run. |
account_failed clan_join_failed registration_failed | Our side. | Retry those items. |
Treat an unrecognised error as a retryable per-item failure. The list is not closed. A later step failing keeps what the earlier ones did. registration_failed means the person is in your clan but has no campaign seat. Retrying an item is always safe.
POST /partner/invite-link
Returns one of your clan members' own referral link, plus how that link has performed. Use it to show a member their personal invite link inside your product.
One person per call, named by wallet address. The first call mints their code; every call after returns the same one, so it is safe to call on every page render. The response is no-store.
{ "wallet": "0x1f2e3d4c5b6a7988796a5b4c3d2e1f0091827364" }{
"wallet": "0x1f2e…7364",
"clanSlug": "yourclan",
"inviteCode": "k7Qm2Vd9",
"inviteUrl": "https://app.zaps.wtf/creator/yourclan?invite=k7Qm2Vd9",
"invites": 4,
"zapsEarned": 40,
"rewardPerInvite": 10,
"dailyRewardCap": 10
}| Field | Notes |
|---|---|
inviteCode | Stable per member. |
inviteUrl | Ready to render. Opens your clan page with the join set up. |
invites | People who have joined your clan through their link, ever. |
zapsEarned | What the link has earned that member. |
rewardPerInvite | Current payout for one successful invite. |
dailyRewardCap | Most invites paid to one member per UTC day. |
| HTTP | code | Cause |
|---|---|---|
| 400 | wallet_invalid | wallet missing or malformed. |
| 400 | partner_has_no_clan | Your key is not attached to a clan. |
| 400 | partner_clan_disbanded | That clan has been disbanded. |
| 403 | scope_denied | Your key lacks clan:referrals. |
| 404 | not_a_member | Nobody in your clan holds that address. Enrol them first, then ask again. |
The address goes in the body, not the path, so it stays out of access and proxy logs. Checksummed or lower-case both work; responses are lower-case.
GET /clans/{slug}/members
Public. No key, no scope.
Lists who is in a clan. Use it to render your roster, and to confirm that an enrolment landed.
100 rows a page, oldest join first. When nextCursor comes back non-null, pass it back verbatim as ?cursor= for the next page; do not build a cursor yourself. Cached public, s-maxage=15.
{
"items": [
{ "handle": "yourclan", "name": "Your clan", "avatarUrl": "https://…",
"role": "kol", "identityType": "x", "xConnected": true,
"traderScore": 0, "traderTier": "Unproven", "zapsBalance": 0,
"walletAddress": null, "pendingConfirmation": false, "minted": false,
"joinedAt": "2026-09-09T18:52:16.309Z" },
{ "handle": null, "name": null, "avatarUrl": null,
"role": "member", "identityType": "wallet", "xConnected": false,
"traderScore": 36, "traderTier": "Unproven", "zapsBalance": 146,
"walletAddress": "0x7b21…b194", "pendingConfirmation": true, "minted": false,
"joinedAt": "2026-08-27T18:43:47.607Z" }
],
"memberCount": 4,
"unclaimedCount": 0,
"nextCursor": null
}| Field | Notes |
|---|---|
identityType | x has handle, name and avatar, and walletAddress is null. wallet has no handle, so the address is the public identity and is returned in full. |
pendingConfirmation | true means you enrolled them and nobody has signed in yet. They are a real member and count in memberCount. |
memberCount | Real members, whether X, wallet or partner-enrolled. This is the number to show. |
unclaimedCount | Placeholder rows nobody enrolled and nobody has signed into. Not members. |
This route returns full wallet addresses for wallet-only members. Render them shortened (0x7b21…b194). Do not export, index, cross-reference or forward them. If you only need counts, use memberCount here and yourClan.registered in the campaign feed instead.
Errors
Every error on /partner/* is the same shape, including field validation:
{ "error": { "code": "partner_key_invalid",
"message": "That key is not valid, has expired, or has been revoked" } }| HTTP | code | Action |
|---|---|---|
| 400 | malformed_json | Body is not valid JSON. |
| 401 | partner_key_missing | No key sent, or sent in the query string. |
| 401 | partner_key_invalid | Unknown, expired or revoked. Do not retry. |
| 403 | scope_denied | Key lacks the scope. The message names it. Do not retry. |
| 404 | campaign_not_found | Unknown slug. |
| 404 | not_found | Campaigns are switched off our side. Retry with backoff. |
| 429 | partner_rate_limited | Honour Retry-After. |
Route-level 404s, meaning a path that matches no endpoint or the wrong method, return the framework's { statusCode, error, message } instead. Check the status, not the code.
Notes
Server-side only. api.zaps.wtf sends no CORS headers for partner origins. Call it from your backend and cache the result.
No webhooks. Poll the feed.
No removals. Leaving a clan is the member's own action; no key can do it for them.
The same address twice in one batch is two results and counts twice in enrolled. De-duplicate before reconciling against your own headcount.
Scopes
A route your key lacks the scope for returns 403 scope_denied. GET /partner/me lists what you hold.
| Scope | Opens |
|---|---|
campaigns:read | /partner/me, /partner/campaigns, /partner/campaigns/{slug} |
clan:enroll | POST /partner/enroll |
clan:referrals | POST /partner/invite-link |
Rate limit
120 requests a minute, counted per key across all your servers. Enrolment is charged per person, not per request, so a 100-item batch costs 100. Over the limit is 429 with a Retry-After header in seconds; honour it rather than guessing.
Ask us
Key rotation, extra scopes, a higher rate limit, or anything the doc does not answer.
Telegram group