Skip to content
ExponaBLOG
NESTJS · DAY 092 / 100

A Meta Lead Ads webhook pipeline: lessons from production

From Facebook form submission to CRM lead in seconds.

Expona Admin10 min read

A Meta Lead Ads webhook pipeline

Facebook Lead Ads are powerful—until your pipeline breaks silently at 2 AM. A Meta Lead Ads webhook pipeline bridges the gap between a customer filling out your form on Facebook and a qualified lead landing in your CRM, often in under five seconds. In this post, I'll walk you through the real-world architecture that keeps that pipeline reliable, from the initial handshake with Meta's servers to deduplication logic that saves your sales team from chasing ghosts.

Subscribe and verify — Handle the verification challenge and validate signatures

The first step is making your NestJS server discoverable and trusted by Meta. When you set up a webhook subscription in the Meta App Dashboard, Facebook sends a GET request with a verification token. Your endpoint must echo back the hub.challenge parameter to prove ownership. After that, every webhook arrives as a POST with an X-Hub-Signature-256 header containing an HMAC-SHA256 signature.

I've seen teams skip signature validation and regret it when a malicious actor floods their queue with fake leads. Here's how to do it right in NestJS:

typescript
import { Controller, Post, Get, Query, Body, BadRequestException, Logger } from '@nestjs/common';
import * as crypto from 'crypto';

@Controller('webhooks/meta-lead-ads')
export class MetaLeadAdsController {
  private readonly logger = new Logger(MetaLeadAdsController.name);
  private readonly appSecret = process.env.META_APP_SECRET; // Keep in .env
  private readonly verifyToken = process.env.META_VERIFY_TOKEN;

  @Get()
  verifyWebhook(
    @Query('hub.mode') mode: string,
    @Query('hub.challenge') challenge: string,
    @Query('hub.verify_token') token: string,
  ): string {
    if (token !== this.verifyToken) {
      this.logger.warn('Verification token mismatch');
      throw new BadRequestException('Invalid verify token');
    }
    return challenge;
  }

  private validateSignature(body: any, signature: string): boolean {
    const hmac = crypto
      .createHmac('sha256', this.appSecret)
      .update(JSON.stringify(body))
      .digest('hex');
    return `sha256=${hmac}` === signature;
  }

  @Post()
  async handleWebhook(
    @Body() body: any,
    @Query('X-Hub-Signature-256') rawSignature?: string,
  ) {
    const signature = rawSignature || this.getSignatureFromHeaders();
    if (!this.validateSignature(body, signature)) {
      this.logger.error('Signature validation failed');
      throw new BadRequestException('Invalid signature');
    }
    // Process webhook
    return { status: 'ok' };
  }

  private getSignatureFromHeaders(): string {
    // In real code, inject @Req() to access headers
    return '';
  }
}

Always validate signatures. It's cheap CPU-wise and protects your lead pipeline from poisoning.

Webhooks carry IDs only — Fetch full lead data from the Graph API using the leadgen ID

Here's a common gotcha: the webhook payload from Meta does not include the full lead data. You get an event with a leadgen_id and maybe a timestamp. To get the actual email, phone, and form answers, you must call the Graph API with your page access token. That round trip takes 100–500 ms, so doing it synchronously in your webhook handler blocks your response.

Let's look at what a webhook event typically contains:

typescript
// Webhook payload structure
{
  "object": "page",
  "entry": [
    {
      "id": "123456789",
      "time": 1234567890,
      "messaging": [],
      "changes": [
        {
          "value": {
            "leadgen_id": "abc123def456",
            "page_id": "123456789",
            "created_time": 1234567890,
            "ad_id": "987654321"
          },
          "field": "leadgen"
        }
      ]
    }
  ]
}

To fetch the actual lead, you'd call GET /abc123def456?fields=id,created_time,field_data&access_token=PAGE_ACCESS_TOKEN, which returns something like:

typescript
// Graph API response
{
  "id": "abc123def456",
  "created_time": "2024-01-15T14:22:30+0000",
  "field_data": [
    { "name": "email", "values": ["customer@example.com"] },
    { "name": "phone_number", "values": ["+1234567890"] },
    { "name": "full_name", "values": ["John Doe"] }
  ]
}

That's your real lead data. Trying to fetch this during the webhook handler means Meta waits for the API call to finish. If Meta times out waiting for a 200 response, it'll retry, potentially duplicating work.

Queue the fetch — Respond 200 immediately; a worker fetches and creates the lead

The architecture that works: respond 200 immediately, then queue a job to fetch and process the lead asynchronously. I use Bull with Redis for this. Here's the flow:

  1. Receive webhook, validate signature
  2. Extract leadgen_id
  3. Push job to queue (takes ~1 ms)
  4. Return 200 to Meta
  5. Worker picks up job, calls Graph API, creates lead in CRM, updates database

In NestJS with Bull:

typescript
import { Injectable } from '@nestjs/common';
import { Queue } from 'bull';
import { InjectQueue } from '@nestjs/bull';

@Injectable()
export class MetaLeadAdsService {
  constructor(
    @InjectQueue('meta-leadgen') private leadgenQueue: Queue,
  ) {}

  async queueLeadFetch(leadgenId: string, pageId: string): Promise {
    await this.leadgenQueue.add(
      { leadgenId, pageId },
      {
        attempts: 3,
        backoff: { type: 'exponential', delay: 2000 },
        removeOnComplete: true,
      },
    );
  }
}

@Controller('webhooks/meta-lead-ads')
export class MetaLeadAdsController {
  constructor(private readonly metaService: MetaLeadAdsService) {}

  @Post()
  async handleWebhook(@Body() body: any): Promise<{ status: string }> {
    // Validation logic here...
    const leadgenId = body.entry[0].changes[0].value.leadgen_id;
    const pageId = body.entry[0].changes[0].value.page_id;

    await this.metaService.queueLeadFetch(leadgenId, pageId);
    return { status: 'ok' };
  }
}

// In a separate processor file
import { Processor, Process } from '@nestjs/bull';

@Processor('meta-leadgen')
export class MetaLeadgenProcessor {
  constructor(
    private readonly graphApiService: GraphApiService,
    private readonly crmService: CrmService,
  ) {}

  @Process()
  async procesLeadgenJob(job: Job): Promise {
    const { leadgenId, pageId } = job.data;
    
    // Fetch from Graph API (may take 200–500 ms)
    const leadData = await this.graphApiService.fetchLead(leadgenId, pageId);
    
    // Create in CRM
    await this.crmService.createLead(leadData);
  }
}

This pattern ensures Meta never waits on you. Even if your Graph API call is slow, the webhook handler returns 200 immediately, and Meta knows you got the event.

Handle token expiry — Page tokens expire. Monitor and alert before leads silently stop

Page access tokens for Lead Ads last 60 days. After that, your Graph API calls return 400 errors, and new leads start silently failing. You won't know until your sales team asks why lead volume dropped.

The fix: monitor token expiry proactively. Store the token's expires_at timestamp when you receive it, and schedule a job to refresh it a week before expiry. Also, log all Graph API errors and set up alerts for 400s.

typescript
@Injectable()
export class MetaTokenService {
  constructor(private readonly db: DatabaseService) {}

  async savePageToken(pageId: string, token: string, expiresIn: number): Promise {
    const expiresAt = new Date(Date.now() + expiresIn * 1000);
    await this.db.pageToken.upsert({
      where: { pageId },
      update: { token, expiresAt },
      create: { pageId, token, expiresAt },
    });
  }

  async scheduleTokenRefresh(): Promise {
    const tokens = await this.db.pageToken.findMany();
    for (const { pageId, expiresAt } of tokens) {
      const daysUntilExpiry = (expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24);
      if (daysUntilExpiry <= 7 && daysUntilExpiry > 0) {
        // Refresh token
        const newToken = await this.refreshToken(pageId);
        await this.savePageToken(pageId, newToken.access_token, newToken.expires_in);
      } else if (daysUntilExpiry <= 0) {
        // Alert: token expired
        await this.alertTeam(`Page token for ${pageId} has expired`);
      }
    }
  }

  private async refreshToken(pageId: string): Promise {
    // Call Meta API to refresh
    return fetch(`https://graph.instagram.com/oauth/access_token?...`).then(r => r.json());
  }

  private async alertTeam(message: string): Promise {
    // Send to Slack, PagerDuty, etc.
  }
}

Schedule this check to run daily. Catching token expiry before it breaks production is worth the few milliseconds of overhead.

Deduplicate — The same lead can arrive twice. Use the leadgen ID as a unique key

Meta's webhook system occasionally retries if your server is slow or times out. You might receive the same leadgen_id twice within seconds. If you don't deduplicate, you'll create duplicate leads in your CRM, which annoys your sales team and skews your analytics.

The simplest approach: use the leadgen_id as a unique constraint on your leads table, and handle the duplicate gracefully in the processor.

typescript
// In your database schema (Prisma example)
model MetaLead {
  id             String    @id @default(cuid())
  leadgenId      String    @unique // Unique constraint
  pageId         String
  email          String?
  phone          String?
  fullName       String?
  formData       Json?
  createdAt      DateTime  @default(now())
  updatedAt      DateTime  @updatedAt
  crmLeadId      String?   // Reference to your CRM system
  processingStatus String @default("pending") // pending, completed, failed

  @@index([pageId])
  @@index([processingStatus])
}

@Processor('meta-leadgen')
export class MetaLeadgenProcessor {
  constructor(
    private readonly graphApiService: GraphApiService,
    private readonly crmService: CrmService,
    private readonly db: DatabaseService,
  ) {}

  @Process()
  async processLeadgenJob(job: Job): Promise {
    const { leadgenId, pageId } = job.data;

    // Check if we've already processed this lead
    const existing = await this.db.metaLead.findUnique({
      where: { leadgenId },
    });
    if (existing?.processingStatus === 'completed') {
      return; // Already handled, skip
    }

    try {
      const leadData = await this.graphApiService.fetchLead(leadgenId, pageId);
      const crmLeadId = await this.crmService.createLead(leadData);

      // Upsert to avoid conflicts
      await this.db.metaLead.upsert({
        where: { leadgenId },
        update: {
          crmLeadId,
          processingStatus: 'completed',
          updatedAt: new Date(),
        },
        create: {
          leadgenId,
          pageId,
          crmLeadId,
          processingStatus: 'completed',
          email: leadData.email,
          phone: leadData.phone,
          fullName: leadData.fullName,
        },
      });
    } catch (error) {
      await this.db.metaLead.upsert({
        where: { leadgenId },
        update: { processingStatus: 'failed' },
        create: { leadgenId, pageId, processingStatus: 'failed' },
      });
      throw error; // Let Bull retry
    }
  }
}

By storing the leadgen_id with a unique constraint and tracking processing status, you can safely handle retries and skip duplicates without flooding your CRM.

Common mistakes

  • Skipping signature validation: Opening your webhook to the internet without verifying signatures is a security hole. Always validate the HMAC.
  • Synchronous Graph API calls in the webhook handler: Blocks your response, causes timeouts, triggers retries, and duplicates. Queue the work.
  • Ignoring token expiry: Leads silently stop flowing once your page token expires. Set up monitoring and refresh before expiry.
  • No deduplication logic: Meta retries webhooks. Without checking for duplicates, your sales team gets confused by phantom leads.
  • Not logging leadgen IDs and errors: When something breaks, you need to correlate webhook events with Graph API calls. Log both aggressively.

Key takeaways

  • Build a Meta Lead Ads webhook pipeline that validates signatures, queues work, and responds to Meta immediately—don't block on API calls.
  • Webhooks deliver only the leadgen_id; fetch full lead data from the Graph API asynchronously, not in the request handler.
  • Use a job queue (Bull + Redis) to decouple webhook ingestion from lead processing; retry failures with exponential backoff.
  • Monitor page token expiry and refresh seven days before it expires; alert your team if a token dies.
  • Deduplicate using leadgen_id as a unique key and track processing status; handle retries gracefully.
  • Log everything: webhook events, Graph API calls, errors, and deduplication decisions. You'll need it for debugging at 2 AM.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles