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

- Company: Gumroad (https://www.justpush.io/recipes/gumroad)
- Event: `sale` (sale)
- Tags: Payments & billing, Orders, Payments
- Install: https://studio.justpush.io/recipes/gumroad/new-sale
- Web page: https://www.justpush.io/recipes/gumroad/new-sale

## 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 sale resource with the API call in the setup notes.
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 (shown in Studio after install)?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.

## 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 → sale (Ping)
// Fires for every sale, including subscription renewals, free downloads and pre-orders.
// Docs: https://gumroad.com/ping
//
// Gumroad POSTs form-encoded fields such as product_name=…, price=4900, card[type]=visa.
// 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", "4900").

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"
}

// Gumroad's price is always in USD cents, whatever the product's currency field says.
function usd(cents) {
    const n = typeof cents === "number" ? cents : /^-?\d+(\.\d+)?$/.test(text(cents) ?? "") ? Number(cents) : NaN
    if (!Number.isFinite(n)) return null
    try {
        return new Intl.NumberFormat("en-GB", { style: "currency", currency: "USD" }).format(n / 100)
    } catch {
        return `US$${(n / 100).toFixed(2)}`
    }
}

// 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
}

// What kind of sale it is decides the title and how loud the push is.
const KINDS = {
    test: { title: "🧪 Test sale", priority: -1 }, // Low — you bought your own product
    preorder: { title: "🕒 New pre-order", priority: 0 }, // Normal — the card is only authorised for now
    renewal: { title: "🔁 Subscription renewal", priority: -1 }, // Low — routine recurring income
    free: { title: "📥 New free download", priority: -1 }, // Low — nice, but no money in
    sale: { title: "💰 New sale", priority: 0 }, // Normal — money in is good news, not an emergency
}

function handleRequest(request) {
    const form = readForm(request)
    const resource = text(field(form, "resource_name"))
    const test = flag(field(form, "test"))

    // "Send test ping to URL" in Settings → Advanced posts your latest sale with test=true
    // and, unlike a real ping, no resource_name.
    if (!resource && test && text(field(form, "sale_id"))) {
        return {
            title: "🔔 Gumroad connected",
            message: `Test ping for ${text(field(form, "product_name")) ?? "your latest sale"} received`,
            topic: TOPIC,
            priority: -1, // Low — just confirms the setup works
        }
    }

    // Guard: only sales. Real pings name their resource; without one, fall back to the shape.
    const isSale = resource
        ? resource === "sale"
        : Boolean(text(field(form, "sale_id"))) && !flag(field(form, "refunded")) && !flag(field(form, "disputed"))
    if (!isSale) {
        console.log(`Ignoring Gumroad "${resource ?? "unknown"}" ping`)
        return null
    }

    // ?renewals=0 on the ping URL skips subscription renewals.
    const renewal = flag(field(form, "is_recurring_charge"))
    if (renewal && ["0", "false", "no"].includes(String(request.query?.renewals ?? "").toLowerCase())) {
        console.log("Skipping subscription renewal (?renewals=0)")
        return null
    }

    const gift = flag(field(form, "is_gift_receiver_purchase"))
    const cents = gift ? field(form, "gift_price") ?? field(form, "price") : field(form, "price")
    const amount = usd(cents)
    const free = amount !== null && Number(cents) === 0
    const kind = test ? "test" : flag(field(form, "is_preorder_authorization")) ? "preorder" : renewal ? "renewal" : free ? "free" : "sale"
    const { title, priority } = pick(KINDS, kind, KINDS.sale)

    const variants = field(form, "variants")
    const options = variants && typeof variants === "object" ? Object.values(variants).map(text).filter(Boolean).join(", ") : ""
    const quantity = Number(text(field(form, "quantity")))
    const product =
        (text(field(form, "product_name")) ?? "a product") +
        (options ? ` (${options})` : "") +
        (quantity > 1 ? ` ×${quantity}` : "")
    const buyer = text(field(form, "full_name")) ?? text(field(form, "email"))
    // On a gift, the ping is the receiver's purchase; the gifter is the one who paid.
    const who = gift ? text(field(form, "gifter_email")) ?? buyer : buyer

    return {
        title,
        message:
            (amount && !free ? `${amount} from ${who ?? "a customer"}` : who ?? "A customer") +
            ` — ${product}` +
            (gift ? (buyer && buyer !== who ? ` (gift for ${buyer})` : " (gift)") : ""),
        topic: TOPIC,
        priority,
        // Cash register only when money actually came in.
        ...(kind === "sale" || kind === "renewal" ? { sound: "cashregister" } : {}),
        buttons: link("https://app.gumroad.com/customers", "View sales"),
    }
}
```

## Gumroad sale webhook payload (sample)

```json
{
  "card": {
    "type": "visa",
    "visual": "**** **** **** 4242"
  },
  "email": "jane@example.com",
  "price": "4900",
  "sale_id": "yN3kP8sQ1wR5tU7vX9zA2g==",
  "currency": "usd",
  "disputed": "false",
  "quantity": "1",
  "referrer": "direct",
  "refunded": "false",
  "full_name": "Jane Doe",
  "permalink": "acme-course",
  "seller_id": "x7Q2kL0vT1aB7cD3eF5gHw==",
  "ip_country": "Netherlands",
  "product_id": "Qm9vc3RlclRlc3QxMjM0NQ==",
  "can_contact": "true",
  "dispute_won": "false",
  "gumroad_fee": "540",
  "order_number": "524980123",
  "product_name": "Acme Design Course",
  "purchaser_id": "1234567890123",
  "resource_name": "sale",
  "sale_timestamp": "2026-09-29T09:45:53Z",
  "short_product_id": "qzxbt",
  "product_permalink": "https://acme.gumroad.com/l/acme-course",
  "discover_fee_charged": "false",
  "is_gift_receiver_purchase": "false"
}
```

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