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.

How it works

Three steps to your first push

STEP 01

Install the recipe

One click in Studio gives you a personal webhook URL.

STEP 02

Paste your URL into Gumroad

Add it as a webhook for cancellation.

STEP 03

Get a push on your phone

With the text, sound and buttons from the recipe.

Setup

How to set up the Gumroad cancellation webhook

  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 into Ping endpoint and save. For other events, subscribe it to the cancellation resource with the API call in the setup notes.https://••••••••/•••••••• your personal URL, shown after install
  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" \
  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.
The code

What runs when the webhook arrives

Studio calls handleRequest(request) with the incoming webhook and sends the message it returns. It's yours after install; change anything.

transform.js
1// Gumroad → cancellation (resource subscription)
2// Fires when a membership or subscription is cancelled, by the buyer, by you, by Gumroad,
3// or automatically after failed payments.
4// Docs: https://gumroad.com/api#resource-subscriptions
5//
6// Gumroad POSTs form-encoded fields such as product_name=…, user_email=…, cancelled=true.
7// Depending on how the body was parsed, it can arrive as a nested object, a flat object
8// with bracket keys, or only as the raw string. Every value is a string ("true").
9
10const TOPIC = "Gumroad"
11
12// Keys that must never become object properties.
13const UNSAFE = new Set(["__proto__", "constructor", "prototype"])
14
15// Put a value at a bracket path such as card[visual] or purchase_ids[].
16function setPath(target, key, value) {
17 const list = /\[\]$/.test(key)
18 const parts = String(key).replace(/\[\]$/, "").replace(/\]/g, "").split("[")
19 if (parts.some((p) => p === "" || UNSAFE.has(p))) return
20 let node = target
21 for (const part of parts.slice(0, -1)) {
22 if (!Object.prototype.hasOwnProperty.call(node, part) || !node[part] || typeof node[part] !== "object" || Array.isArray(node[part])) node[part] = {}
23 node = node[part]
24 }
25 const last = parts[parts.length - 1]
26 if (!list) node[last] = value
27 else if (Array.isArray(node[last]) && Object.prototype.hasOwnProperty.call(node, last)) node[last].push(value)
28 else node[last] = [value]
29}
30
31// Decode one form-encoded piece; a bad escape shouldn't break the whole body.
32function decode(value) {
33 try {
34 return decodeURIComponent(String(value).replace(/\+/g, " "))
35 } catch {
36 return String(value)
37 }
38}
39
40// Turn whatever Studio gives us (object, JSON string or raw form string) into one object.
41function readForm(request) {
42 let body = request.body
43 if (typeof body === "string" && body.trim().startsWith("{")) {
44 try {
45 body = JSON.parse(body)
46 } catch {
47 body = null
48 }
49 }
50 const out = {}
51 if (body && typeof body === "object" && !Array.isArray(body)) {
52 for (const [key, value] of Object.entries(body)) {
53 if (key.includes("[")) setPath(out, key, value)
54 else if (!UNSAFE.has(key)) out[key] = value
55 }
56 if (Object.keys(out).length) return out
57 }
58 // Fall back to the raw form-encoded string.
59 const raw = typeof body === "string" ? body : typeof request.raw === "string" ? request.raw : ""
60 for (const pair of raw.split("&")) {
61 if (!pair) continue
62 const at = pair.indexOf("=")
63 setPath(out, decode(at < 0 ? pair : pair.slice(0, at)), at < 0 ? "" : decode(pair.slice(at + 1)))
64 }
65 return out
66}
67
68// Read an own field only, so names like "toString" never resolve to something inherited.
69function field(form, key) {
70 return Object.prototype.hasOwnProperty.call(form, key) ? form[key] : undefined
71}
72
73// Form fields are strings; treat blanks as missing.
74function text(value) {
75 if (typeof value === "number" && Number.isFinite(value)) return String(value)
76 return typeof value === "string" && value.trim() ? value.trim() : null
77}
78
79// Booleans arrive as "true"/"false" in form bodies and as true/false in JSON.
80function flag(value) {
81 return value === true || value === "true"
82}
83
84// Only link to real http(s) URLs.
85function link(url, cta) {
86 return typeof url === "string" && /^https?:\/\//.test(url) ? [{ cta, url }] : []
87}
88
89// Look a key up without tripping over inherited names such as "toString".
90function pick(table, key, fallback) {
91 return Object.prototype.hasOwnProperty.call(table, key) ? table[key] : fallback
92}
93
94// Gumroad's recurrence values, in words.
95const RECURRENCE = {
96 monthly: "monthly",
97 quarterly: "quarterly",
98 biannually: "every 6 months",
99 yearly: "yearly",
100 every_two_years: "every 2 years",
101}
102
103// A date without a clock time, so Studio's time zone doesn't matter.
104function day(value) {
105 const date = new Date(text(value) ?? "")
106 return Number.isNaN(date.getTime())
107 ? null
108 : date.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" })
109}
110
111function handleRequest(request) {
112 const form = readForm(request)
113 const resource = text(field(form, "resource_name"))
114
115 // Guard: only cancellation pings.
116 if (resource !== "cancellation") {
117 console.log(`Ignoring Gumroad "${resource ?? "unknown"}" ping`)
118 return null
119 }
120
121 const who = text(field(form, "user_email")) ?? "A subscriber"
122 const recurrence = pick(RECURRENCE, text(field(form, "recurrence")), null)
123 const product = (text(field(form, "product_name")) ?? "your membership") + (recurrence ? ` (${recurrence})` : "")
124 const failed = flag(field(form, "cancelled_due_to_payment_failures"))
125 const bySeller = flag(field(form, "cancelled_by_seller"))
126 const byAdmin = flag(field(form, "cancelled_by_admin"))
127 // cancelled_at is when the subscription stops (the end of the paid period), or when payments failed.
128 const ends = failed ? null : day(field(form, "cancelled_at"))
129
130 return {
131 title: failed ? "💳 Subscription cancelled after failed payments" : "👋 Subscription cancelled",
132 message:
133 (failed
134 ? `${who}'s ${product} was cancelled because payments failed`
135 : bySeller
136 ? `You cancelled ${who}'s ${product}`
137 : byAdmin
138 ? `Gumroad cancelled ${who}'s ${product}`
139 : `${who} cancelled ${product}`) +
140 (ends ? `. Ends ${ends}.` : ""),
141 topic: TOPIC,
142 // Low when you did it yourself; otherwise Normal — worth knowing, nothing is on fire.
143 priority: bySeller ? -1 : 0,
144 buttons: link("https://app.gumroad.com/customers", "View sales"),
145 }
146}
Payload

The Gumroad cancellation webhook

This is what Gumroad sends to your URL for cancellation. It is a sample, trimmed to the fields recipes use.

cancellation · sample.json
1{
2 "user_id": "1234567890123",
3 "cancelled": "true",
4 "created_at": "2026-07-29T09:12:00Z",
5 "product_id": "Qm9vc3RlclRlc3Q2Nzg5MA==",
6 "recurrence": "monthly",
7 "user_email": "[email protected]",
8 "cancelled_at": "2026-10-29T09:12:00Z",
9 "product_name": "Acme Membership",
10 "purchase_ids": [
11 "aB3cD5eF7gH9iJ1kL3mN5o==",
12 "pQ7rS9tU1vW3xY5zA7bC9d=="
13 ],
14 "resource_name": "cancellation",
15 "subscription_id": "Pd8wK2mN5qR7sT9vX1zB3g==",
16 "cancelled_by_buyer": "true",
17 "free_trial_ends_at": "",
18 "charge_occurrence_count": "3"
19}
FAQ

Gumroad subscription cancelled notifications: questions

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.

Ready when you are

Gumroad on your phone in two minutes.

Install the recipe, paste one URL, done. Free for 30 days, no credit card required.