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:

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

EventFires when
subscription.createdA subscription starts: a first purchase, a free trial, or a new purchase after an earlier one ended
subscription.renewedA period renews. is_trial_conversion is true on the renewal that ends a free trial
subscription.expiredA subscription ends without renewing, or moved to another account (reason: "transferred")
subscription.billing_retryA renewal charge failed and the store is retrying
subscription.grace_periodA renewal charge failed and the store keeps access during a grace period
subscription.on_holdGoogle Play account hold: payment failed past grace, access stops
subscription.pausedGoogle Play pause: access stops until it resumes
subscription.revokedThe current period was refunded or revoked, and access ends now
subscription.transferA 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_changeA plan change, applied now or scheduled for the next renewal (scheduled)
refundA store refunded a period or a one-time purchase. accessRemoved says whether it cost access
refund_reversedApple reversed a refund on appeal. The money is back, and access returns
offer.redeemedAn introductory, promotional, win-back or offer-code price was redeemed (not sent for a CashSDK coupon, which sends coupon.redeemed)
coupon.redeemedA CashSDK coupon was used: the store reported a purchase carrying the coupon's offer. Once per purchase chain
non_renewing_purchaseA one-time purchase (consumable or lifetime unlock) was validated
consumable.balance_changedA 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.

Sample payload#

Every event shares an envelope: a stable id, a type, a created timestamp, and a data object.

json
{
  "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.

json
{
  "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.

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

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

text
send_test_webhook(url="https://api.yourapp.com/hooks/cashsdk", type="purchase.completed")

Next steps#