Syncing Venue Activity
Import fills and funding you placed directly on the exchange, so Pear's positions match the venue.
A trade account is an exchange account, and you can trade it without Pear. When you do, Pear's ledger and the venue's disagree until someone tells Pear what happened. The POST /sync/fills/* and POST /sync/funding/* endpoints are how you tell it.
The sync is client-driven. Pear does not poll the exchange for you: per-IP rate limits at the venues make that impossible at scale. Your client reads the venue with your own credentials, works out what Pear is missing, and posts it.
This is what produces the EXTERNAL and PARTIAL values of a position's origin, and the isExternal flag on a fill. See Synthetic Position and Fills.
Most integrations never call these endpoints. If you write TypeScript, use the syncer in @pear-protocol/exchanges-sdk and skip to What gets rejected. The raw endpoints are documented for callers who cannot.
Use the SDK syncer
createSyncer(connection) returns an AccountSyncer that runs a whole cycle for you: it reads Pear's state, reads the venue, diffs them, and posts the result across the sync endpoints.
import PearSDK from '@pear-protocol/core-sdk';
import { ExchangesSDK } from '@pear-protocol/exchanges-sdk';
const exchanges = new ExchangesSDK({ sdk });
const connection = await exchanges.connect(tradeAccountId);
const syncer = exchanges.createSyncer(connection);
const fills = await syncer.synchronizeFills();
const funding = await syncer.synchronizeFunding();Both methods return an outcome rather than throwing on the normal failure paths:
| Outcome | Returned by | Meaning |
|---|---|---|
'synced' | both | The cycle posted, or there was nothing to post. |
'deferred' | synchronizeFills() only | The gateway answered 409 SYNC_CREATE_*. Nothing was written and the watermark did not move, so the next cycle retries the same window. |
'aborted' | both | The exchange fetch failed. Nothing was posted. Retry on the next tick. |
'deferred' and 'aborted' are both normal. Neither is an error to alert on unless it repeats.
Anything else still throws: a rejected post, a validation failure, or a 409 that is not a SYNC_CREATE_* deferral. Only the exchange read is guarded.
The two cycles are independent. Funding rows are immutable and upsert idempotently, so run synchronizeFunding() on its own cadence rather than pairing it with every fill cycle.
connect() opens a connection from a trade account. The rest of that SDK is in Venue Accounts.
The endpoints
Ten endpoints, all POST, all scoped to one trade account through x-trade-account-id. All ten are writes, so a read API key gets 403.
| Endpoint | SDK method | Does |
|---|---|---|
POST /sync/fills/since | sdk.core.sync.fills.since | Returns the timestamp to fetch venue fills from. |
POST /sync/fills/reconcile | sdk.core.sync.fills.reconcile | Replaces one provisional fill with the sibling fills the venue really printed. |
POST /sync/fills/reconcile/bulk | sdk.core.sync.fills.reconcileBulk | The same, gzipped. |
POST /sync/fills/confirm | sdk.core.sync.fills.confirm | Settles a provisional fill with the venue's own id and fees. |
POST /sync/fills/confirm/bulk | sdk.core.sync.fills.confirmBulk | The same, gzipped. |
POST /sync/fills/create | sdk.core.sync.fills.create | Applies fills Pear does not own, creating or merging positions, then verifies against the venue snapshot. |
POST /sync/fills/create/bulk | sdk.core.sync.fills.createBulk | The same, gzipped. |
POST /sync/funding/since | sdk.core.sync.funding.since | Returns the timestamp to fetch funding history from. |
POST /sync/funding/create | sdk.core.sync.funding.create | Upserts funding payments and advances the funding watermark. |
POST /sync/funding/create/bulk | sdk.core.sync.funding.createBulk | The same, gzipped. |
The eight write endpoints answer 200 with an empty object, {}. There is no id, no count, and no per-row result. Read the outcome back from GET /positions and GET /fills.
The venue always comes from the trade account. connector is required on the fill and payment objects, but the value stored is the trade account's own.
One fill cycle
Order matters. Reconcile and confirm first, create last. Both settle fills Pear already owns, and create then applies what is left and checks the result against the venue's position snapshot. Settling first is what keeps a fill Pear placed from being imported a second time as a foreign one.
You also need three read endpoints to build the payload:
| Read | Why |
|---|---|
GET /positions/lite | Pear's open positions, with positionKey and exposure. |
GET /fills/provisionals-lite | The provisional fills whose cloid decides confirm vs reconcile vs create. |
GET /instruments/lite | precision.quantity.contractSize, to normalize venue sizes to base units. |
The watermark
POST /sync/fills/since answers the only question you cannot answer yourself: how far back to read the venue.
curl -X POST "https://pro-gateway.pearprotocol.io/sync/fills/since" \
-H "x-api-key: $PEAR_API_KEY" \
-H "x-trade-account-id: $TRADE_ACCOUNT_ID" \
-H "content-type: application/json" \
-d '{ "overlapMs": 60000 }'{
"sinceMs": 1767225540000,
"neverSynced": false,
"confirmedExchangeFillIds": ["42917301", "42917305"]
}| Field | Meaning |
|---|---|
sinceMs | Fetch venue fills from this instant, in milliseconds since epoch. |
neverSynced | true only when this account has no checkpoint and nothing to anchor on. sinceMs is then 0, and the whole available history is in scope. |
firstSyncStartedAt | Present only when neverSynced is true. A server-clock instant taken before you fetch. Echo it back in meta.firstSyncStartedAt. |
confirmedExchangeFillIds | Venue fill ids at or after sinceMs that Pear already holds as confirmed. Absent when the list is empty. |
overlapMs is required, and must be a positive integer. The gateway subtracts it from the reference timestamp and floors the result at 0, so the window deliberately reaches back behind what has already been imported. That overlap is what recovers a fill the venue published late.
Because the window overlaps, you will re-read fills you already imported. Drop every fill whose id is in confirmedExchangeFillIds before you build the payload. Compare ids case-insensitively, after trimming. That list is the only deduplication the watermark gives you.
sinceMs === 0 alone is ambiguous: a clamped watermark produces it too. Branch on neverSynced, not on the zero.
How it advances
Pear keeps one checkpoint per phase. Each write endpoint moves only its own:
| Endpoint | Moves its checkpoint to |
|---|---|
/sync/fills/reconcile and its bulk twin | The highest timestamp in the payload. |
/sync/fills/confirm and its bulk twin | The highest timestamp in the payload. |
/sync/fills/create and its bulk twin | meta.externalMaxTs. When that is null, meta.firstSyncStartedAt instead, clamped to the gateway's clock. |
/sync/funding/create and its bulk twin | meta.externalMaxTs. |
POST /sync/fills/since then takes the earliest of the reconcile, confirm, and create checkpoints, and pulls the result back further to the oldest provisional fill still outstanding. So one lagging phase holds the window open for all three, and an unsettled provisional fill keeps its own window open until it settles.
With no checkpoint at all, the window anchors on the oldest open position or the oldest provisional fill, whichever is earlier. With none of those either, it is 0 and neverSynced is true.
POST /sync/funding/since reads the funding checkpoint alone.
Three rules follow:
- A rejected call advances nothing. The next cycle sees the same
sinceMsand retries the same window. This is what makes a'deferred'outcome safe to ignore. - Set
meta.externalMaxTsto the timestamp of the newest fill you processed, ornullwhen you fetched none. meta.externalMaxTsmust not be more than 300000 ms ahead of the gateway's clock. Beyond that it is a400. The margin absorbs clock drift; a seconds-instead-of-milliseconds mistake overshoots it by orders of magnitude, which is the point.
POST /sync/funding/since is the same call with a smaller answer. Funding has no positions or provisional fills to anchor on, so the watermark is the only reference:
{ "sinceMs": 1767225540000 }The fill lifecycle
A fill has three statuses. Fills covers what each one means for reading; this is what moves between them.
| From | To | Endpoint | What changes |
|---|---|---|---|
PROVISIONAL | CONFIRMED | /sync/fills/confirm | The same row is updated in place with the venue's exchangeFillId, tradeFee, and pearFee. Its id and timestamp do not change. |
PROVISIONAL | RECONCILED | /sync/fills/reconcile | The row is retired with zeroed fees, and two or more new CONFIRMED rows replace it. |
| — | CONFIRMED | /sync/fills/create | New rows, inserted already confirmed. |
Two consequences:
- A fill written by
createis neverPROVISIONAL. Only Pear's own order placement writes a provisional row, because only Pear knows a client order id before the venue prints against it. Everything you import is confirmed on arrival. - A reconciled fill's
iddisappears.RECONCILEDrows are never returned by any read endpoint, and the replacements carry new ids. Match onexchangeFillId.
Confirm
Keyed by position id. Each value is a non-empty array of fills, and every fill in it must repeat that same positionId.
curl -X POST "https://pro-gateway.pearprotocol.io/sync/fills/confirm" \
-H "x-api-key: $PEAR_API_KEY" \
-H "x-trade-account-id: $TRADE_ACCOUNT_ID" \
-H "content-type: application/json" \
-d @confirm.json{
"9c1b2d3e-4f56-4789-a0b1-c2d3e4f56789": [
{
"type": "confirm",
"exchangeFillId": "42917338",
"cloid": "0x9f2c4a7e10b3486d",
"positionId": "9c1b2d3e-4f56-4789-a0b1-c2d3e4f56789",
"connector": "hyperliquid",
"symbol": "0",
"quantity": "0.0052",
"timestamp": 1767225601240,
"tradeFee": "0.22",
"builderFee": "0.05"
}
]
}| Field | Type | Notes |
|---|---|---|
type | "confirm" | The literal string. Required. |
exchangeFillId | string | The venue's own id. Non-empty. Must not already exist on this account. |
cloid | string | The client order id that identifies the provisional row. Not nullable here. |
positionId | UUID | Must equal the key it sits under. |
connector | hyperliquid | binance | bybit | okx | lighter | |
symbol | string | The instrument ID, and it must match the provisional row's. |
quantity | decimal string | Unsigned. |
timestamp | integer | Milliseconds since epoch, positive. Recorded as the venue execution time; the fill's own timestamp keeps its provisional value. |
tradeFee | decimal string | The venue's fee. May be negative ("-0.04") where the venue paid a maker rebate. |
builderFee | decimal string | Unsigned. Stored as the fill's pearFee. A negative value is rejected. |
The object must hold at least one key.
Reconcile
Keyed by client order id. Each value is an array of two or more sibling fills that all carry that same cloid. Use it when one order Pear placed came back from the venue as several prints.
{
"0x9f2c4a7e10b3486d": [
{
"exchangeFillId": "42917338",
"cloid": "0x9f2c4a7e10b3486d",
"connector": "hyperliquid",
"symbol": "0",
"price": "96150.5",
"quantity": "0.0030",
"side": "BUY",
"reduceOnly": false,
"timestamp": 1767225601240,
"tradeFee": "0.13",
"builderFee": "0.03",
"liquidation": null
},
{
"exchangeFillId": "42917341",
"cloid": "0x9f2c4a7e10b3486d",
"connector": "hyperliquid",
"symbol": "0",
"price": "96152.0",
"quantity": "0.0022",
"side": "BUY",
"reduceOnly": false,
"timestamp": 1767225601310,
"tradeFee": "0.09",
"builderFee": "0.02",
"liquidation": null
}
]
}Every sibling must agree with the provisional fill it replaces, and with the others:
| Rule | Rejected with |
|---|---|
| At least two siblings per key | Reconciliation requires at least two sibling fills |
Every sibling's cloid equals the key | Reconciliation CLOID mismatch |
symbol, connector, and side all match the provisional fill | Invalid symbol, Invalid connector, Invalid side |
The siblings' quantity sums exactly to the provisional fill's quantity | Reconciliation quantity mismatch |
No exchangeFillId repeats within the request | Duplicate exchange fill IDs |
No exchangeFillId already exists on the account | Exchange fill IDs already exist |
| Every key names a provisional fill on this account | Invalid cloids |
liquidation is required, and nullable. Send null for an ordinary fill, or { "mark": "96150.5" } when the venue liquidated the position at that mark.
Applying external fills
POST /sync/fills/create is the endpoint that actually imports activity. It takes three things: the baskets of fills to apply, the venue's current position snapshot, and the cycle metadata.
{
"applicables": [
{
"positionKey": "1e0f4d2c9b8a7f6e5d4c3b2a190807060504030201f0e0d0c0b0a09080706050",
"exposure": { "0": "+0.0052", "1": "-0.1620" },
"origin": "EXTERNAL",
"status": "OPEN",
"fills": [
{
"exchangeFillId": "42917402",
"cloid": null,
"connector": "hyperliquid",
"symbol": "0",
"side": "BUY",
"quantity": "0.0052",
"price": "96150.5",
"usd": "500.00",
"timestamp": 1767225601240,
"reduceOnly": false,
"synthetic": false,
"isExternal": true,
"tradeFee": "0.22",
"builderFee": "0"
},
{
"exchangeFillId": "42917403",
"cloid": null,
"connector": "hyperliquid",
"symbol": "1",
"side": "SELL",
"quantity": "0.1620",
"price": "3086.2",
"usd": "500.00",
"timestamp": 1767225601310,
"reduceOnly": false,
"synthetic": false,
"isExternal": true,
"tradeFee": "0.21",
"builderFee": "0"
}
],
"metadata": {
"initialExposureUSD": { "0": "+500.00", "1": "-500.00" }
}
}
],
"exchangePositions": [
{ "symbol": "0", "size": "0.0052", "side": "BUY", "entryPrice": "96150.5", "markPrice": "96420.0" },
{ "symbol": "1", "size": "0.1620", "side": "SELL", "entryPrice": "3086.2", "markPrice": "3081.4" }
],
"meta": {
"externalMaxTs": 1767225601310,
"newestFillTs": 1767225601310
}
}applicables and exchangePositions may both be empty arrays. meta is required.
The applicable
One applicable is one target position. Pear finds the position by positionKey and merges into it, or creates it when there is none.
| Field | Notes |
|---|---|
positionKey | Deterministic, computed by you. See below. Non-empty. |
exposure | Instrument ID to signed base-unit size, with an explicit + or - on every value. This is written as given, not recomputed. |
origin | PEAR, EXTERNAL, or PARTIAL. |
status | OPEN or CLOSED. |
closedReason | NETTED, USER, or LIQUIDATED. Set it when status is CLOSED. |
fills | At least one. Applied in the order you send them, so sort ascending by timestamp. |
metadata.initialExposureUSD | Instrument ID to signed USD cost basis, explicit sign required. Read only when the position is created, and ignored on a merge. A partial map is allowed. |
Your client is the authority on exposure, origin, and status. The gateway writes them without recomputing, then checks the total against exchangePositions. A flip is two applicables sharing one positionKey, {"status": "CLOSED"} first and {"status": "OPEN"} second, in that order.
positionKey is the lowercase hex SHA-256 of this exact JSON, with the two keys in this order and the symbols sorted ascending by string comparison:
{"tradeAccountId":"<trade account id, lowercased>","symbols":["0","1"]}No connector, and no side. Two positions on the same asset set therefore share a key, which is what lets Pear find the one correct target.
The applicable fill
| Field | Type | Notes |
|---|---|---|
exchangeFillId | string | The venue's id for a real fill. For a synthetic, a deterministic id you mint. Non-empty. |
cloid | string | null | null for a fill with no client order id. |
connector | connector | |
symbol | string | Instrument ID. |
side | BUY | SELL | |
quantity | decimal string | Unsigned, base units with contractSize already applied. |
price, usd | decimal string | Unsigned. "0" is reserved for an unpriceable gone-close synthetic. |
timestamp | integer | Milliseconds. Fills replay in this order. |
reduceOnly | boolean | |
synthetic | boolean | true for a bridge fill you reconstructed rather than one the venue printed. |
isExternal | boolean | true for activity placed outside Pear. |
tradeFee | decimal string | Optional sign. Negative means a maker rebate. |
builderFee | decimal string | Unsigned. Stored as pearFee. |
closedReason | enum | Optional. For example NETTED on a synthetic close fill. |
liquidation | object | null | Optional. { "mark": "96150.5" }. |
original | object | Optional. { "exchangeFillId", "quantity" } of the venue print this fill was split from. |
Fees are never verified against the venue, and they flow straight into portfolio, TCA, and leaderboard numbers. Send what the venue reported.
The snapshot is the referee
exchangePositions is the venue's own open-position list, normalized to base units. It is not advisory. After applying your fills, Pear folds every non-closed position on the account into a per-instrument total and compares it to this snapshot. Any mismatch on any instrument rolls the entire cycle back and answers 400, with a message naming the instrument and both numbers.
| Field | Notes |
|---|---|
symbol | Instrument ID. |
size | Unsigned, base units. Always >= 0. |
side | BUY for net long, SELL for net short. The sign lives here, not in size. |
entryPrice, markPrice | Unsigned decimal strings, as the venue reports them. |
So the snapshot must be read from the venue in the same cycle as the fills, and the fills you send must explain the difference between what Pear holds and what the snapshot says. Where they cannot, send a synthetic fill to bridge the gap.
Cycle metadata
| Field | Required | Notes |
|---|---|---|
externalMaxTs | yes, nullable | The newest fill timestamp you processed, in ms, or null when you fetched none. Advances the watermark. Rejected when more than 300000 ms ahead of server time. |
newestFillTs | no | The raw newest fill you fetched, never adjusted. The freshness gate measures against this. Send it whenever you send externalMaxTs. Same 300000 ms future limit. |
firstSyncStartedAt | no | Echo of firstSyncStartedAt from /sync/fills/since. Send it on a first sync so fills that arrive during your fetch are not skipped. |
Keep newestFillTs and externalMaxTs separate. If you ever advance externalMaxTs past the real newest fill, the freshness gate would measure the adjusted value and let through a cycle whose fills have not settled.
Funding
POST /sync/funding/create takes the funding events since the funding watermark. Rows upsert idempotently on trade account, connector, instrument, and timestamp, so re-posting the overlap window is free.
{
"payments": [
{
"connector": "hyperliquid",
"instrumentId": "0",
"fundingRate": "0.0000125",
"notional": "-0.62",
"fundingPerUnit": "-119.23",
"timestamp": 1767225600000
}
],
"meta": { "externalMaxTs": 1767225600000 }
}| Field | Notes |
|---|---|
instrumentId | Instrument ID. Note the name: instrumentId here, symbol on a fill. |
fundingRate | The applied rate. Signed, but the sign is optional: a leading - is meaningful, a leading + is allowed and unusual. |
notional | The funding amount in USD. Negative means you paid. |
fundingPerUnit | Signed funding per one unit of size, the attribution coefficient. |
timestamp | Milliseconds, positive integer. |
meta.externalMaxTs | The newest payment timestamp, or null. Same 300000 ms future limit as fills. |
payments may be an empty array. That writes no rows, and still advances the funding watermark when meta.externalMaxTs is set.
Until an account has funding history, GET /portfolio/analytics reports config.fundingSynced: false and its funding figures are unknown, not zero. See Trade History.
The bulk variants
Four endpoints take the same payload as a gzipped file upload instead of a JSON body: /sync/fills/reconcile/bulk, /sync/fills/confirm/bulk, /sync/fills/create/bulk, and /sync/funding/create/bulk. The gateway forwards the compressed bytes to a worker, which decompresses and validates them off the shared API.
| Content type | multipart/form-data |
| Field name | file, exactly |
| Field content | The gzip of the JSON body the non-bulk endpoint would take, byte for byte |
| Maximum size | 10 MB compressed |
| Response | {}, the same as the non-bulk endpoint |
gzip -c payload.json > payload.json.gz
curl -X POST "https://pro-gateway.pearprotocol.io/sync/fills/create/bulk" \
-H "x-api-key: $PEAR_API_KEY" \
-H "x-trade-account-id: $TRADE_ACCOUNT_ID" \
-F "file=@payload.json.gz"Do not set content-type yourself. The multipart boundary has to come from your HTTP client.
A missing or empty file field is a 400 with the framework error shape and a message naming the field, such as Missing gzipped create payload in the "file" field.
The SDK switches automatically: it measures the JSON body and uses the bulk path above 50 KB. Below that, the plain endpoint is cheaper. Neither since endpoint has a bulk variant, because each takes a single field.
What gets rejected
The four 409s are the ones to build for. They apply to /sync/fills/create and its bulk twin only; reconcile, confirm, and both funding endpoints have no such gate. None of the four is a rejection of your payload. Nothing was written, the watermark did not move, and the same request will succeed once the account settles. Back off and retry.
errorCode | Status | Cause | details |
|---|---|---|---|
SYNC_CREATE_EXECUTION_IN_FLIGHT | 409 | A Pear execution is still running on this account, so your snapshot is already stale. | executionId |
SYNC_CREATE_EXECUTION_JUST_SETTLED | 409 | An execution finished moments ago, and its writes are younger than the settle window. | executionId |
SYNC_CREATE_FILLS_TOO_RECENT | 409 | The newest venue fill is too fresh. The venue's position and fill endpoints settle at different speeds, and diffing them within seconds of a trade invents corrections that are not real. | newestFillTs, minFillAgeSeconds, both as strings |
SYNC_CREATE_CLOID_ALREADY_PROVISIONAL | 409 | A fill in your payload carries a client order id Pear holds as provisional. Pear's own fill reached the ledger after you read the provisional list, so you filed it as foreign. | cloid |
Read minFillAgeSeconds from details rather than hard-coding a wait. It is configuration, not contract.
SYNC_CREATE_FILLS_TOO_RECENT reaches the plain endpoint before anything runs. On the bulk endpoint the freshness check happens in the worker, after decompression, because the timestamps are inside the gzip. The other three behave identically on both paths.
Everything else:
| Status | errorCode | Cause |
|---|---|---|
400 | VALIDATION_ERROR | The body failed the schema. details is an array of { path, message, code }. |
400 | VALIDATION_ERROR | A cross-check failed: a duplicate or already-known exchangeFillId, a cloid that names no provisional fill, a sibling that disagrees with the fill it replaces, or a positionId that does not match its key. message names which. |
400 | VALIDATION_ERROR | The verify step found computed exposure and the venue snapshot disagreeing. message reads Sync verify mismatch for <symbol>: computed <a> != snapshot <b>. The whole cycle rolled back. |
400 | — | Missing or non-UUID x-trade-account-id, or a missing file on a bulk endpoint. Framework error shape, no errorCode. |
401 | — | Missing or invalid credentials. |
403 | — | API key does not have write access. All ten endpoints are writes. |
404 | — | The trade account is not yours. |
See Error Handling for both body shapes.
Two rejections worth calling out, because they look like bugs and are not:
- A confirm or reconcile whose
exchangeFillIdPear already has is a400, not a no-op. The endpoints are not idempotent on retry. Refresh the provisional list and rebuild the payload rather than replaying the old one. - A verify mismatch rolls back fills that were individually valid. The apply and the check share one transaction. A partial import is never left behind.