Partner API
AI index
Launch contract: sandbox is synthetic and cannot charge or create a patient. Use only signed-onboarding origins and credentials.

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. <challenge> is the same 43-character base64url value in the header and body:

POST /webhooks/chia HTTP/1.1
Content-Type: application/json
Chia-Endpoint-Verification-Challenge: <challenge>

{"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/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=<hex> 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 <timestamp>.<exact_raw_body> 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.
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<Response> {
  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<typeof verifyPartnerWebhook>;
  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<Uint8Array> {
  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.

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.

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.

import { handleChiaWebhook } from "@/server/chia-webhook";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function POST(request: Request): Promise<Response> {
  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.

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. 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.