# Partner API (/partner-api)



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 [#getting-a-key]

Keys are issued by hand, one per clan. Ask in the Telegram group or email [ajay@zeru.finance](mailto:ajay@zeru.finance), and say which clan you run and what you want to build.

<OpenApp href="https://t.me/zerufinancediscussions">
  Ask for a key
</OpenApp>

## Authentication [#authentication]

Send the key in a header. Either form works:

```http
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 [#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 [#get-partnerme]

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.

```json
{
  "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 [#get-partnercampaigns]

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`.

```json
{
  "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 [#post-partnerenroll]

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 [#request]

Send exactly one of `wallet` or `users`. `campaign` sits on the envelope and applies to every person in the call.

```json
// 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 [#response]

Always 200 with the same shape, single or batch. Results are positional, so `results[i]` answers `users[i]`, and `wallet` is echoed lowercased.

```json
{
  "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 [#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 [#post-partnerinvite-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`.

```json
{ "wallet": "0x1f2e3d4c5b6a7988796a5b4c3d2e1f0091827364" }
```

```json
{
  "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 [#get-clansslugmembers]

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`.

```json
{
  "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 [#errors-1]

Every error on `/partner/*` is the same shape, including field validation:

```json
{ "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 [#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 [#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 [#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 [#ask-us]

Key rotation, extra scopes, a higher rate limit, or anything the doc does not answer. Ask in the Telegram group or email [ajay@zeru.finance](mailto:ajay@zeru.finance).

<OpenApp href="https://t.me/zerufinancediscussions">
  Telegram group
</OpenApp>

<MachineNote>
  Five endpoints · JSON over HTTPS · server-side only
</MachineNote>
