PearPear
API IntegrationTrade Activity

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:

OutcomeReturned byMeaning
'synced'bothThe cycle posted, or there was nothing to post.
'deferred'synchronizeFills() onlyThe gateway answered 409 SYNC_CREATE_*. Nothing was written and the watermark did not move, so the next cycle retries the same window.
'aborted'bothThe 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.

EndpointSDK methodDoes
POST /sync/fills/sincesdk.core.sync.fills.sinceReturns the timestamp to fetch venue fills from.
POST /sync/fills/reconcilesdk.core.sync.fills.reconcileReplaces one provisional fill with the sibling fills the venue really printed.
POST /sync/fills/reconcile/bulksdk.core.sync.fills.reconcileBulkThe same, gzipped.
POST /sync/fills/confirmsdk.core.sync.fills.confirmSettles a provisional fill with the venue's own id and fees.
POST /sync/fills/confirm/bulksdk.core.sync.fills.confirmBulkThe same, gzipped.
POST /sync/fills/createsdk.core.sync.fills.createApplies fills Pear does not own, creating or merging positions, then verifies against the venue snapshot.
POST /sync/fills/create/bulksdk.core.sync.fills.createBulkThe same, gzipped.
POST /sync/funding/sincesdk.core.sync.funding.sinceReturns the timestamp to fetch funding history from.
POST /sync/funding/createsdk.core.sync.funding.createUpserts funding payments and advances the funding watermark.
POST /sync/funding/create/bulksdk.core.sync.funding.createBulkThe 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:

ReadWhy
GET /positions/litePear's open positions, with positionKey and exposure.
GET /fills/provisionals-liteThe provisional fills whose cloid decides confirm vs reconcile vs create.
GET /instruments/liteprecision.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"]
}
FieldMeaning
sinceMsFetch venue fills from this instant, in milliseconds since epoch.
neverSyncedtrue only when this account has no checkpoint and nothing to anchor on. sinceMs is then 0, and the whole available history is in scope.
firstSyncStartedAtPresent only when neverSynced is true. A server-clock instant taken before you fetch. Echo it back in meta.firstSyncStartedAt.
confirmedExchangeFillIdsVenue 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:

EndpointMoves its checkpoint to
/sync/fills/reconcile and its bulk twinThe highest timestamp in the payload.
/sync/fills/confirm and its bulk twinThe highest timestamp in the payload.
/sync/fills/create and its bulk twinmeta.externalMaxTs. When that is null, meta.firstSyncStartedAt instead, clamped to the gateway's clock.
/sync/funding/create and its bulk twinmeta.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 sinceMs and retries the same window. This is what makes a 'deferred' outcome safe to ignore.
  • Set meta.externalMaxTs to the timestamp of the newest fill you processed, or null when you fetched none.
  • meta.externalMaxTs must not be more than 300000 ms ahead of the gateway's clock. Beyond that it is a 400. 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.

FromToEndpointWhat changes
PROVISIONALCONFIRMED/sync/fills/confirmThe same row is updated in place with the venue's exchangeFillId, tradeFee, and pearFee. Its id and timestamp do not change.
PROVISIONALRECONCILED/sync/fills/reconcileThe row is retired with zeroed fees, and two or more new CONFIRMED rows replace it.
CONFIRMED/sync/fills/createNew rows, inserted already confirmed.

Two consequences:

  • A fill written by create is never PROVISIONAL. 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 id disappears. RECONCILED rows are never returned by any read endpoint, and the replacements carry new ids. Match on exchangeFillId.

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"
    }
  ]
}
FieldTypeNotes
type"confirm"The literal string. Required.
exchangeFillIdstringThe venue's own id. Non-empty. Must not already exist on this account.
cloidstringThe client order id that identifies the provisional row. Not nullable here.
positionIdUUIDMust equal the key it sits under.
connectorhyperliquid | binance | bybit | okx | lighter
symbolstringThe instrument ID, and it must match the provisional row's.
quantitydecimal stringUnsigned.
timestampintegerMilliseconds since epoch, positive. Recorded as the venue execution time; the fill's own timestamp keeps its provisional value.
tradeFeedecimal stringThe venue's fee. May be negative ("-0.04") where the venue paid a maker rebate.
builderFeedecimal stringUnsigned. 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:

RuleRejected with
At least two siblings per keyReconciliation requires at least two sibling fills
Every sibling's cloid equals the keyReconciliation CLOID mismatch
symbol, connector, and side all match the provisional fillInvalid symbol, Invalid connector, Invalid side
The siblings' quantity sums exactly to the provisional fill's quantityReconciliation quantity mismatch
No exchangeFillId repeats within the requestDuplicate exchange fill IDs
No exchangeFillId already exists on the accountExchange fill IDs already exist
Every key names a provisional fill on this accountInvalid 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.

FieldNotes
positionKeyDeterministic, computed by you. See below. Non-empty.
exposureInstrument ID to signed base-unit size, with an explicit + or - on every value. This is written as given, not recomputed.
originPEAR, EXTERNAL, or PARTIAL.
statusOPEN or CLOSED.
closedReasonNETTED, USER, or LIQUIDATED. Set it when status is CLOSED.
fillsAt least one. Applied in the order you send them, so sort ascending by timestamp.
metadata.initialExposureUSDInstrument 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

FieldTypeNotes
exchangeFillIdstringThe venue's id for a real fill. For a synthetic, a deterministic id you mint. Non-empty.
cloidstring | nullnull for a fill with no client order id.
connectorconnector
symbolstringInstrument ID.
sideBUY | SELL
quantitydecimal stringUnsigned, base units with contractSize already applied.
price, usddecimal stringUnsigned. "0" is reserved for an unpriceable gone-close synthetic.
timestampintegerMilliseconds. Fills replay in this order.
reduceOnlyboolean
syntheticbooleantrue for a bridge fill you reconstructed rather than one the venue printed.
isExternalbooleantrue for activity placed outside Pear.
tradeFeedecimal stringOptional sign. Negative means a maker rebate.
builderFeedecimal stringUnsigned. Stored as pearFee.
closedReasonenumOptional. For example NETTED on a synthetic close fill.
liquidationobject | nullOptional. { "mark": "96150.5" }.
originalobjectOptional. { "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.

FieldNotes
symbolInstrument ID.
sizeUnsigned, base units. Always >= 0.
sideBUY for net long, SELL for net short. The sign lives here, not in size.
entryPrice, markPriceUnsigned 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

FieldRequiredNotes
externalMaxTsyes, nullableThe 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.
newestFillTsnoThe raw newest fill you fetched, never adjusted. The freshness gate measures against this. Send it whenever you send externalMaxTs. Same 300000 ms future limit.
firstSyncStartedAtnoEcho 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 }
}
FieldNotes
instrumentIdInstrument ID. Note the name: instrumentId here, symbol on a fill.
fundingRateThe applied rate. Signed, but the sign is optional: a leading - is meaningful, a leading + is allowed and unusual.
notionalThe funding amount in USD. Negative means you paid.
fundingPerUnitSigned funding per one unit of size, the attribution coefficient.
timestampMilliseconds, positive integer.
meta.externalMaxTsThe 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 typemultipart/form-data
Field namefile, exactly
Field contentThe gzip of the JSON body the non-bulk endpoint would take, byte for byte
Maximum size10 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.

errorCodeStatusCausedetails
SYNC_CREATE_EXECUTION_IN_FLIGHT409A Pear execution is still running on this account, so your snapshot is already stale.executionId
SYNC_CREATE_EXECUTION_JUST_SETTLED409An execution finished moments ago, and its writes are younger than the settle window.executionId
SYNC_CREATE_FILLS_TOO_RECENT409The 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_PROVISIONAL409A 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:

StatuserrorCodeCause
400VALIDATION_ERRORThe body failed the schema. details is an array of { path, message, code }.
400VALIDATION_ERRORA 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.
400VALIDATION_ERRORThe 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.
400Missing or non-UUID x-trade-account-id, or a missing file on a bulk endpoint. Framework error shape, no errorCode.
401Missing or invalid credentials.
403API key does not have write access. All ten endpoints are writes.
404The 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 exchangeFillId Pear already has is a 400, 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.

On this page