Gumroad new sale notifications on your phone.

Get a push notification with the amount, buyer and product every time you make a sale on Gumroad.

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

STEP 03

Get a push on your phone

With the text, sound and buttons from the recipe.

Setup

How to set up the Gumroad sale 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 sale 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. In Gumroad, open **Settings → Advanced**, paste the URL above into **Ping endpoint** and save. **Send test ping to URL** posts your most recent sale with `test` set, which gives a quiet **🔔 Gumroad connected** push (you need at least one sale for the button to work).
  6. Every sale pings, including subscription renewals. To skip renewals, add `?renewals=0`:
  7. ```
  8. your webhook URL?renewals=0
  9. ```
  10. Gumroad sends the ping form-encoded, retries a failed delivery a few times (after 1, 3, 10 and 60 minutes) and can deliver the same sale twice. Don't also add this URL as a `sale` resource subscription through the API, or you'll get every sale twice.
  11. 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 → sale (Ping)
2// Fires for every sale, including subscription renewals, free downloads and pre-orders.
3// Docs: https://gumroad.com/ping
4//
5// Gumroad POSTs form-encoded fields such as product_name=…, price=4900, card[type]=visa.
6// Depending on how the body was parsed, it can arrive as a nested object, a flat object
7// with bracket keys, or only as the raw string. Every value is a string ("true", "4900").
8
9const TOPIC = "Gumroad"
10
11// Keys that must never become object properties.
12const UNSAFE = new Set(["__proto__", "constructor", "prototype"])
13
14// Put a value at a bracket path such as card[visual] or purchase_ids[].
15function setPath(target, key, value) {
16 const list = /\[\]$/.test(key)
17 const parts = String(key).replace(/\[\]$/, "").replace(/\]/g, "").split("[")
18 if (parts.some((p) => p === "" || UNSAFE.has(p))) return
19 let node = target
20 for (const part of parts.slice(0, -1)) {
21 if (!Object.prototype.hasOwnProperty.call(node, part) || !node[part] || typeof node[part] !== "object" || Array.isArray(node[part])) node[part] = {}
22 node = node[part]
23 }
24 const last = parts[parts.length - 1]
25 if (!list) node[last] = value
26 else if (Array.isArray(node[last]) && Object.prototype.hasOwnProperty.call(node, last)) node[last].push(value)
27 else node[last] = [value]
28}
29
30// Decode one form-encoded piece; a bad escape shouldn't break the whole body.
31function decode(value) {
32 try {
33 return decodeURIComponent(String(value).replace(/\+/g, " "))
34 } catch {
35 return String(value)
36 }
37}
38
39// Turn whatever Studio gives us (object, JSON string or raw form string) into one object.
40function readForm(request) {
41 let body = request.body
42 if (typeof body === "string" && body.trim().startsWith("{")) {
43 try {
44 body = JSON.parse(body)
45 } catch {
46 body = null
47 }
48 }
49 const out = {}
50 if (body && typeof body === "object" && !Array.isArray(body)) {
51 for (const [key, value] of Object.entries(body)) {
52 if (key.includes("[")) setPath(out, key, value)
53 else if (!UNSAFE.has(key)) out[key] = value
54 }
55 if (Object.keys(out).length) return out
56 }
57 // Fall back to the raw form-encoded string.
58 const raw = typeof body === "string" ? body : typeof request.raw === "string" ? request.raw : ""
59 for (const pair of raw.split("&")) {
60 if (!pair) continue
61 const at = pair.indexOf("=")
62 setPath(out, decode(at < 0 ? pair : pair.slice(0, at)), at < 0 ? "" : decode(pair.slice(at + 1)))
63 }
64 return out
65}
66
67// Read an own field only, so names like "toString" never resolve to something inherited.
68function field(form, key) {
69 return Object.prototype.hasOwnProperty.call(form, key) ? form[key] : undefined
70}
71
72// Form fields are strings; treat blanks as missing.
73function text(value) {
74 if (typeof value === "number" && Number.isFinite(value)) return String(value)
75 return typeof value === "string" && value.trim() ? value.trim() : null
76}
77
78// Booleans arrive as "true"/"false" in form bodies and as true/false in JSON.
79function flag(value) {
80 return value === true || value === "true"
81}
82
83// Gumroad's price is always in USD cents, whatever the product's currency field says.
84function usd(cents) {
85 const n = typeof cents === "number" ? cents : /^-?\d+(\.\d+)?$/.test(text(cents) ?? "") ? Number(cents) : NaN
86 if (!Number.isFinite(n)) return null
87 try {
88 return new Intl.NumberFormat("en-GB", { style: "currency", currency: "USD" }).format(n / 100)
89 } catch {
90 return `US$${(n / 100).toFixed(2)}`
91 }
92}
93
94// Only link to real http(s) URLs.
95function link(url, cta) {
96 return typeof url === "string" && /^https?:\/\//.test(url) ? [{ cta, url }] : []
97}
98
99// Look a key up without tripping over inherited names such as "toString".
100function pick(table, key, fallback) {
101 return Object.prototype.hasOwnProperty.call(table, key) ? table[key] : fallback
102}
103
104// What kind of sale it is decides the title and how loud the push is.
105const KINDS = {
106 test: { title: "🧪 Test sale", priority: -1 }, // Low — you bought your own product
107 preorder: { title: "🕒 New pre-order", priority: 0 }, // Normal — the card is only authorised for now
108 renewal: { title: "🔁 Subscription renewal", priority: -1 }, // Low — routine recurring income
109 free: { title: "📥 New free download", priority: -1 }, // Low — nice, but no money in
110 sale: { title: "💰 New sale", priority: 0 }, // Normal — money in is good news, not an emergency
111}
112
113function handleRequest(request) {
114 const form = readForm(request)
115 const resource = text(field(form, "resource_name"))
116 const test = flag(field(form, "test"))
117
118 // "Send test ping to URL" in Settings → Advanced posts your latest sale with test=true
119 // and, unlike a real ping, no resource_name.
120 if (!resource && test && text(field(form, "sale_id"))) {
121 return {
122 title: "🔔 Gumroad connected",
123 message: `Test ping for ${text(field(form, "product_name")) ?? "your latest sale"} received`,
124 topic: TOPIC,
125 priority: -1, // Low — just confirms the setup works
126 }
127 }
128
129 // Guard: only sales. Real pings name their resource; without one, fall back to the shape.
130 const isSale = resource
131 ? resource === "sale"
132 : Boolean(text(field(form, "sale_id"))) && !flag(field(form, "refunded")) && !flag(field(form, "disputed"))
133 if (!isSale) {
134 console.log(`Ignoring Gumroad "${resource ?? "unknown"}" ping`)
135 return null
136 }
137
138 // ?renewals=0 on the ping URL skips subscription renewals.
139 const renewal = flag(field(form, "is_recurring_charge"))
140 if (renewal && ["0", "false", "no"].includes(String(request.query?.renewals ?? "").toLowerCase())) {
141 console.log("Skipping subscription renewal (?renewals=0)")
142 return null
143 }
144
145 const gift = flag(field(form, "is_gift_receiver_purchase"))
146 const cents = gift ? field(form, "gift_price") ?? field(form, "price") : field(form, "price")
147 const amount = usd(cents)
148 const free = amount !== null && Number(cents) === 0
149 const kind = test ? "test" : flag(field(form, "is_preorder_authorization")) ? "preorder" : renewal ? "renewal" : free ? "free" : "sale"
150 const { title, priority } = pick(KINDS, kind, KINDS.sale)
151
152 const variants = field(form, "variants")
153 const options = variants && typeof variants === "object" ? Object.values(variants).map(text).filter(Boolean).join(", ") : ""
154 const quantity = Number(text(field(form, "quantity")))
155 const product =
156 (text(field(form, "product_name")) ?? "a product") +
157 (options ? ` (${options})` : "") +
158 (quantity > 1 ? ` ×${quantity}` : "")
159 const buyer = text(field(form, "full_name")) ?? text(field(form, "email"))
160 // On a gift, the ping is the receiver's purchase; the gifter is the one who paid.
161 const who = gift ? text(field(form, "gifter_email")) ?? buyer : buyer
162
163 return {
164 title,
165 message:
166 (amount && !free ? `${amount} from ${who ?? "a customer"}` : who ?? "A customer") +
167 ` — ${product}` +
168 (gift ? (buyer && buyer !== who ? ` (gift for ${buyer})` : " (gift)") : ""),
169 topic: TOPIC,
170 priority,
171 // Cash register only when money actually came in.
172 ...(kind === "sale" || kind === "renewal" ? { sound: "cashregister" } : {}),
173 buttons: link("https://app.gumroad.com/customers", "View sales"),
174 }
175}
Payload

The Gumroad sale webhook

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

sale · sample.json
1{
2 "card": {
3 "type": "visa",
4 "visual": "**** **** **** 4242"
5 },
6 "email": "[email protected]",
7 "price": "4900",
8 "sale_id": "yN3kP8sQ1wR5tU7vX9zA2g==",
9 "currency": "usd",
10 "disputed": "false",
11 "quantity": "1",
12 "referrer": "direct",
13 "refunded": "false",
14 "full_name": "Jane Doe",
15 "permalink": "acme-course",
16 "seller_id": "x7Q2kL0vT1aB7cD3eF5gHw==",
17 "ip_country": "Netherlands",
18 "product_id": "Qm9vc3RlclRlc3QxMjM0NQ==",
19 "can_contact": "true",
20 "dispute_won": "false",
21 "gumroad_fee": "540",
22 "order_number": "524980123",
23 "product_name": "Acme Design Course",
24 "purchaser_id": "1234567890123",
25 "resource_name": "sale",
26 "sale_timestamp": "2026-09-29T09:45:53Z",
27 "short_product_id": "qzxbt",
28 "product_permalink": "https://acme.gumroad.com/l/acme-course",
29 "discover_fee_charged": "false",
30 "is_gift_receiver_purchase": "false"
31}
FAQ

Gumroad new sale 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.