# Webhooks V2 webhooks are signed synthetic receiver tests, not a lifecycle or refresh feed. Each partner tenant and environment has one separately registered public HTTPS endpoint and signing secret. The credentialed partner can explicitly request exactly three `test:true` shapes: | Type | V2 mode | Safe action after verification | |---|---|---| | `endpoint.test` | test only | no-op | | `catalog.updated` | test only | no-op | | `settlement_summary.finalized` | test only | no-op | No webhook contains or implies a patient, order, prescription, shipment, treatment outcome, or payable amount. ## Delivery contract - At-least-once delivery: duplicates are normal. - Retry window: up to 24 hours in both environments for any non-`2xx` or transport failure. - Retry body and event ID stay stable; timestamp and signature are fresh per attempt. - Ordering is not guaranteed and must not matter. - Any `2xx` acknowledges the event. Response bodies are discarded and redirects are never followed. - One exact pre-registered endpoint per environment; no query, fragment, embedded credential, redirect, or caller-selected destination. V2 emits no live business webhook. Catalog freshness uses HTTP cache validation and production settlement revisions are polled from the isolated finance API. Adding a live event later requires a versioned contract and a durable outbox/reconciliation path reviewed for its stated retry window. There is no handoff, identity, checkout, payment, prescription, order, shipment, or outcome event. ## Endpoint ownership challenge Before Chia enables a new or changed endpoint, it proves the partner controls the exact registered HTTPS route with a separate `POST`. This control-plane probe is **not** a signed webhook event and does not contain the webhook headers below. It cannot authorize business work. Branch on `Chia-Endpoint-Verification-Challenge` before signed-event verification, validate the exact request with `createPartnerWebhookEndpointProof`, and return only the proof header. The current request is canonical UTF-8 JSON with no whitespace. `` is the same 43-character base64url value in the header and body: ```http POST /webhooks/chia HTTP/1.1 Content-Type: application/json Chia-Endpoint-Verification-Challenge: {"challenge":"","object":"endpoint_verification","purpose":"chia_partner_webhook_ownership"} ``` For an exact match, return an empty `2xx` response whose proof is `v1=` followed by the lowercase SHA-256 hex digest of the ASCII challenge: ```http HTTP/1.1 200 OK Cache-Control: no-store Chia-Endpoint-Verification: v1=<64-lowercase-hex-characters> ``` Reject a missing, duplicate, malformed, mismatched, non-canonical, or oversized challenge with `4xx`. Do not log the challenge or body, create a dedupe receipt, enqueue a job, update endpoint state, or call catalog, finance, care, or partner APIs. The response proves route control to Chia; the unsigned request does not authenticate Chia to the partner. ## Signed events: verify before parsing Required headers: | Header | Purpose | |---|---| | `Chia-Webhook-Id` | Stable deduplication key; must match the envelope | | `Chia-Webhook-Timestamp` | Unix seconds included in the signature | | `Chia-Webhook-Signature` | Exactly one `v1=` HMAC value from Chia's active sender secret | | `Chia-Webhook-Type` | Must match the verified envelope type | | `Chia-Version` | Must match the credential-pinned API version | Receiver order is security-critical: 1. Require `application/json` and read the exact body bytes through a bounded reader. Disable framework JSON parsing for this route. Cap ownership probes at 4 KiB and signed events at 128 KiB. 2. If the ownership-challenge header is present, handle only the exact challenge contract above and return. Never fall through to event processing. 3. Otherwise, reject a timestamp more than 300 seconds from the receiver clock. 4. Compute HMAC-SHA256 over `.` and constant-time match the single `v1` value against the active secret or, during coordinated rotation, one retiring secret. 5. Parse the closed envelope; require ID/type/version headers to match it and `livemode` to match the endpoint environment. 6. Require `test: true`, then insert the unique event ID as a no-business-side-effect receipt. 7. Never inspect synthetic catalog or settlement fields to trigger work. 8. Return `2xx` only after the durable receipt commits. ```ts import { createPartnerWebhookEndpointProof, routeVerifiedPartnerWebhook, verifyPartnerWebhook, } from "@chiahealth/partner"; const MAX_ENDPOINT_VERIFICATION_BODY_BYTES = 4 * 1024; const MAX_WEBHOOK_BODY_BYTES = 128 * 1024; class WebhookBodyTooLargeError extends Error {} export async function handleChiaWebhook(request: Request): Promise { const mediaType = request.headers .get("Content-Type") ?.split(";", 1)[0] ?.trim() .toLowerCase(); if (mediaType !== "application/json") { return new Response(null, { status: 415 }); } const isEndpointVerification = request.headers.has( "Chia-Endpoint-Verification-Challenge", ); let rawBody: Uint8Array; try { rawBody = await readCappedBody( request, isEndpointVerification ? MAX_ENDPOINT_VERIFICATION_BODY_BYTES : MAX_WEBHOOK_BODY_BYTES, ); } catch (error) { if (error instanceof WebhookBodyTooLargeError) { return new Response(null, { status: 413 }); } throw error; // transient read/store failures must remain retryable } if (isEndpointVerification) { try { const proof = createPartnerWebhookEndpointProof(rawBody, request.headers); return new Response(null, { status: 200, headers: { "Cache-Control": "no-store", "Chia-Endpoint-Verification": proof, }, }); } catch { return new Response(null, { status: 400, headers: { "Cache-Control": "no-store" }, }); } } let event: ReturnType; try { event = verifyPartnerWebhook(rawBody, request.headers, { environment: "sandbox", secrets: [ process.env.CHIA_WEBHOOK_SECRET_CURRENT!, ...(process.env.CHIA_WEBHOOK_SECRET_RETIRING ? [process.env.CHIA_WEBHOOK_SECRET_RETIRING] : []), ], }); } catch { return new Response(null, { status: 400 }); } await durableInbox.processOnce(event.id, () => routeVerifiedPartnerWebhook(event, { onTest: async () => undefined, }), ); return new Response(null, { status: 200 }); } async function readCappedBody( request: Request, maximumBytes: number, ): Promise { if (!request.body) return new Uint8Array(); const reader = request.body.getReader(); const chunks: Uint8Array[] = []; let length = 0; try { for (;;) { const { done, value } = await reader.read(); if (done) break; length += value.byteLength; if (length > maximumBytes) { await reader.cancel(); throw new WebhookBodyTooLargeError(); } chunks.push(value); } } finally { reader.releaseLock(); } const body = new Uint8Array(length); let offset = 0; for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength; } return body; } ``` `processOnce` must atomically commit only the unique no-op test receipt. The reference receiver is runnable but uses single-process files, so replace its receipt store with a durable conditional-write store in production. V2 has no live handler and must not enqueue or wake a catalog or finance worker. Never put an event ID, catalog version, summary ID, period, signed field, or raw payload into any downstream job. Catalog refresh remains scheduled HTTP revalidation; production finance refresh remains independent polling of immutable releases. ## Framework raw-body adapters > **Certification status:** these adapters are reviewed integration patterns, not evidence of certification for a partner's framework. The partner's actual framework, version, hosting runtime, proxy limits, and middleware order must be confirmed during onboarding. Before either environment is certified, run the chosen adapter against the ownership challenge, all three signed event shapes, a duplicate, stale/bad signatures, the 4 KiB/128 KiB boundaries, and a transient durable-store failure. Each adapter below calls the complete `handleChiaWebhook` implementation above. Keep that function in a server-only module. Do not put a JSON/body parser, request logger, replay recorder, or error reporter that includes headers or bodies in front of this route. ### Express Register `express.raw` for this route **before** any global `express.json()` middleware. A non-JSON request leaves `request.body` unset and is rejected with `415`; the raw-parser size error is converted to `413`. ```ts import express, { type ErrorRequestHandler } from "express"; import type { IncomingHttpHeaders } from "node:http"; import { handleChiaWebhook } from "./chia-webhook.js"; const app = express(); app.post( "/webhooks/chia", express.raw({ type: "application/json", limit: 131_072 }), async (request, response) => { if (!Buffer.isBuffer(request.body)) { response.sendStatus(415); return; } try { const result = await handleChiaWebhook( new Request("https://receiver.invalid/webhooks/chia", { method: "POST", headers: toWebHeaders(request.headers), body: request.body, }), ); response.status(result.status).end(); } catch { response.sendStatus(500); // transient failure; Chia retries } }, ); app.use(express.json()); // only routes registered after the Chia receiver const rawBodyErrors: ErrorRequestHandler = (error, _request, response, next) => { if ((error as { type?: string }).type === "entity.too.large") { response.sendStatus(413); return; } next(error); }; app.use(rawBodyErrors); function toWebHeaders(source: IncomingHttpHeaders): Headers { const target = new Headers(); for (const [name, value] of Object.entries(source)) { if (typeof value === "string") target.set(name, value); else for (const item of value ?? []) target.append(name, item); } return target; } ``` The `.invalid` URL is only an in-memory `Request` adapter; it performs no network call. Never replace it with a forwarded request or accept a destination from the caller. ### Hono For Hono on a Node or Bun server runtime, pass `c.req.raw` directly. Do not call `c.req.json()`, `c.req.text()`, or `c.req.parseBody()` first. The shared handler performs the bounded stream read. ```ts import { Hono } from "hono"; import { handleChiaWebhook } from "./chia-webhook.js"; const app = new Hono(); app.post("/webhooks/chia", async (c) => { try { return await handleChiaWebhook(c.req.raw); } catch { return c.body(null, 500); // transient failure; Chia retries } }); export default app; ``` This SDK uses server-side Node cryptography. Do not deploy this adapter to a browser or an edge runtime whose compatibility has not been certified. ### Next.js App Router Use a Route Handler in the Node runtime, for example `app/api/webhooks/chia/route.ts`. App Router supplies the raw Web `Request`; do not call a body reader before the shared handler. ```ts import { handleChiaWebhook } from "@/server/chia-webhook"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function POST(request: Request): Promise { try { return await handleChiaWebhook(request); } catch { return new Response(null, { status: 500 }); // transient; Chia retries } } ``` The older Pages Router is not the adapter above. If the partner uses it, set `export const config = { api: { bodyParser: false } }`, read and cap the `IncomingMessage` bytes yourself, and certify that separate adapter before use. Never pass `req.body` from the default Pages Router parser to signature verification. ## Test all shapes Use one caller-owned random UUIDv4 per logical test request. Persist it before the first call and reuse it only when retrying the identical body. The SDK rejects a webhook-test call that has neither an explicit `idempotencyKey` nor an `operationId` backed by the client's configured durable `idempotencyStore`. ```ts import { randomUUID } from "node:crypto"; for (const eventType of [ "endpoint.test", "catalog.updated", "settlement_summary.finalized", ] as const) { const idempotencyKey = randomUUID(); await webhookTestOperations.insert({ environment: "sandbox", eventType, idempotencyKey, }); // durable commit before network I/O const accepted = await runtime.createWebhookTest({ eventType, idempotencyKey, }); await webhookTestOperations.markAccepted(idempotencyKey); console.log(eventType, accepted.data.test); // true } ``` `webhookTestOperations` is the partner's durable, non-logging operation store. If the process stops after Chia accepts a request, reload the retained key and retry the same event type; do not create another key unless you intend to create another signed test event. Never place keys or accepted payloads in application logs. An API `202` proves acceptance, not delivery. Confirm each of the three test shapes reached the correct environment receiver and entered the zero-side-effect branch. Production tests intentionally have both `livemode:true` and `test:true`. Test the verifier without credentials using [`webhook-signature-test-vectors.json`](https://partners.chia.health/examples/webhook-signature-test-vectors.json). Test duplicate handling by replaying an identical signed request in a controlled harness; do not store raw production payloads for replay. ## Responses and observability - Return `2xx` only after the durable no-op deduplication receipt commits. - Return `400` for malformed, stale, unsigned, cross-environment, or contract-invalid deliveries. - Return `413` when your bounded reader rejects an oversized body and `415` for a wrong media type. - Return `5xx` only for a transient failure before the durable transaction commits so Chia retries. Safe metrics: synthetic test event type, environment, duplicate/new, response class, latency, receipt-store outcome, and a receiver-owned ephemeral request ID. Never log the raw body, signatures, secrets, request headers/URL, Chia identifiers, summary period, or catalog version. ## Secret rotation Install the new secret in the receiver first while it still accepts the old active secret. Chia then switches its sender to the new secret; every delivery still carries exactly one signature. After verified delivery, delete the old retiring secret. Never configure more than two receiver secrets or mix sandbox/production prefixes. See [Environments](https://partners.chia.health/docs/environments/#rotation).