# Gumroad subscription cancelled notifications on your phone > Get a push notification when a Gumroad membership or subscription is cancelled, with who cancelled it and when it ends. - Company: Gumroad (https://www.justpush.io/recipes/gumroad) - Event: `cancellation` (cancellation) - Tags: Payments & billing, Subscriptions - Install: https://studio.justpush.io/recipes/gumroad/subscription-cancelled - Web page: https://www.justpush.io/recipes/gumroad/subscription-cancelled ## Setup 1. Click Install in Studio and sign in. The recipe is added to your account and you get a personal webhook URL. 2. In Gumroad, open Settings > Advanced. 3. For sales, paste your webhook URL (shown in Studio after install) into Ping endpoint and save. For other events, subscribe it to the cancellation resource with the API call in the setup notes. 4. Gumroad doesn't sign pings, so keep the URL private. 5. Gumroad's **Ping endpoint** setting only covers sales. For `cancellation` pings, subscribe the URL through Gumroad's API: 6. In Gumroad, open **Settings → Advanced → Applications**, create an application (any name, and any https URL such as `https://example.com` as the redirect URI), then click **Generate access token**. 7. Run this, with your token in place of `YOUR_ACCESS_TOKEN`: 8. ``` 9. curl https://api.gumroad.com/v2/resource_subscriptions \ 10. -d "access_token=YOUR_ACCESS_TOKEN" \ 11. -d "resource_name=cancellation" \ 12. --data-urlencode "post_url=your webhook URL (shown in Studio after install)" \ 13. -X PUT 14. ``` 15. Gumroad pings when the cancellation happens, not when the subscription finally ends (that is the separate `subscription_ended` resource). 16. Pings only arrive while the application and its access token exist, so don't delete or revoke them. Gumroad sends them form-encoded, retries a failed delivery a few times and can deliver the same event twice. 17. Gumroad doesn't sign pings, so keep your endpoint URL private. ## Code Studio calls `handleRequest(request)` with the incoming webhook (`{ method, headers, body, query, raw }`) and sends the message object it returns. Returning `null` sends nothing. ```js // Gumroad → cancellation (resource subscription) // Fires when a membership or subscription is cancelled, by the buyer, by you, by Gumroad, // or automatically after failed payments. // Docs: https://gumroad.com/api#resource-subscriptions // // Gumroad POSTs form-encoded fields such as product_name=…, user_email=…, cancelled=true. // Depending on how the body was parsed, it can arrive as a nested object, a flat object // with bracket keys, or only as the raw string. Every value is a string ("true"). const TOPIC = "Gumroad" // Keys that must never become object properties. const UNSAFE = new Set(["__proto__", "constructor", "prototype"]) // Put a value at a bracket path such as card[visual] or purchase_ids[]. function setPath(target, key, value) { const list = /\[\]$/.test(key) const parts = String(key).replace(/\[\]$/, "").replace(/\]/g, "").split("[") if (parts.some((p) => p === "" || UNSAFE.has(p))) return let node = target for (const part of parts.slice(0, -1)) { if (!Object.prototype.hasOwnProperty.call(node, part) || !node[part] || typeof node[part] !== "object" || Array.isArray(node[part])) node[part] = {} node = node[part] } const last = parts[parts.length - 1] if (!list) node[last] = value else if (Array.isArray(node[last]) && Object.prototype.hasOwnProperty.call(node, last)) node[last].push(value) else node[last] = [value] } // Decode one form-encoded piece; a bad escape shouldn't break the whole body. function decode(value) { try { return decodeURIComponent(String(value).replace(/\+/g, " ")) } catch { return String(value) } } // Turn whatever Studio gives us (object, JSON string or raw form string) into one object. function readForm(request) { let body = request.body if (typeof body === "string" && body.trim().startsWith("{")) { try { body = JSON.parse(body) } catch { body = null } } const out = {} if (body && typeof body === "object" && !Array.isArray(body)) { for (const [key, value] of Object.entries(body)) { if (key.includes("[")) setPath(out, key, value) else if (!UNSAFE.has(key)) out[key] = value } if (Object.keys(out).length) return out } // Fall back to the raw form-encoded string. const raw = typeof body === "string" ? body : typeof request.raw === "string" ? request.raw : "" for (const pair of raw.split("&")) { if (!pair) continue const at = pair.indexOf("=") setPath(out, decode(at < 0 ? pair : pair.slice(0, at)), at < 0 ? "" : decode(pair.slice(at + 1))) } return out } // Read an own field only, so names like "toString" never resolve to something inherited. function field(form, key) { return Object.prototype.hasOwnProperty.call(form, key) ? form[key] : undefined } // Form fields are strings; treat blanks as missing. function text(value) { if (typeof value === "number" && Number.isFinite(value)) return String(value) return typeof value === "string" && value.trim() ? value.trim() : null } // Booleans arrive as "true"/"false" in form bodies and as true/false in JSON. function flag(value) { return value === true || value === "true" } // Only link to real http(s) URLs. function link(url, cta) { return typeof url === "string" && /^https?:\/\//.test(url) ? [{ cta, url }] : [] } // Look a key up without tripping over inherited names such as "toString". function pick(table, key, fallback) { return Object.prototype.hasOwnProperty.call(table, key) ? table[key] : fallback } // Gumroad's recurrence values, in words. const RECURRENCE = { monthly: "monthly", quarterly: "quarterly", biannually: "every 6 months", yearly: "yearly", every_two_years: "every 2 years", } // A date without a clock time, so Studio's time zone doesn't matter. function day(value) { const date = new Date(text(value) ?? "") return Number.isNaN(date.getTime()) ? null : date.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" }) } function handleRequest(request) { const form = readForm(request) const resource = text(field(form, "resource_name")) // Guard: only cancellation pings. if (resource !== "cancellation") { console.log(`Ignoring Gumroad "${resource ?? "unknown"}" ping`) return null } const who = text(field(form, "user_email")) ?? "A subscriber" const recurrence = pick(RECURRENCE, text(field(form, "recurrence")), null) const product = (text(field(form, "product_name")) ?? "your membership") + (recurrence ? ` (${recurrence})` : "") const failed = flag(field(form, "cancelled_due_to_payment_failures")) const bySeller = flag(field(form, "cancelled_by_seller")) const byAdmin = flag(field(form, "cancelled_by_admin")) // cancelled_at is when the subscription stops (the end of the paid period), or when payments failed. const ends = failed ? null : day(field(form, "cancelled_at")) return { title: failed ? "💳 Subscription cancelled after failed payments" : "👋 Subscription cancelled", message: (failed ? `${who}'s ${product} was cancelled because payments failed` : bySeller ? `You cancelled ${who}'s ${product}` : byAdmin ? `Gumroad cancelled ${who}'s ${product}` : `${who} cancelled ${product}`) + (ends ? `. Ends ${ends}.` : ""), topic: TOPIC, // Low when you did it yourself; otherwise Normal — worth knowing, nothing is on fire. priority: bySeller ? -1 : 0, buttons: link("https://app.gumroad.com/customers", "View sales"), } } ``` ## Gumroad cancellation webhook payload (sample) ```json { "user_id": "1234567890123", "cancelled": "true", "created_at": "2026-07-29T09:12:00Z", "product_id": "Qm9vc3RlclRlc3Q2Nzg5MA==", "recurrence": "monthly", "user_email": "jane@example.com", "cancelled_at": "2026-10-29T09:12:00Z", "product_name": "Acme Membership", "purchase_ids": [ "aB3cD5eF7gH9iJ1kL3mN5o==", "pQ7rS9tU1vW3xY5zA7bC9d==" ], "resource_name": "cancellation", "subscription_id": "Pd8wK2mN5qR7sT9vX1zB3g==", "cancelled_by_buyer": "true", "free_trial_ends_at": "", "charge_occurrence_count": "3" } ``` ## FAQ ### Does this work on iPhone and Android? Yes. Install the JustPush app from the App Store or Google Play and sign in. Every phone signed in to your account gets the notification. ### Do I need to write code? No. Install the recipe in Studio, paste your webhook URL into Gumroad and you are done. The code is there if you want to change the text, the sound or the buttons. ### Can I change what the notification says? Yes. After install the recipe's code is yours. Edit it in Studio and test it against the sample payload before you save. ### What does it cost? JustPush is free for 30 days. After that it's $19.99 a year, or $39.99 once. There is no extra charge for recipes.