Skip to content

Kaching Subscriptions Webhooks Documentation

Get notified the moment a subscription contract changes. Kaching delivers a signed POST request to your endpoint for each event you subscribe to, so you can sync subscriptions, trigger emails, update a CRM, or kick off fulfilment without polling the API.

Every delivery is signed with HMAC-SHA256 so you can verify it came from Kaching.

Configure webhooks in the Kaching Subscriptions admin under Settings → Integrations → Webhooks.

For each event you want to receive:

  1. Enter an HTTPS endpoint URL (e.g. https://example.com/webhooks/kaching).
  2. Toggle the event on.

A shop-level signing secret is generated and shown once when you first save — copy it and store it securely. You can reveal, copy, or regenerate it at any time. The same secret signs every event for the shop. Use Send test to deliver a sample payload to a saved endpoint.

Each event is identified by a topic. The topic is sent both in the request body and in the X-Kaching-Topic header.

Topic Fires when
subscription_contract/created A new subscription contract is created.
subscription_contract/paused A contract is paused.
subscription_contract/resumed A paused contract is resumed.
subscription_contract/cancelled A contract is cancelled.
subscription_contract/upcoming_charge The pre-renewal reminder is sent, before a renewal.
billing_attempt/success A renewal payment succeeds.
billing_attempt/failure A renewal payment fails.
order/skipped An upcoming subscription order is skipped.
order/unskipped A previously skipped order is restored.

Webhooks are delivered as an HTTP POST with Content-Type: application/json.

Headers

Header Value
X-Kaching-Topic The event topic, e.g. subscription_contract/created.
X-Kaching-Webhook-Timestamp UNIX timestamp (seconds) when the request was signed.
X-Kaching-Hmac-Sha256 HMAC-SHA256 signature of the request, as 64-char lowercase hex.

Body

The body is a JSON envelope with the topic and an data object:

{
"topic": "subscription_contract/created",
"data": {
/* SubscriptionEventData — see Payload reference */
}
}

Verify every request before trusting it. The signature proves the request came from Kaching and was not tampered with.

The signature is computed over the string `${timestamp}.${rawBody}`, where:

  • timestamp is the value of the X-Kaching-Webhook-Timestamp header, and
  • rawBody is the exact raw request body — the bytes as received, before any JSON parsing or re-serialization.

To verify:

  1. Read the X-Kaching-Webhook-Timestamp and X-Kaching-Hmac-Sha256 headers.
  2. Compute HMAC_SHA256(signingSecret, "${timestamp}.${rawBody}") and hex-encode it.
  3. Compare it to X-Kaching-Hmac-Sha256 using a constant-time comparison.
  4. Reject the request if the timestamp is too old. Compare X-Kaching-Webhook-Timestamp to your current time and drop anything outside a tolerance window — ±5 minutes (300 seconds) is a reasonable default. This is the anti-replay guard: the timestamp is part of the signed string, so an attacker who captures a valid request can’t replay it indefinitely — the signature stays valid but the request “expires” once it falls outside the window. Use a wider window if your server clock may drift relative to Kaching’s.

Example (Node.js)

const crypto = require("crypto");
// `rawBody` must be the raw request body string, NOT a re-serialized object.
function verifyKachingWebhook(rawBody, headers, signingSecret) {
const timestamp = headers["x-kaching-webhook-timestamp"];
const signature = headers["x-kaching-hmac-sha256"];
const expected = crypto
.createHmac("sha256", signingSecret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const valid = crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature),
);
// Anti-replay: reject requests older than 5 minutes (300 seconds). Without
// this, a captured signed request stays valid forever and could be replayed.
// `Math.abs` also tolerates minor clock skew in either direction.
const ageSeconds = Math.abs(
Math.floor(Date.now() / 1000) - Number(timestamp),
);
const TOLERANCE_SECONDS = 300; // 5 minutes — tune to your clock skew
return valid && ageSeconds < TOLERANCE_SECONDS;
}
  • Acknowledge with any 2xx status (200–299). Respond within 10 seconds — the request times out after that. Defer heavy processing to a background job and return quickly.
  • Retries: network errors, timeouts, 429, and 5xx responses are retried. Other 4xx responses are treated as terminal and are not retried — fix a misconfigured endpoint and re-save it.
  • No ordering or idempotency guarantees: deliveries are asynchronous and retries can deliver the same event more than once. Deduplicate on your side — for example, key on contractId + topic + the timestamp header.

The data object is the same vendor-neutral SubscriptionEventData shape for every topic. Below is a full example — exactly what the Send test button delivers for subscription_contract/created.

{
"topic": "subscription_contract/created",
"data": {
"contractId": "44017680677",
"subscriptionCreatedAt": "2026-03-26T20:17:17Z",
"currencyCode": "USD",
"customer": {
"id": "1000000000000",
"email": "example@gmail.com",
"firstName": "John",
"lastName": "Doe",
"displayName": "John Doe",
"lifetimeDuration": "about 2 months",
"locale": "en-US",
"numberOfOrders": 0,
"tags": [
"kaching-has-active-subscription",
"kaching-has-cancelled-subscription",
"kaching-has-paused-subscription",
"kaching-has-payment-failure",
"kaching-subscriber"
],
"isEmailVerified": true
},
"linesCount": 2,
"lines": [
{
"title": "Hat",
"variantTitle": null,
"productId": "10000000000001",
"variantId": "10000000000002",
"quantity": 1,
"variantImageUrl": "https://subscriptions-assets.kachingappz.app/products/hat.jpeg",
"variantImageAltText": null,
"lineDiscountedPrice": 10,
"lineDiscountedPriceFormatted": "$10.00"
},
{
"title": "Blue Shirt",
"variantTitle": null,
"productId": "10200405213477",
"variantId": "51451354415397",
"quantity": 1,
"variantImageUrl": "https://subscriptions-assets.kachingappz.app/products/shirt.jpeg",
"variantImageAltText": null,
"lineDiscountedPrice": 10,
"lineDiscountedPriceFormatted": "$10.00"
}
],
"subtotalPrice": { "amount": 20, "currencyCode": "USD" },
"subtotalPriceFormatted": "$20.00",
"totalShippingPrice": { "amount": 10, "currencyCode": "USD" },
"totalShippingPriceFormatted": "$10.00",
"totalPrice": { "amount": 30, "currencyCode": "USD" },
"totalPriceFormatted": "$30.00",
"nextOrderDate": "2027-11-25T13:00:00.000+02:00",
"humanReadableNextOrderDate": "November 25, 2027",
"nextCycleIndex": 1,
"modifiedOrderDate": null,
"humanReadableModifiedOrderDate": null,
"modifiedCycleIndex": null,
"resumeDate": null,
"humanReadableResumeDate": null,
"deliveryInterval": "MONTH",
"deliveryFrequency": 1,
"billingInterval": "MONTH",
"billingFrequency": 1,
"maxCycles": null,
"minCycles": null,
"isPrepaid": false,
"manageSubscriptionUrl": "https://shopify.com/",
"merchantSupportEmail": "example@gmail.com",
"deliveryMethod": {
"__typename": "SubscriptionDeliveryMethodShipping",
"address": {
"address1": "2515 South Broad Street",
"address2": null,
"city": "Linden",
"province": "New Jersey",
"firstName": "John",
"lastName": "Doe",
"provinceCode": "NJ",
"countryCodeV2": "US",
"zip": "07036",
"phone": null
},
"shippingOption": {
"presentmentTitle": "Standard shipping",
"title": "Standard shipping"
}
},
"paymentMethod": {
"id": "gid://shopify/CustomerPaymentMethod/2374f6125e593465b7ab15f47d971432",
"revokedAt": null,
"revokedReason": null,
"__typename": "CustomerPaymentMethod",
"instrument": {
"__typename": "CustomerCreditCard",
"brand": "visa",
"lastDigits": "4242",
"billingAddress": {
"firstName": "John",
"lastName": "Doe",
"address1": "2515 South Broad Street",
"province": "New Jersey",
"countryCode": "US",
"city": "Linden",
"zip": "07036"
}
}
},
"churnReasons": null,
"churnFreeText": null
}
}
Field Type Description
contractId string Numeric Shopify subscription contract ID.
subscriptionCreatedAt string ISO-8601 timestamp when the contract was created.
currencyCode string ISO currency code, e.g. USD.
customer object Customer details — see below.
linesCount number Number of line items on the contract.
lines array Line items — see below.
subtotalPrice object { amount, currencyCode } — line subtotal before shipping.
subtotalPriceFormatted string Localized subtotal, e.g. $20.00.
totalShippingPrice object { amount, currencyCode } — shipping charge per cycle.
totalShippingPriceFormatted string Localized shipping total.
totalPrice object { amount, currencyCode } — total charged per cycle.
totalPriceFormatted string Localized total.
nextOrderDate string | null ISO-8601 date of the next scheduled order, in the customer’s timezone.
humanReadableNextOrderDate string | null Human-readable next order date, e.g. November 25, 2027.
nextCycleIndex number | null Index of the next billing cycle (0-based).
modifiedOrderDate string | null See conditional fields.
humanReadableModifiedOrderDate string | null See conditional fields.
modifiedCycleIndex number | null See conditional fields.
resumeDate string | null See conditional fields.
humanReadableResumeDate string | null See conditional fields.
deliveryInterval string Delivery interval: DAY, WEEK, MONTH, or YEAR.
deliveryFrequency number Number of intervals between deliveries (e.g. 1 = every 1 month).
billingInterval string Billing interval: DAY, WEEK, MONTH, or YEAR.
billingFrequency number Number of intervals between charges.
maxCycles number | null Maximum number of billing cycles, if capped.
minCycles number | null Minimum number of billing cycles, if committed.
isPrepaid boolean true if the subscription is prepaid.
manageSubscriptionUrl string Customer-facing link to manage the subscription.
merchantSupportEmail string | null Merchant support email, if configured.
deliveryMethod object Shopify delivery method (shipping or pickup), incl. native __typename.
paymentMethod object Shopify customer payment method, incl. native __typename.
churnReasons string[] | null See conditional fields.
churnFreeText string | null See conditional fields.
Field Type Description
id string Numeric Shopify customer ID.
email string Customer email.
firstName string | null First name.
lastName string | null Last name.
displayName string | null Display name.
lifetimeDuration string | null How long they’ve been a customer, e.g. about 2 months.
locale string | null Customer locale, e.g. en-US.
numberOfOrders number | null Lifetime order count.
tags string[] Shopify customer tags.
isEmailVerified boolean | null Whether the email is verified.
Field Type Description
title string Product title.
variantTitle string | null Variant title.
productId string | null Numeric Shopify product ID.
variantId string | null Numeric Shopify variant ID.
quantity number Units per delivery.
variantImageUrl string | null Variant image URL.
variantImageAltText string | null Variant image alt text.
lineDiscountedPrice number Final per-line price after discounts.
lineDiscountedPriceFormatted string Localized per-line price, e.g. $10.00.

Some fields are only populated for specific events; otherwise they are null:

  • modifiedOrderDate, humanReadableModifiedOrderDate, and modifiedCycleIndex are set only for order/skipped and order/unskipped, identifying the cycle that was skipped or restored.
  • resumeDate and humanReadableResumeDate are set only for subscription_contract/paused, and only when the contract was paused for a set number of deliveries: by the customer in the portal, by the merchant in the Kaching admin, or with pausedCycleCount on a Shop API pause. They are the order the subscription comes back on, in the same format as nextOrderDate and humanReadableNextOrderDate. On such an event nextOrderDate and nextCycleIndex are that same order, not the first paused delivery, which is never charged.
  • churnReasons and churnFreeText are set only for subscription_contract/paused and subscription_contract/cancelled, and only when a reason was given: by the customer, or as reason on a Shop API cancel (then churnReasons holds that one text and churnFreeText is null).