Webhooks
Signed, retried with backoff, replay-protected, and testable before your receiver exists.
Register an endpoint with POST /v1/webhooks (https only, a public host; private and loopback addresses are refused at registration and again by resolution at delivery). The signing secret (whsec_…) is returned once. Endpoints belong to one environment: sandbox endpoints receive sandbox events, live endpoints live events.
| Event | When |
|---|---|
calculation.completed | A calculation finished |
connection.activated | A client connected a profile to your application |
connection.revoked | A connection ended (by the client or by you) |
records.imported | A batch of records landed in a connected profile |
key.revoked | An API key was revoked |
webhook.test | A test delivery you asked for |
Verify a delivery
Every delivery carries Taxpio-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, plus Taxpio-Delivery-Id and Taxpio-Event. Recompute the HMAC over the raw body with your secret, compare in constant time, and refuse a t older than 300 seconds. Answer any 2xx within eight seconds; anything else is retried.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyTaxpio(secret, header, rawBody, now = Math.floor(Date.now() / 1000)) {
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(now - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === expected.length / 2 && timingSafeEqual(given, Buffer.from(expected, "hex"));
}import hmac, hashlib, time
def verify_taxpio(secret: str, header: str, raw_body: bytes, now: int | None = None) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
t = int(parts["t"])
if abs((now or int(time.time())) - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(parts.get("v1", ""), expected)Retries and the sink
A failed delivery is retried after 1m, 5m, 30m, 2h, 12h, then marked dead. An endpoint that fails 25 deliveries in a row is disabled; enable it again from the console. Redirects are never followed. POST /v1/webhooks/{id}/test queues a webhook.test delivery; GET /v1/webhooks/{id}/deliveries shows every attempt with its status, the endpoint's last answer and the next attempt.
Before your receiver exists, register a sandbox endpoint whose URL is https://api.taxpio.com/v1/sandbox/webhook-sink/pending: TaxPio rewrites it to the endpoint's own id, delivers to itself, verifies its own signature with your endpoint's secret, and shows the outcome in the trace and the console.
{
"id": "evt_…", "object": "event", "type": "calculation.completed", "created": "2026-09-06T08:19:32.797Z",
"environment": "sandbox", "workspace_id": "…", "attempt": 1,
"data": { "calculation": { "id": "calc_…", "jurisdiction": "CA", "program": "T1", "tax_year": 2026, "scope": "IN_SCOPE", "rule_package": "CA-T1-PREP-2026.1" } }
}