Prefer your agent? The MCP server exposes
register_webhook_endpoint. Describe the URL and events in plain language
and it registers the endpoint and returns the signing secret.
Webhooks
Register an endpoint, verify signed events, and handle retries and idempotency.
Webhooks push purchase, subscription, and entitlement events to your backend the moment they happen, so you can provision access, update records, and trigger email without polling.
Register an endpoint#
Register a publicly reachable HTTPS URL and subscribe it to the events you care about:
curl https://api.cashsdk.com/v1/webhooks \
-H "Authorization: Bearer csk_sk_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.yourapp.com/hooks/cashsdk",
"events": ["purchase.completed", "subscription.renewed", "refund.issued"]
}'
CashSDK returns a signing secret (whsec_...). Store it as an environment
variable; you'll use it to verify every incoming request.
Event types#
These are the events CashSDK sends today. Subscribe to all, or list the ones you handle.
| Event | Fires when |
|---|---|
subscription.created | A subscription starts: a first purchase, a free trial, or a new purchase after an earlier one ended |
subscription.renewed | A period renews. is_trial_conversion is true on the renewal that ends a free trial |
subscription.expired | A subscription ends without renewing, or moved to another account (reason: "transferred") |
subscription.billing_retry | A renewal charge failed and the store is retrying |
subscription.grace_period | A renewal charge failed and the store keeps access during a grace period |
subscription.on_hold | Google Play account hold: payment failed past grace, access stops |
subscription.paused | Google Play pause: access stops until it resumes |
subscription.revoked | The current period was refunded or revoked, and access ends now |
subscription.transfer | A purchase moved between two of your users: restore policy transfer (reason: "restore_policy_transfer"), a deleted account ("previous_owner_deleted"), a store-verified ownership repair ("store_verified_ownership_repair"), or your own call to POST /v1/customers/{app_user_id}/transfer ("merchant_transfer"). Sent to both |
product_change | A plan change, applied now or scheduled for the next renewal (scheduled) |
refund | A store refunded a period or a one-time purchase. accessRemoved says whether it cost access |
refund_reversed | Apple reversed a refund on appeal. The money is back, and access returns |
offer.redeemed | An introductory, promotional, win-back or offer-code price was redeemed (not sent for a CashSDK coupon, which sends coupon.redeemed) |
coupon.redeemed | A CashSDK coupon was used: the store reported a purchase carrying the coupon's offer. Once per purchase chain |
non_renewing_purchase | A one-time purchase (consumable or lifetime unlock) was validated |
consumable.balance_changed | A consumable balance moved because of a purchase or a refund |
Every subscription event carries period_type (trial, intro, promo or normal) for the
period it describes, store, environment, transaction_id and original_transaction_id.
Treat events as a signal to re-read the customer, not as the state itself. They can
arrive out of order and more than once. GET /v1/customers/{id}/entitlements is the
current truth. A refund with accessRemoved: false is an older period's refund:
the customer still has access.
Sample payload#
Every event shares an envelope: a stable id, a type, a created timestamp,
and a data object.
{
"id": "evt_2f9c1a",
"type": "purchase.completed",
"created": 1752787200,
"data": {
"app_user_id": "u_812",
"product_id": "pro_monthly",
"entitlement": "pro",
"transaction_id": "txn_9ab21",
"expires_at": "2026-08-17T00:00:00Z",
"environment": "production"
}
}
coupon.redeemed#
Sent when a store purchase carries one of your coupons' offers: an App Store offer code
(including one typed straight into the App Store) or a Google Play coupon offer. The id is
stable for that redemption, so a redelivery has the same id. Sandbox and TestFlight
purchases are sent too, with environment: "Sandbox"; they never count toward a coupon's
cap. A Family Sharing copy is not a redemption and sends nothing. An existing subscriber who
redeems an App Store code for their next renewal is sent this event when that renewal happens.
{
"id": "evt_0341kMEsSwD8FxT0TqPsBa",
"type": "coupon.redeemed",
"created": 1790467200,
"data": {
"userId": "u_812",
"couponId": "cpn_0341kLp2tQmW3kTSd5fVwD",
"code": "SPRING-25",
"redemptionId": "cred_0341kMEsSwD8FxT0TqPsBa",
"status": "completed",
"platform": "ios",
"productIdentifier": "com.example.pro.monthly",
"transactionId": "2000000912345678",
"originalTransactionId": "2000000912345678",
"priceMinor": 749,
"currency": "USD",
"environment": "Production"
}
}
status is completed, or unreserved for a Google Play purchase of the coupon's offer that
no reservation from your app covered (shown in the dashboard, not counted toward the cap).
userId is null when an App Store code was redeemed before your app identified the user.
Verify the signature#
Each request carries a CashSDK-Signature header, an HMAC-SHA256 of the raw
request body keyed with your signing secret. Compute the HMAC over the
unparsed body and compare in constant time before trusting anything.
import crypto from "node:crypto";
// Use the raw body string, not a re-serialized object.
export function verify(rawBody, signatureHeader, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody, "utf8")
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express example (body captured with express.raw())
app.post("/hooks/cashsdk", (req, res) => {
const ok = verify(
req.body, // Buffer / raw string
req.header("CashSDK-Signature"),
process.env.WEBHOOK_SIGNING_SECRET
);
if (!ok) return res.status(400).send("bad signature");
const event = JSON.parse(req.body);
// ... handle event.type
res.sendStatus(200);
});
Verify against the raw bytes you received. Parsing to JSON and re-stringifying can reorder keys or change whitespace and will break the HMAC.
Retries & idempotency#
Respond with a 2xx quickly (do heavy work asynchronously). If your endpoint
errors, times out, or returns a non-2xx, CashSDK retries with exponential
backoff over several hours.
Because a retry can deliver an event you already processed, treat handlers as
idempotent: dedupe on the event id. Record processed ids and skip repeats.
if (await seen(event.id)) return res.sendStatus(200);
await process(event);
await markSeen(event.id);
Send a test event#
Fire a synthetic event to your endpoint from the dashboard, or with the MCP
send_test_webhook tool, to confirm your signature check and handler before you
depend on live traffic.
send_test_webhook(url="https://api.yourapp.com/hooks/cashsdk", type="purchase.completed")