For developers / Application APIs
Pepper DEX API
A read-only index over Pepper's logs, serving two generations of pool from two sets of tables. It answers in decimal strings, clamps every list at five hundred rows without saying so, and sends no CORS header at all.
Two DEXes, one API
Pepper has constant-product pairs and concentrated pools side by side. This service serves both, from two sets of tables, under two prefixes: the bare paths are the constant-product ones and everything under /cl/ is concentrated. They are not variants of one another.
The two families are not interchangeable
A constant-product pair has two reserves and one price. A concentrated pool has neither: it has a price, a tick, the liquidity active at that tick, and an unbounded set of ranges that are not active at all. Reading activeLiquidity as depth, or a balance as tradeable size, is wrong in the specific way that costs money - the depth at the current price can be a thousandth of the balance, with the rest sitting in ranges the next trade never touches.
A pool address belongs to one family. Asking /pool/{address} for a concentrated pool is a 404, not a redirect.
A token launched on Pepper has no verified mark until it is on the public token list, which is also where the Pickle apps take a listed token's logo, name and symbol from. Getting there is a pull request with its logo and entry - Token list and address tags has the steps and the criteria.
Base URL and CORS
| Endpoint | Value |
|---|---|
| Public | https://pepper.picklechain.xyz/api/an nginx mount on the Pepper site's origin |
| A stack you run | http://127.0.0.1:4020the service's own port |
| CORS | none at allno header, no OPTIONS handler, no verb but GET |
A browser on another origin cannot call this service
There is no access-control-allow-origin header anywhere in it and no do_OPTIONS to answer a preflight. A fetch from a page on your own domain fails while the identical request from curl or a server succeeds, which is the failure everyone misreads as a network fault. Put it behind your own origin, or call it from a backend.
Every answer is sent no-store with x-content-type-options: nosniff. There is no rate limit in the service: what bounds a caller is the clamp on limit, which is 500 on every list route with a default of 50.
Constant-product routes
/health
GET- Params
- none
- Returns
- {cursorBlock, lagBlocks, updatedAt, spoolHeadBlock, streams:{v2, cl}}
Where each indexer stream has got to, against the head of the spool. The three top-level keys are the constant-product stream, kept there so a caller written before the concentrated pools existed still reads what it meant.
Worth knowing. Check `lagBlocks` before trusting anything else in this service. A stream that has stopped serves its last answer indefinitely and nothing else on any route says so.
/pools
GET- Params
- none
- Returns
- {pools: [...], ethUsd}
Every constant-product pool, ordered by swap count: both tokens' metadata, reserves, the block each reserve was read at, creation block, swap count and cumulative per-side volume.
Worth knowing. An OBJECT, not a bare array - the pools are under `pools`. `volume0`/`volume1` are cumulative amounts PAID IN per side, so they are not comparable across pools and are not a price.
/pool/{pair}
GET- Params
- the pair address in the path, matched case-insensitively
- Returns
- one pool object, plus ethUsd
One pool, in the same shape as a row of /pools.
Worth knowing. It is implemented by building the FULL pool list and scanning it, so it costs exactly what /pools costs. Fetching ten pools one at a time does ten times the work of fetching all of them. 404 when unknown.
/tokens
GET- Params
- none
- Returns
- {tokens: [{address, symbol, decimals, ...}]}
Every token seen on either side of a constant-product pool, which is what a swap picker needs.
Worth knowing. Token metadata is read once per process and cached for the life of that process - not to be fast, but because the node meters `eth_call` through one bucket shared by every client of it. A token that changes its symbol keeps the old one until a restart.
/swaps
GET- Params
- pair, sender, limit
- Returns
- {swaps: [{txHash, logIndex, block, pair, sender, recipient, amount0In, amount1In, amount0Out, amount1Out}]}
Newest first, filterable by pair and by sender, both exact addresses.
Worth knowing. Amounts are decimal strings. A malformed address parameter is a 400, but an address written without its 0x prefix is accepted rather than silently truncated.
/liquidity
GET- Params
- pair, limit
- Returns
- {events: [{txHash, logIndex, block, pair, kind, sender, recipient, amount0, amount1}]}
Mints and burns, newest first, `kind` being one of those two words.
Worth knowing. `recipient` is null on a mint. The pool's Mint event carries no recipient, so the field is left empty rather than filled in from the sender, which would read as a fact.
/stats
GET- Params
- none
- Returns
- {pools, swaps, mints, burns, poolsWithEth, poolsWithoutEth, valueEthWei, valueEthDenominator, valueUsd, ethUsd, concentrated}
Counters for the constant-product side, an ETH-denominated figure for the pools that hold WETH, and the concentrated side's counters nested under `concentrated` rather than added in.
Worth knowing. `valueEthWei` IS NOT TVL and the service says so in its own source. It is the WETH side doubled, summed over the pools that have a WETH side; a pool without one contributes nothing and is counted separately under `poolsWithoutEth`. The concentrated figures are kept apart because the doubling rule does not hold for them at all.
# Health first, always. lagBlocks is the only field that tells you
# whether anything else on this service is current.
curl -s "$DEX_API/health"
# Pools are under a key, not at the top level.
curl -s "$DEX_API/pools" | jq '.pools[0] | {pair, reserve0, reserve1, swapCount}'
# Swaps for one pair. The address may be written with or without 0x.
curl -s "$DEX_API/swaps?pair=$PAIR&limit=100" | jq '.swaps | length'Concentrated routes
/cl/pools
GET- Params
- none
- Returns
- {pools: [...], ethUsd}
Every concentrated pool: fee tier, tick spacing, whether it is initialised, the square-root price, the current tick, the liquidity active at that tick, both balances and the derived human price.
Worth knowing. `activeLiquidity` is the depth AT the current price, not the size of the pool, and `balance0`/`balance1` are the size. They are the same number only in a pool where every position spans the whole range, which is the one shape nobody opens a concentrated pool to build.
- Params
- the pool address in the path
- Returns
- one pool object, plus ethUsd
One concentrated pool. Unlike /pool/{pair} this one is a keyed lookup, so it is cheap.
Worth knowing. 404 when unknown. A malformed address is a 400.
/cl/tiers
GET- Params
- none
- Returns
- {tiers: [{fee, tickSpacing, enabledBlock}]}
The fee tiers the factory has enabled, in fee order. `fee` is in millionths, so 3000 is 0.30 percent.
Worth knowing. One pair can have a pool at every enabled tier, so a tier is part of a pool's identity here rather than a property of the pair.
- Params
- pool, owner, closed, limit
- Returns
- {positions: [{pool, owner, tickLower, tickUpper, liquidity, deposited0/1, withdrawn0/1, collected0/1, lastBlock}]}
Open ranges and their sizes, newest activity first.
Worth knowing. A closed position is kept as a row of zeroes and is HIDDEN unless you pass `closed=1`. And `collected0`/`collected1` are principal and fees together - no event separates them, so subtracting to show fees earned is right only for a fully closed position.
/cl/ticks
GET- Params
- pool (required)
- Returns
- {ticks: [{tick, liquidityGross, liquidityNet}]}
The initialised ticks of one pool, in tick order - the input to a depth chart.
Worth knowing. `pool` is REQUIRED and its absence is a 400, not an empty list. Every tick of every pool in one answer would be a depth chart of nothing, so the service refuses rather than serving it.
/cl/swaps
GET- Params
- pool, sender, limit
- Returns
- {swaps: [{txHash, logIndex, block, pool, sender, recipient, amount0, amount1, sqrtPriceX96, liquidity, tick}]}
Newest first, with the pool's state as of that swap.
Worth knowing. The amounts are SIGNED, as the chain emits them: one side is always negative and that is which way the trade went. Summing them without taking absolute values nets a market to nothing.
/cl/events
GET- Params
- pool, owner, kind, limit
- Returns
- {events: [{txHash, logIndex, block, pool, kind, owner, sender, recipient, tickLower, tickUpper, liquidity, amount0, amount1}]}
Mints, burns and collects, newest first.
Worth knowing. A burn moves no tokens. It credits what is owed inside the pool and waits for a collect, so a burn and its collect are two events and only the second is money moving.
sqrtPriceX96 is the pool's own storage and price is what one whole unit of token0 buys, adjusted for both tokens' decimals and formatted to eighteen significant figures. Computing it yourself from the square root is easy to get subtly wrong: dividing before squaring rounds, and squaring then doubles the error, which is how a price of exactly one comes back as a long string of nines.
What the value figures mean
valueEthWei is not TVL and must not be labelled one
It is the WETH side of a pool, doubled, summed over the pools that have a WETH side. That doubling is the standard way to value a constant-product pool from one side and it is only meaningful because that side is the chain's own gas asset. A pool with no WETH side contributes nothing at all and is counted separately under poolsWithoutEth - so the figure is a lower bound over a subset, not a total.
The concentrated pools are never folded into it. Their two sides are not worth the same, so doubling one of them is a claim the data does not support: a pool whose price has walked above every range holds one token and none of the other. Their figures live under concentrated and under wethBalanceWei, which is undoubled and named for exactly what it is.
Several routes also carry a dollar figure and a price snapshot. Those come from a layer this page does not own - read its own reference for what the number means and how old it is. Two rules here are worth carrying across anyway: the figure is null and never 0 when it cannot be derived, because a zero is a measurement; and a per-token price computed from reserves is not published and must not be inferred.
The indexer behind it
It reads the sequencer's log spool in Postgres, not `eth_getLogs`. The node serves roughly two minutes of logs, so an indexer built on the RPC would see a window and present it as history.
Two streams, two topic sets, two cursors, and they never run in the same pass. The constant-product cursor has long been at the head of the chain; adding the concentrated topics to its filter would have skipped every such log already behind it, permanently and silently, so the concentrated stream starts at zero and backfills on its own.
- It sleeps two seconds between passes once it has caught up, and takes at most 5000 logs per batch; both are environment variables.
- The cursor moves only inside the transaction that wrote the rows, so a crash repeats a batch rather than skipping one.
- Token symbols and decimals are read once per process and cached for its whole life - not as an optimisation but because the node meters
eth_callthrough one bucket shared by every client of it.
The practical consequence is on /health: streams.cl can be far behind streams.v2 and that is normal rather than broken, because the concentrated stream backfills from zero while the other stays at the head. Compare each stream against spoolHeadBlock, not against the other.
Errors
| Status | Meaning |
|---|---|
| 400 | bad parameterany address or number the handler could not parse. The message is always the same string. |
| 404 | unknown pool, or no such routethere is no route table - an unmatched path falls through to this. |
| 503 | index not readya database error, including the ordinary case of the indexer never having run, so the tables do not exist yet. It carries the first line of the driver's own message. |
There are only three, there are no error codes in the body, and the message strings are fixed rather than descriptive. Match on the status. A 400 in particular says nothing about which parameter was rejected.