{"slug": "build-a-gumroad-lead-machine-in-google-sheets-ping-webhook-license-api-ai-upsell", "title": "Build a Gumroad Lead Machine in Google Sheets (Ping Webhook + License API + AI Upsell)", "summary": "A developer built a system using Google Sheets, Apps Script, and Gumroad's API to turn every small sale into a verified lead with an AI-personalized follow-up email, bypassing traditional CRM and Zapier. The 70-line script catches Gumroad's form-encoded ping, deduplicates sales, verifies licenses via Gumroad's API, and sends upsell emails using GPT-4o-mini for under $0.01 each.", "body_md": "A traditional B2B funnel asks a stranger to fill a form, wait for a sales rep, and sit through a call — and it typically converts around 0.1–0.5%. I stopped running that. Instead I sell a small $29 product on Gumroad and let the purchase itself qualify the lead.\n\nThis post is the Apps Script that turns every Gumroad sale into a logged, license-verified lead with an AI-personalized follow-up email — no CRM, no Zapier, about 70 lines you can copy.\n\nA free download gets you emails from people who spent nothing, so they're not qualified. A demo form gets you almost nobody, because the friction is huge. A small paid \"micro-commitment\" sits in the middle: the buyer proved intent with their wallet, and you now have their email, the exact product they bought, and a valid license — everything you need to follow up with the right offer.\n\nGumroad handles the checkout. Google Sheets is the lead database. Apps Script is the glue: catch the sale, verify it's real, and send the next step.\n\nMake a sheet named `Leads`\n\nand add this so the code and the columns stay in sync:\n\n```\n// Leads columns: timestamp | saleId | email | product | amount | license | status\nconst COL = { TS:1, SALE_ID:2, EMAIL:3, PRODUCT:4, AMOUNT:5, LICENSE:6, STATUS:7 };\n```\n\nIn Gumroad, enable the ping under Settings → Advanced → \"Ping\", and point it at your Web App URL with a secret token in the query string: `https://script.google.com/.../exec?token=my-secret`\n\n.\n\nThe one thing to get right: **Gumroad posts application/x-www-form-urlencoded, not JSON.** So you read\n\n`e.parameter`\n\n, not `JSON.parse(e.postData.contents)`\n\n. This trips up everyone who copied a JSON webhook example.\n\n``` js\nfunction doPost(e) {\n  const p = e.parameter;   // Gumroad is form-encoded — use e.parameter, NOT JSON.parse\n\n  const expected = PropertiesService.getScriptProperties().getProperty('GUMROAD_TOKEN');\n  if (expected && p.token !== expected) {\n    return ContentService.createTextOutput('unauthorized');\n  }\n\n  const sale = saleToRow(p);\n  if (!sale.saleId) return ContentService.createTextOutput('ignored');\n\n  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Leads');\n\n  // Gumroad can resend a ping — dedupe by sale_id.\n  const seen = sheet.getRange(2, COL.SALE_ID, Math.max(sheet.getLastRow() - 1, 1), 1)\n                    .getValues().flat();\n  if (seen.indexOf(sale.saleId) !== -1) return ContentService.createTextOutput('duplicate');\n\n  sheet.appendRow([new Date(), sale.saleId, sale.email, sale.product, sale.amount, sale.license, sale.status]);\n  return ContentService.createTextOutput('ok');\n}\n\nfunction saleToRow(p) {\n  return {\n    saleId:  p.sale_id || '',\n    email:   p.email || '',\n    product: p.product_name || '',\n    amount:  Number(p.price || 0) / 100,   // Gumroad sends price in cents\n    license: p.license_key || '',\n    status:  'new'\n  };\n}\n```\n\nA ping is just an HTTP POST — anyone who finds your URL can fake one. Before you provision anything valuable, verify the license against Gumroad's API. It returns the real purchase record, or nothing.\n\n``` js\nfunction verifyLicense(productPermalink, licenseKey) {\n  const res = UrlFetchApp.fetch('https://api.gumroad.com/v2/licenses/verify', {\n    method: 'post',\n    muteHttpExceptions: true,\n    payload: {\n      product_permalink: productPermalink,\n      license_key: licenseKey,\n      increment_uses_count: 'false'   // just checking — don't burn a use\n    }\n  });\n  const data = JSON.parse(res.getContentText());\n  return data.success ? data.purchase : null;   // buyer + product details, or null\n}\n```\n\nA time trigger (Triggers → Add trigger → `sendUpsells`\n\n, every 5 minutes) emails each new lead once, then marks it so it never double-sends. The message is written per-product by `gpt-4o-mini`\n\n— under $0.01 per email — and falls back to a plain template if no API key is set.\n\n``` js\nfunction sendUpsells() {\n  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Leads');\n  const last = sheet.getLastRow();\n  if (last < 2) return;\n\n  const rows = sheet.getRange(2, 1, last - 1, COL.STATUS).getValues();\n  rows.forEach((row, i) => {\n    if (row[COL.STATUS - 1] !== 'new') return;   // only fresh leads\n    const email = row[COL.EMAIL - 1];\n    const product = row[COL.PRODUCT - 1];\n    const message = personalize(product);        // one API call\n    GmailApp.sendEmail(email, `Your next step after ${product}`, message, { name: 'Hayrullah at MageSheet' });\n    sheet.getRange(i + 2, COL.STATUS).setValue('emailed');\n  });\n}\n\nfunction personalize(product) {\n  const apiKey = PropertiesService.getScriptProperties().getProperty('OPENAI_API_KEY');\n  if (!apiKey) {\n    return `Thanks for grabbing ${product}. Reply to this email and I'll send the advanced setup.`;\n  }\n  const res = UrlFetchApp.fetch('https://api.openai.com/v1/chat/completions', {\n    method: 'post', contentType: 'application/json',\n    headers: { Authorization: 'Bearer ' + apiKey }, muteHttpExceptions: true,\n    payload: JSON.stringify({\n      model: 'gpt-4o-mini', temperature: 0.7,\n      messages: [{ role: 'user',\n        content: `Write a friendly 3-sentence follow-up email to someone who just bought \"${product}\". ` +\n                 `Offer a paid done-for-you upgrade. No subject line, no placeholders.` }]\n    })\n  });\n  if (res.getResponseCode() !== 200) {\n    return `Thanks for grabbing ${product}. Happy to help you take it further — just reply.`;\n  }\n  return JSON.parse(res.getContentText()).choices[0].message.content;\n}\n```\n\nI unit-tested `saleToRow`\n\nbefore shipping — the price field is the trap, because Gumroad reports it in cents (`2900`\n\nmeans $29.00), and a missing field must default cleanly instead of writing `NaN`\n\ninto the sheet.\n\n`e.parameter.email`\n\n, not `JSON.parse(e.postData.contents)`\n\n. This is the single most common mistake.`doPost(e)`\n\ncan't read HTTP headers.`?token=`\n\nin the ping URL and compare `e.parameter.token`\n\n.`sale_id`\n\ncheck stops duplicate leads.That's an autonomous lead machine in about 70 lines: a Gumroad checkout becomes a logged, verified lead with a personalized upsell — all inside a Google Sheet, with the micro-commitment doing your lead qualification for you.\n\nThe production version — ad attribution back to the sale, license provisioning, and a multi-step follow-up sequence — is written up on the [MageSheet blog](https://magesheet.com/blog/gumroad-lead-generation-machine).\n\nBuilt by the MageSheet team.", "url": "https://wpnews.pro/news/build-a-gumroad-lead-machine-in-google-sheets-ping-webhook-license-api-ai-upsell", "canonical_source": "https://dev.to/hayrullahkar/build-a-gumroad-lead-machine-in-google-sheets-ping-webhook-license-api-ai-upsell-33n9", "published_at": "2026-07-15 08:32:02+00:00", "updated_at": "2026-07-15 08:58:19.844567+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "artificial-intelligence"], "entities": ["Gumroad", "Google Sheets", "Apps Script", "GPT-4o-mini", "Zapier"], "alternates": {"html": "https://wpnews.pro/news/build-a-gumroad-lead-machine-in-google-sheets-ping-webhook-license-api-ai-upsell", "markdown": "https://wpnews.pro/news/build-a-gumroad-lead-machine-in-google-sheets-ping-webhook-license-api-ai-upsell.md", "text": "https://wpnews.pro/news/build-a-gumroad-lead-machine-in-google-sheets-ping-webhook-license-api-ai-upsell.txt", "jsonld": "https://wpnews.pro/news/build-a-gumroad-lead-machine-in-google-sheets-ping-webhook-license-api-ai-upsell.jsonld"}}