NodukanNodukan/ Developers
Guides

Lead Magnet → CRM Funnel

Create a lead magnet, export captured contacts, and sync them to your CRM automatically using Nodukan webhooks and the Leads API.

Lead Magnet → CRM Funnel

This guide shows you how to build a complete lead capture funnel:

  1. Create a lead magnet via API
  2. Use webhooks to sync leads in real time as they're captured
  3. Use the Leads API to backfill historical leads into your CRM

The code samples target HubSpot but the pattern works with any CRM (Salesforce, Klaviyo, Mailchimp, Notion, Airtable, etc.).

Architecture#

Visitor opts in to lead magnet
       ↓
Nodukan stores the lead
       ↓
POST /webhooks/nodukan  ← Nodukan pushes lead.captured event
       ↓
Your server receives + verifies payload
       ↓
Create/update contact in HubSpot CRM
       ↓
Confirm 200 OK to Nodukan

Step 1 — Create the lead magnet via API#

You can create lead magnets programmatically — useful if you're building a multi-tenant creator tool and need to provision them at scale.

// create-lead-magnet.js
const res = await fetch('https://api.nodukan.com/v1/lead-magnets', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NODUKAN_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    title: 'Free Content Calendar Template 2026',
    description: '52 weeks of content ideas for Indian creators. Includes captions, hashtag banks, and posting schedules.',
    type: 'template',
    fileUrl: 'https://cdn.nodukan.com/uploads/content-calendar-2026.zip',
    thumbnailUrl: 'https://cdn.nodukan.com/uploads/content-calendar-thumb.jpg',
    isActive: true,
    successMessage: 'Check your email — your content calendar is on its way! 🎉',
    fields: [
      { name: 'name', label: 'Your Name', required: true },
      { name: 'phone', label: 'WhatsApp Number', required: false },
      { name: 'niche', label: 'Your Content Niche', required: false },
    ],
  }),
});

const { data } = await res.json();
console.log('Lead magnet created:', data.id);
console.log('Share URL:', data.shareUrl);
console.log('Embed code:', data.embedCode);

Step 2 — Register the webhook#

// setup-webhook.js
const res = await fetch('https://api.nodukan.com/v1/webhooks', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NODUKAN_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    url: 'https://your-server.example.com/webhooks/nodukan',
    events: ['lead.captured'],
    description: 'Sync leads to HubSpot CRM',
  }),
});

const { data } = await res.json();
// Save data.signingSecret to your secrets manager!
console.log('Webhook ID:', data.id);
console.log('Signing secret (save this!):', data.signingSecret);

Step 3 — Handle the webhook and push to CRM#

// webhook-handler.js
import express from 'express';
import crypto from 'crypto';

const app = express();

// HubSpot client (using the official HubSpot API)
async function upsertHubSpotContact(lead) {
  const res = await fetch('https://api.hubapi.com/crm/v3/objects/contacts/batch/upsert', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.HUBSPOT_TOKEN}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      inputs: [
        {
          idProperty: 'email',
          id: lead.email,
          properties: {
            email: lead.email,
            firstname: lead.name?.split(' ')[0] ?? '',
            lastname: lead.name?.split(' ').slice(1).join(' ') ?? '',
            phone: lead.phone ?? '',
            // Custom properties — create these in HubSpot settings first
            nodukan_lead_source: 'lead_magnet',
            nodukan_lead_magnet_title: lead.leadMagnetTitle,
            nodukan_captured_at: lead.capturedAt,
            utm_source: lead.utmSource ?? '',
            utm_medium: lead.utmMedium ?? '',
          },
        },
      ],
    }),
  });

  if (!res.ok) {
    const err = await res.json();
    throw new Error(`HubSpot error: ${JSON.stringify(err)}`);
  }

  return res.json();
}

function verifySignature(rawBody, sig) {
  const parts = Object.fromEntries(sig.split(',').map((p) => p.split('=')));
  const { t, v1 } = parts;
  if (!t || !v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = crypto
    .createHmac('sha256', process.env.NODUKAN_WEBHOOK_SECRET)
    .update(`${t}.${rawBody}`)
    .digest('hex');
  try {
    return crypto.timingSafeEqual(Buffer.from(v1, 'hex'), Buffer.from(expected, 'hex'));
  } catch {
    return false;
  }
}

app.post('/webhooks/nodukan', express.raw({ type: 'application/json' }), async (req, res) => {
  const rawBody = req.body.toString('utf8');
  const sig = req.headers['x-nodukan-signature'];

  if (!sig || !verifySignature(rawBody, sig)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Respond immediately
  res.status(200).json({ received: true });

  const event = JSON.parse(rawBody);

  if (event.event === 'lead.captured') {
    const { lead, leadMagnetTitle } = event.data;
    try {
      await upsertHubSpotContact({ ...lead, leadMagnetTitle });
      console.log(`✅ Synced lead ${lead.email} to HubSpot`);
    } catch (err) {
      console.error('❌ HubSpot sync failed:', err.message);
      // Alert via Slack or Sentry here
    }
  }
});

app.listen(3000, () => console.log('Webhook server running on :3000'));

Step 4 — Backfill historical leads#

For existing lead magnets with historical leads, use the paginator to sync everyone who opted in before you set up the webhook:

// backfill.js
const LEAD_MAGNET_ID = 'lm_01j9x4km2v';
const BACKFILL_FROM = '2026-01-01';

async function* getLeads(leadMagnetId, from) {
  const base = 'https://api.nodukan.com/v1';
  const headers = { Authorization: `Bearer ${process.env.NODUKAN_API_KEY}` };
  let offset = 0;

  while (true) {
    const url = new URL(`${base}/lead-magnets/${leadMagnetId}/leads`);
    url.searchParams.set('limit', '100');
    url.searchParams.set('offset', String(offset));
    url.searchParams.set('from', from);

    const res = await fetch(url, { headers });
    const body = await res.json();

    yield* body.data;
    if (!body.pagination.hasMore) break;
    offset += 100;
  }
}

let synced = 0;
let failed = 0;

for await (const lead of getLeads(LEAD_MAGNET_ID, BACKFILL_FROM)) {
  try {
    await upsertHubSpotContact({ ...lead, leadMagnetTitle: 'Free Content Calendar' });
    synced++;
    // Throttle to avoid HubSpot rate limits
    await new Promise((r) => setTimeout(r, 50));
  } catch {
    failed++;
  }
}

console.log(`Backfill complete: ${synced} synced, ${failed} failed`);

Extending the funnel#

Once leads are in your CRM, you can:

  • Enroll in an email sequence — trigger a HubSpot workflow to send a 5-email drip sequence
  • Tag by lead source — use utmSource and utmMedium to segment by traffic source
  • Score leads — increment a lead score property based on subsequent purchases (subscribe to order.completed webhook)
  • Sync to Klaviyo — replace upsertHubSpotContact with klaviyo.profiles.createOrUpdate()
Note:

The Leads API returns PII including email and phone numbers. Ensure your integration and data storage comply with India's Digital Personal Data Protection Act (DPDP Act 2023) and your privacy policy.

What's next?#

Was this helpful?
Edit on GitHub

Last updated