# Build a Gumroad Lead Machine in Google Sheets (Ping Webhook + License API + AI Upsell)

> Source: <https://dev.to/hayrullahkar/build-a-gumroad-lead-machine-in-google-sheets-ping-webhook-license-api-ai-upsell-33n9>
> Published: 2026-07-15 08:32:02+00:00

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.

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

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

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

Make a sheet named `Leads`

and add this so the code and the columns stay in sync:

```
// Leads columns: timestamp | saleId | email | product | amount | license | status
const COL = { TS:1, SALE_ID:2, EMAIL:3, PRODUCT:4, AMOUNT:5, LICENSE:6, STATUS:7 };
```

In 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`

.

The one thing to get right: **Gumroad posts application/x-www-form-urlencoded, not JSON.** So you read

`e.parameter`

, not `JSON.parse(e.postData.contents)`

. This trips up everyone who copied a JSON webhook example.

``` js
function doPost(e) {
  const p = e.parameter;   // Gumroad is form-encoded — use e.parameter, NOT JSON.parse

  const expected = PropertiesService.getScriptProperties().getProperty('GUMROAD_TOKEN');
  if (expected && p.token !== expected) {
    return ContentService.createTextOutput('unauthorized');
  }

  const sale = saleToRow(p);
  if (!sale.saleId) return ContentService.createTextOutput('ignored');

  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Leads');

  // Gumroad can resend a ping — dedupe by sale_id.
  const seen = sheet.getRange(2, COL.SALE_ID, Math.max(sheet.getLastRow() - 1, 1), 1)
                    .getValues().flat();
  if (seen.indexOf(sale.saleId) !== -1) return ContentService.createTextOutput('duplicate');

  sheet.appendRow([new Date(), sale.saleId, sale.email, sale.product, sale.amount, sale.license, sale.status]);
  return ContentService.createTextOutput('ok');
}

function saleToRow(p) {
  return {
    saleId:  p.sale_id || '',
    email:   p.email || '',
    product: p.product_name || '',
    amount:  Number(p.price || 0) / 100,   // Gumroad sends price in cents
    license: p.license_key || '',
    status:  'new'
  };
}
```

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

``` js
function verifyLicense(productPermalink, licenseKey) {
  const res = UrlFetchApp.fetch('https://api.gumroad.com/v2/licenses/verify', {
    method: 'post',
    muteHttpExceptions: true,
    payload: {
      product_permalink: productPermalink,
      license_key: licenseKey,
      increment_uses_count: 'false'   // just checking — don't burn a use
    }
  });
  const data = JSON.parse(res.getContentText());
  return data.success ? data.purchase : null;   // buyer + product details, or null
}
```

A time trigger (Triggers → Add trigger → `sendUpsells`

, 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`

— under $0.01 per email — and falls back to a plain template if no API key is set.

``` js
function sendUpsells() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Leads');
  const last = sheet.getLastRow();
  if (last < 2) return;

  const rows = sheet.getRange(2, 1, last - 1, COL.STATUS).getValues();
  rows.forEach((row, i) => {
    if (row[COL.STATUS - 1] !== 'new') return;   // only fresh leads
    const email = row[COL.EMAIL - 1];
    const product = row[COL.PRODUCT - 1];
    const message = personalize(product);        // one API call
    GmailApp.sendEmail(email, `Your next step after ${product}`, message, { name: 'Hayrullah at MageSheet' });
    sheet.getRange(i + 2, COL.STATUS).setValue('emailed');
  });
}

function personalize(product) {
  const apiKey = PropertiesService.getScriptProperties().getProperty('OPENAI_API_KEY');
  if (!apiKey) {
    return `Thanks for grabbing ${product}. Reply to this email and I'll send the advanced setup.`;
  }
  const res = UrlFetchApp.fetch('https://api.openai.com/v1/chat/completions', {
    method: 'post', contentType: 'application/json',
    headers: { Authorization: 'Bearer ' + apiKey }, muteHttpExceptions: true,
    payload: JSON.stringify({
      model: 'gpt-4o-mini', temperature: 0.7,
      messages: [{ role: 'user',
        content: `Write a friendly 3-sentence follow-up email to someone who just bought "${product}". ` +
                 `Offer a paid done-for-you upgrade. No subject line, no placeholders.` }]
    })
  });
  if (res.getResponseCode() !== 200) {
    return `Thanks for grabbing ${product}. Happy to help you take it further — just reply.`;
  }
  return JSON.parse(res.getContentText()).choices[0].message.content;
}
```

I unit-tested `saleToRow`

before shipping — the price field is the trap, because Gumroad reports it in cents (`2900`

means $29.00), and a missing field must default cleanly instead of writing `NaN`

into the sheet.

`e.parameter.email`

, not `JSON.parse(e.postData.contents)`

. This is the single most common mistake.`doPost(e)`

can't read HTTP headers.`?token=`

in the ping URL and compare `e.parameter.token`

.`sale_id`

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

The 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).

Built by the MageSheet team.
