Webhooks
The contract on this page reflects the spec your CoinTracker integration owner shared during kickoff. If anything here differs from the credentials and URLs they sent you, trust those.
When a user completes onboarding inside the iframe, CoinTracker calculates their cost basis and delivers the result to your backend via HTTPS webhook. The iframe doesn't ship the tax data through the post-robot bridge — it's too large and your backend is the right place to receive it.
This page documents what you need to build on your side.
High-level shape
Receiver requirements
You stand up an HTTPS endpoint at the URL you registered with CoinTracker during kickoff. It must:
- Accept
POSTwithContent-Type: application/json. - Verify the authentication header (HMAC signature or bearer token — see Authentication).
- Be idempotent on
txn_id— CoinTracker may re-deliver the same transaction across retries or pages. - Return:
- 2xx — delivery accepted.
- Anything else — delivery is considered failed. CoinTracker does not automatically retry on 4xx or 5xx HTTP responses; only on network errors and timeouts (see "Retries" below).
- Handle pagination — large users will produce multiple POSTs for the same workflow run. Use
execution_id,page_number, andis_last_pageto coordinate.
Body shape
{
"batch_timestamp": 1735689600,
"partner_user_id": "your-stable-user-id",
"event_type": "transaction_lots",
"api_version": "1.0.0",
"execution_id": "embedded-export-workflow-...",
"page_number": 1,
"is_last_page": false,
"transactions": [
{
"txn_id": "your-system's-txn-id",
"cointracker_txn_id": "ct-internal-id",
"transaction_type": "TRANSFER",
"flows": [ /* ... */ ]
}
]
}
batch_timestampnumberrequiredUNIX timestamp (seconds) when this batch snapshot was generated. Use it as a tiebreaker to avoid overwriting newer batches with older ones if deliveries arrive out of order.
partner_user_idstringrequiredThe same stable user identifier you put in the JWT's partner_user_id claim. Use this to join the webhook payload to the user in your system.
event_typestringrequiredEvent type identifier. Currently always "transaction_lots". May expand in future API versions.
api_versionstringrequiredSchema version of the payload. Currently "1.0.0". Branch on this if you need to support multiple shapes across an upgrade window.
execution_idstringrequiredWorkflow run identifier — the same value across every page of a single export. Use it (with page_number / is_last_page) to know which pages belong together.
page_numbernumberrequired1-indexed page number within this execution_id.
is_last_pagebooleanrequiredtrue only on the final page of a given execution_id. Use this if you need to trigger a "import complete" action after the last page lands.
transactionsobject[]requiredThe transactions delivered in this page. Each item carries txn_id (your system's transaction ID, suitable for idempotency), cointracker_txn_id (CoinTracker's internal ID), transaction_type, and flows (asset movements with cost-basis lot details — null if cost basis is not yet supplied). The exact shape of flows is partner-specific; your integration owner will share the schema for your integration.
Authentication
Two options. You pick which one during kickoff.
HMAC-SHA256 (default)
CoinTracker signs the message <timestamp>:<raw-body> with your shared secret, base64-encodes the result, and sends two headers:
X-Webhook-Signature— base64-encoded HMAC-SHA256 of<timestamp>:<raw-body>.X-Webhook-Timestamp— the Unix timestamp (seconds) used in the signed message.
Verification (Node.js example):
import crypto from 'node:crypto';
function verifyCointrackerSignature(
rawBody: Buffer,
timestampHeader: string,
signatureHeader: string,
sharedSecret: string,
): boolean {
const message = `${timestampHeader}:${rawBody.toString('utf8')}`;
const expected = crypto
.createHmac('sha256', sharedSecret)
.update(message)
.digest('base64');
const expectedBuf = Buffer.from(expected);
const receivedBuf = Buffer.from(signatureHeader);
if (expectedBuf.length !== receivedBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, receivedBuf);
}
Verify against the raw body bytes, not the parsed JSON. If your framework auto-parses JSON before the handler runs, the re-serialized form may differ from what CoinTracker signed (key ordering, whitespace) and the signature won't match. In Express, use express.raw({ type: 'application/json' }) on this route; in Hono, use c.req.arrayBuffer(); etc.
Reject deliveries with a stale X-Webhook-Timestamp. A reasonable freshness window is 5 minutes — outside that, treat the request as a replay and reject it.
Bearer token
If you have existing M2M auth infrastructure (Auth0 M2M, OAuth client-credentials, mTLS-fronted services), use bearer auth instead. CoinTracker sends:
Authorization: Bearer <token>
You verify the token against your expected issuer / audience using whatever library your platform uses (jose, jsonwebtoken, your auth provider's SDK).
The bearer audience value is provided during kickoff. CoinTracker may refresh the bearer token and retry once on a 401 response — make sure your token endpoint is reachable.
Retries and idempotency
CoinTracker retries delivery on network errors and request timeouts — up to 5 attempts with exponential backoff (5s minimum wait, 5min maximum wait, 2× multiplier between attempts). Per-attempt request timeout is 30 seconds.
CoinTracker does not automatically retry on 4xx or 5xx HTTP status responses. If you return a non-2xx, the delivery is considered failed for that workflow run and will not be re-driven automatically.
The same transaction may still appear more than once across deliveries (e.g. across pages, or because an admin manually re-runs the export), so your handler must remain idempotent on txn_id.
Implementation pattern:
async function handleCointrackerWebhook(req, res) {
// 1. Verify signature against raw body.
if (!verifySignature(req.rawBody, req.headers['x-webhook-timestamp'], req.headers['x-webhook-signature'], SECRET)) {
return res.status(401).end();
}
const { execution_id, page_number, is_last_page, transactions, partner_user_id } = req.body;
try {
// 2. Idempotency: skip txn_ids you've already processed.
const newTransactions = await filterUnprocessedTxnIds(transactions);
if (newTransactions.length === 0) {
return res.status(200).end(); // already processed — return 2xx
}
// 3. Do your partner-side import logic.
await importTransactions({ partner_user_id, execution_id, transactions: newTransactions });
// 4. Record processed txn_ids before returning 2xx.
await markTransactionsProcessed(newTransactions.map((t) => t.txn_id));
// 5. If this is the final page, trigger any post-import work.
if (is_last_page) {
await onExportComplete({ partner_user_id, execution_id });
}
return res.status(200).end();
} catch (err) {
logger.error('webhook handler failed', { err });
return res.status(500).end();
}
}
Pagination
For users with a lot of transaction history, a single export run is split across multiple webhook POSTs. Every POST in the same run carries the same execution_id; pages are 1-indexed via page_number, and the final page is marked is_last_page: true.
Treat each POST independently for processing — the idempotency-on-txn_id pattern above handles re-deliveries correctly without needing to wait for "all pages received." Use is_last_page as your trigger if you need to know when a run is complete (e.g. flipping a status, emitting an event downstream).
Local testing
Two patterns:
- Tunnel your local dev server with ngrok, Cloudflare Tunnel, or similar. Register the tunnel URL with CoinTracker as your dev-environment webhook URL. Run end-to-end flows in
options.mode: 'alpha'; deliveries hit your laptop. - Force an error on the first delivery during staging tests to verify your retry/replay path. The recommended sequence:
- Walk a fresh user through onboarding in staging.
- Have your handler return a non-2xx (or close the connection) on the first POST it sees.
- Coordinate with your CoinTracker contact to manually re-trigger the export.
- Switch the handler back to its real logic — the redelivery should succeed.
- Confirm
txn_ids were processed exactly once across both attempts.
See Production rollout for the partner-side monitoring you should set up before going live.