Skip to main content
This page covers the domain model behind the API types, enough to read any response correctly before you write a line of integration code. For the reward math itself, see Reward Calculation.

Campaign Identity

A campaign is identified by a compound key, not a single scalar ID:
In URL paths and query strings the key serializes to "chainId:campaignIndex", so { chainId: 8453, campaignIndex: 56 } becomes "8453:56". The SDK does that conversion for you; every method takes the object form.
There is no string overload. Passing "8453:56" to an SDK method will not typecheck. Always pass { chainId, campaignIndex }.
Each campaign also exposes configHash, the keccak256 commitment of its configuration that was submitted on-chain. If you want to verify a campaign’s parameters independently, recompute the hash and compare.

Event Chain Versus Reward Chain

Two chains matter for every campaign, and confusing them is the most common integration bug. They are the same for most campaigns. They differ for cross-chain campaigns, such as positions tracked on Polygon with rewards claimed on Worldchain. The operating rule:
  • Filtering campaigns by vault or pool address? Use the event chain.
  • Connecting a wallet to claim? Use the reward chain.

Lifecycle and Claimability

Claimability is governed by published merkle roots, not by status. Rewards become claimable when a root covering them is published on the reward chain, which happens periodically while a campaign runs, not only at the end. Never gate a claim button on status === "active": ended campaigns stay claimable, and finalized means everything earned is claimable. Gate on claimable > 0n and let the amount decide.
For the business-facing view of these phases (what a creator can change in each, and when budget becomes recoverable), see Campaign Lifecycle.

Open-Ended Campaigns

Most campaigns are fixed-end: totalRewards is spread evenly from startTime to endTime. An open-ended campaign (openEnded: true) instead pays out at a fixed rate until one of three things happens: the budget runs out, endTime passes, or the campaign is cancelled. Its creator can top up the budget while it runs. Top-ups extend how long the campaign runs. They never raise the rate, so they never raise the APY. The rate is also a ceiling: an apyCap mode can pay less than emissionRate. To show the daily emission:

Runway

Three fields, on both campaigns.get and campaigns.stats, say how long an open-ended campaign will keep paying:
Do not use endTime as an open-ended campaign’s end date. It is a ceiling, often months away. Show estimatedEndDate as “ends around”, or fundedUntil as “funded until at least”, and hide the reward APR once budgetExhausted is true.

Reading Accrual From the API

Rewards are computed off-chain by Rabbithole’s indexers from on-chain events. You never reproduce the math; you read a field. Two properties of the accrual engine matter when you build a UI:
  • Earnings are continuous and already exact. accumulatedRewards reflects every second up to the latest on-chain event or price checkpoint. There is no per-second feed to poll.
  • There is no retroactive credit. Time a user was not holding is time they did not accumulate, which is why, at equal size, an earlier holder out-earns a later one.
For priced targets such as prediction-market shares or LP positions, balance is the position’s value, not its raw token count, and it drifts with price checkpoints even when the user does not trade. Plain ERC-20 targets count raw token units.

Modes as Data

campaign.modes is a keyed object. A key is present only when that mode is enabled, and {} means a plain pro-rata campaign. Modes compose, so a campaign can carry several at once. Units. *Bps fields are basis points (1250 = 12.5%). *Usd fields are USD with 6 decimals. Bare balance fields are raw target-token units. earningCap.maxRewards is the exception: it caps rewards, so it is denominated in reward-token units. For what each mode is for (the campaign-design view rather than the data view), see Campaign Modes.

Targets

campaign.target describes the position being tracked. Its shape varies by tokenStandard, and fields that do not apply to a standard are omitted entirely rather than set to null: Match on target.address for ERC-20 style targets, on target.poolId for Uniswap v4 pools, and on target.protocol plus target.marketKey for Ledger targets. tokenStandard is typed as a plain string so new standards do not break your build; skip targets you do not recognise. protocol also appears on some ERC-20 targets to mark a protocol position token, for example "beefy-clm" or "erc7540" (the share token of an async vault). shareModel on a v4 target says how an LP’s slice is computed. Treat it as an open string rather than a closed set:
The SDK type currently narrows shareModel to "weighted" | "tvl", but the API returns "fees" on live v4 campaigns. Do not write an exhaustive switch over it; fall through on values you do not recognise.

What Discovery Returns

campaigns.list and campaigns.active exclude campaigns that require a Forwarder deposit unless you pass includeForwarderRequired: true. That keeps integrations from surfacing campaigns their users cannot earn from. campaigns.get returns any campaign regardless of the flag.
Discovery also omits some internal Rabbithole-operated campaigns from browse results.
This is a visibility convention for partner discovery, not an authorization boundary. If you already have a campaign ID, campaigns.get and campaigns.stats still work, and rewards.forUser and claims.get still return that user’s financial data.

Keep Exploring

How Claiming Works

Merkle roots, cumulative amounts, and the three reward numbers.

Data Conventions

Wire formats, bigint amounts, and pagination.

Forwarder Deposits

Routing deposits for campaigns that require them.