Webhook Basics

CHING POSTs events to URLs you register. Each delivery is signed with your endpoint's secret so you can trust its origin.

Event Shape

{
  "id": "evt_m2n3o4p5q6r7",
  "type": "charge.succeeded",
  "data": {
    "id": "ch_9mTPfRSDmEOU",
    "amount": 9900,
    "currency": "ils",
    "customer": "cus_V8ltq1pK_MWH"
  },
  "livemode": false,
  "created": "2026-04-19T09:15:22.000Z"
}

Verify the Signature

Every delivery includes a Ching-Signature header whose value is HMAC-SHA256(raw_body, endpoint_secret) as a lowercase hex digest. Compute the same on your side and compare with a timing-safe check:

Node.js
import crypto from "node:crypto";

export function verifyChingSignature(
  rawBody: string,
  signature: string,
  secret: string,
): boolean {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(signature, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Use the raw body: Verify against the exact bytes CHING sent, not the JSON-parsed object. Parse after verifying.

Event Types

CHING emits the following events. The data payload on each event mirrors the resource's REST shape, so a charge.succeeded event carries the same object you would get back from GET /v1/charges/:id.

TypeFires when
charge.succeededA charge completed successfully (one-off, subscription first period, or subscription renewal).
charge.failedA charge was declined by the payment provider.
charge.authorizedA manual-capture charge was authorized but not yet captured. The merchant must capture or cancel within capturable_until (7 days).
charge.capturedA previously-authorized charge was captured. Carries amount_captured (may be < amount on partial capture). At this point the receipt is emailed and the tax document issues.
charge.canceledA held authorization was canceled - either by the merchant calling POST /v1/charges/:id/cancel, or automatically by the daily sweep at capturable_until. Payload carries cancellation_reason: requested_by_customer | fraudulent | abandoned | expired.
refund.createdA refund succeeded. Failed refunds are never persisted and never emit a webhook; the original API call returns a 400 with the provider's reason instead.
setup_session.completedA customer finished adding a card through a setup session.
setup_session.failedA setup session's card attachment was rejected by the provider.
setup_session.expiredA pending setup session passed its expiry without being completed.
payment_method.attachedA payment method was saved to a customer (setup-session completion, sandbox card, or test card).
payment_method.detachedA customer removed a saved payment method.
subscription.createdA new subscription was created. Fires for trialing, active, and incomplete subscriptions.
subscription.updatedA subscription's state changed - period rolled after a successful renewal, status flipped (trialing -> active, past_due -> active), or another non-destructive update landed.
subscription.trial_will_endFired ~3 days before a trialing subscription's trial ends. Fires once per trial window - the notified sub is stamped so a later cron tick never re-fires.
subscription.canceledA subscription moved to the canceled status (immediate cancel, end-of-period rollover, or dunning-retry exhaustion).
subscription.past_dueA renewal failed (charge declined or no usable payment method) and the subscription entered past_due. Payload includes retry_attempts and next_retry_at when the charge was declined.
customer.createdA customer was created - via POST /v1/customers or the upsert endpoint when no match was found.
customer.updatedA customer's details changed - via POST /v1/customers/:id or the upsert endpoint when an existing match was patched.
customer.deletedA customer was soft-deleted via DELETE /v1/customers/:id. The record is tombstoned; its charges, documents, and subscriptions are retained.

Subscribe to ["*"] to receive every event.

Subscription lifecycle: a failed first charge in live mode produces charge.failed and subscription.created (with status: "incomplete"). If the customer never completes payment, CHING transitions the sub to incomplete_expired after 23 hours - no webhook is emitted for that transition; the row simply stops being eligible for renewal.
Dunning retries: when a renewal charge fails, the sub enters past_due and CHING retries the card on a 3-day / 7-day / 14-day schedule. Each failed retry fires charge.failed + subscription.past_due (with retry_attempts and next_retry_at fields). After three failed retries the sub is canceled - merchants receive subscription.canceled with reason: "dunning_exhausted". A retry that succeeds flips the sub back to active and fires charge.succeeded + subscription.updated.

Retries

Deliveries time out after 10 seconds. If your endpoint returns a non-2xx or times out, CHING retries up to 3 total attempts via a background cron. To be a good webhook consumer:

  • Return 200 as soon as you have persisted the event id.
  • Do heavy work asynchronously - don't make CHING wait for your database, email provider, or third-party API.
  • Deduplicate by event.id - a retry after a 500 will re-send the same id.