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
POSTwith a JSON body. - Verify the authentication header (HMAC signature or bearer token — see Authentication).
- Be idempotent on
flow_ids— CoinTracker may re-deliver the same export on retry. - Return:
- 2xx — delivery accepted, will not retry.
- 4xx — permanent failure, will not retry. Use for "I know this is bad and you're not going to fix it by retrying" cases.
- 5xx — transient failure, will retry (5 attempts, exponential backoff).
- Handle pagination — large users may produce multiple POSTs for the same
cost_basis_run_id.
Body shape
{
"flow_ids": ["..."],
"cost_basis_run_id": "...",
"ghost_user_id": "...",
"user_config": { },
"partner_config": { }
}
flow_idsstring[]requiredIdentifiers for the individual flows included in this delivery. Use this as your idempotency key — if you've already processed a flow ID in a previous delivery, skip it. CoinTracker may include the same flow ID across retries.
cost_basis_run_idstringrequiredIdentifier for the cost-basis calculation run that produced this delivery. A single run may be split across multiple webhook POSTs (pagination).
ghost_user_idstringrequiredCoinTracker's internal identifier for the user. Stable across deliveries for the same partner user — useful as a join key if you store CoinTracker IDs alongside your own.
user_configobjectUser-level configuration captured during onboarding (cost basis method, tax year, etc.). Shape is partner-specific — your CoinTracker integration owner will share the exact schema for your integration.
partner_configobjectPartner-level configuration applied to this user (your partner slug, plan tier, feature flags). Shape is partner-specific.
Authentication
Two options. You pick which one during kickoff.
HMAC-SHA256 (default)
CoinTracker signs the raw request body with a shared secret. You verify the signature against the X-Cointracker-Signature header.
Verification (Node.js example):
import crypto from 'node:crypto';
function verifyCointrackerSignature(
rawBody: Buffer,
receivedSignature: string,
sharedSecret: string,
): boolean {
const expected = crypto
.createHmac('sha256', sharedSecret)
.update(rawBody)
.digest('hex');
// Use timing-safe equality to avoid timing-attack leaks.
const expectedBuf = Buffer.from(expected);
const receivedBuf = Buffer.from(receivedSignature);
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.
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.
Retries and idempotency
CoinTracker retries on 5xx responses 5 times with exponential backoff. The same flow_ids may be delivered more than once across attempts.
Implementation pattern:
async function handleCointrackerWebhook(req, res) {
// 1. Verify signature against raw body. Reject on mismatch.
if (!verifySignature(req.rawBody, req.headers['x-cointracker-signature'], SECRET)) {
return res.status(401).end();
}
const { flow_ids, cost_basis_run_id, ghost_user_id, user_config, partner_config } = req.body;
try {
// 2. Idempotency: skip flow_ids you've already processed.
const newFlowIds = await filterUnprocessedFlowIds(flow_ids);
if (newFlowIds.length === 0) {
return res.status(200).end(); // already processed — return 2xx
}
// 3. Do your partner-side import logic.
await importFlows({ newFlowIds, cost_basis_run_id, ghost_user_id, user_config, partner_config });
// 4. Record processed flow_ids before returning 2xx.
await markFlowIdsProcessed(newFlowIds);
return res.status(200).end();
} catch (err) {
// Transient error — let CoinTracker retry.
logger.error('webhook handler failed', { err });
return res.status(500).end();
}
}
Don't return 4xx for transient failures. A 4xx tells CoinTracker not to retry — if your database is briefly unavailable, return 5xx and let CoinTracker try again. 4xx is for "this delivery is permanently rejected" (invalid signature, malformed payload, your account has been deactivated).
Pagination
For users with a lot of transaction history, a single cost_basis_run_id may be split across multiple webhook POSTs. Each POST contains a subset of flow_ids for the same cost_basis_run_id.
Treat each POST independently for processing — the idempotency-on-flow_ids pattern above handles it correctly without needing to wait for "all pages received." If you need to know when a cost_basis_run_id is fully delivered, coordinate with your integration owner — there isn't a public "last page" signal in the payload itself.
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 a 5xx on the first delivery during staging tests to verify your retry path. The recommended sequence:
- Walk a fresh user through onboarding in staging.
- Have your handler return 500 on the first POST it sees.
- Observe CoinTracker retry on the documented backoff schedule.
- Switch the handler back to its real logic — the next retry should succeed.
- Confirm
flow_idswere processed exactly once.
See Production rollout for the partner-side monitoring you should set up before going live.