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.
| Type | Fires when |
|---|---|
charge.succeeded | A charge completed successfully (one-off, subscription first period, or subscription renewal). |
charge.failed | A charge was declined by the payment provider. |
charge.authorized | A manual-capture charge was authorized but not yet captured. The merchant must capture or cancel within capturable_until (7 days). |
charge.captured | A 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.canceled | A 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.created | A 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.completed | A customer finished adding a card through a setup session. |
setup_session.failed | A setup session's card attachment was rejected by the provider. |
setup_session.expired | A pending setup session passed its expiry without being completed. |
payment_method.attached | A payment method was saved to a customer (setup-session completion, sandbox card, or test card). |
payment_method.detached | A customer removed a saved payment method. |
subscription.created | A new subscription was created. Fires for trialing, active, and incomplete subscriptions. |
subscription.updated | A 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_end | Fired ~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.canceled | A subscription moved to the canceled status (immediate cancel, end-of-period rollover, or dunning-retry exhaustion). |
subscription.past_due | A 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.created | A customer was created - via POST /v1/customers or the upsert endpoint when no match was found. |
customer.updated | A customer's details changed - via POST /v1/customers/:id or the upsert endpoint when an existing match was patched. |
customer.deleted | A 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
200as 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.