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#
| Event | Fires when |
|---|---|
purchase.completed | A one-time or first subscription purchase is validated |
subscription.renewed | The store bills a renewal successfully |
subscription.canceled | Auto-renew is turned off (access continues until period end) |
subscription.expired | A subscription lapses without renewing |
trial.started | A free trial begins |
refund.issued | Apple or Google refunds or revokes a purchase |
entitlement.granted | An entitlement becomes active (purchase or manual grant) |
entitlement.revoked | An entitlement is removed (expiry, refund, manual) |
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"
}
}
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")