Ads

Lead forms

Collect leads inside Meta and LinkedIn ads with native forms, receive them by webhook or by polling, and route them into your CRM.

A lead form lets someone send you their details without leaving the ad. The network prefills what it knows (name, email, phone), the person taps submit, and you get the lead. Pozy creates forms on Meta (Facebook and Instagram) and LinkedIn with the same endpoints, delivers Meta leads to your webhook the moment they arrive, and lets you read leads from both networks in one list.

Meta LinkedIn
Form lives on The Facebook Page The Company Page that owns the ad account
accountId A connected Facebook account, or a metaads connection with a Page selected The linkedinads connection
Prefilled questions EMAIL, PHONE, FULL_NAME, FIRST_NAME, LAST_NAME Not available through the API
Custom questions Text and multiple choice text and multipleChoice
New leads lead.received webhook, in real time Poll GET /v1/ads/leads
Lead history Stored by Pozy Read live from LinkedIn, kept 90 days by LinkedIn
Test leads Yes No

Reddit retired its native lead forms in September 2026. On Reddit, lead_generation sends people to your own page (see Reddit Ads).

#Collect leads on Meta

Accept the lead ads terms first

Each Facebook Page must accept Meta's Lead Generation terms once, at https://www.facebook.com/ads/leadgen/tos/, before lead ads can run. Until it does, ad creation fails with Meta subcode 1815089. Creating forms and reading leads work before that.

#Create the form

POST /v1/ads/lead-forms takes accountId, name, privacyPolicyUrl and the form content in platformSpecificData.

curl -X POST https://api.pozy.ai/v1/ads/lead-forms \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "name": "Kitchen remodel quote",
    "privacyPolicyUrl": "https://example.com/privacy",
    "platformSpecificData": {
      "questions": [
        { "type": "FULL_NAME" },
        { "type": "EMAIL" },
        { "type": "PHONE" },
        {
          "type": "CUSTOM",
          "key": "budget",
          "label": "What is your budget?",
          "options": [
            { "key": "under_10k", "value": "Under $10k" },
            { "key": "over_10k", "value": "$10k or more" }
          ]
        }
      ],
      "thankYouTitle": "Thanks, we will call you within a day",
      "thankYouButtonText": "See our work",
      "thankYouButtonType": "VIEW_WEBSITE",
      "thankYouWebsiteUrl": "https://example.com/projects"
    }
  }'
Response
{ "status": "success", "form": { "id": "1029384756102938", "name": "Kitchen remodel quote" } }

Prefilled question types make their own label and key, so send only { "type": "..." }. A CUSTOM question needs key and label, plus options for a choice question.

Creating a form is not idempotent. A retry makes a second form, so do not retry it automatically.

#Attach it to an ad

Send goal: "lead_generation" and leadGenFormId on POST /v1/ads/create or POST /v1/ads/boost. No linkUrl is needed: the form is the destination, and Pozy sets the ad set's lead optimization and promoted Page for you.

JSON
{
  "accountId": "66b2e19d8c3f5a7e9d0b1c2e",
  "adAccountId": "act_1234567890",
  "name": "Remodel quote, lead ad",
  "goal": "lead_generation",
  "leadGenFormId": "1029384756102938",
  "budgetAmount": 20,
  "budgetType": "daily",
  "headline": "Get a free remodel quote",
  "body": "Tell us about your kitchen. We reply within a day.",
  "callToAction": "SIGN_UP",
  "imageUrl": "https://cdn.example.com/kitchen.jpg"
}

Every ad in a lead ad set needs its own leadGenFormId. Instagram lead ads use the Page's form. The full create request is in Meta Ads.

#Send a test lead

POST /v1/ads/lead-forms/{formId}/test-leads puts a fake lead on the form so you can check your webhook and field mapping before spending.

cURL
curl -X POST https://api.pozy.ai/v1/ads/lead-forms/1029384756102938/test-leads \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "fieldData": [{ "name": "email", "values": ["test@example.com"] }] }'

Meta allows one pending test lead per form. Delete it with DELETE /v1/ads/lead-forms/{formId}/test-leads before creating another.

#Receive leads

Subscribe a webhook endpoint to lead.received. Pozy forwards each Meta lead as soon as Meta reports it.

#Form options

Everything below goes inside platformSpecificData. Unknown keys return 400.

Field What it does
questions Prefilled and custom questions, in order
thankYouTitle, thankYouBody, thankYouButtonText, thankYouButtonType, thankYouWebsiteUrl The screen after submit
businessPhoneNumber, countryCode Required together when thankYouButtonType is CALL_BUSINESS, and only then. The number is international with + (+12025550123); countryCode is ISO 3166-1 alpha-2.
contextCard An intro screen: title, content, style (LIST_STYLE or PARAGRAPH_STYLE), buttonText, and coverPhoto (a direct public JPEG or PNG URL, up to 5 MB)
customDisclaimer Your own legal text with title, body and consent checkboxes
isOptimizedForQuality Adds a review step before submit, for fewer but better leads
isPhoneSmsVerifyEnabled Verifies the phone number by text message
locale, followUpActionUrl, privacyPolicyLinkText, questionPageCustomHeadline Language, follow-up link and wording
blockDisplayForNonTargetedViewer Hides the form from people outside the ad's targeting

MESSAGE_BUSINESS is not accepted as a thank-you button. The older top-level form fields still work when platformSpecificData is absent, but mixing the two shapes in one request returns 400.

#Collect leads on LinkedIn

LinkedIn forms use the same endpoints with a LinkedIn-shaped platformSpecificData, picked by the linkedinads accountId. They belong to the Company Page of the ad account, so adAccountId goes inside the form.

cURL
curl -X POST https://api.pozy.ai/v1/ads/lead-forms \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "accountId": "66b2e19d8c3f5a7e9d0b1c2f",
    "name": "Q3 benchmark report",
    "privacyPolicyUrl": "https://example.com/privacy",
    "platformSpecificData": {
      "adAccountId": "517258773",
      "headline": "Get the 2027 benchmark report",
      "description": "Tell us where to send it.",
      "state": "PUBLISHED",
      "questions": [
        { "kind": "text", "name": "company", "question": "Company name", "maxResponseLength": 120 },
        { "kind": "multipleChoice", "name": "team_size", "question": "Team size",
          "choices": [ { "id": 1, "text": "1 to 10" }, { "id": 2, "text": "11 to 50" }, { "id": 3, "text": "51 or more" } ] }
      ],
      "consents": [ { "description": "I agree to be contacted about this report", "required": true } ]
    }
  }'
Field Rule
headline Up to 255 characters
description Up to 1,500 characters
state DRAFT (default) or PUBLISHED
questions kind, name and question on each. multipleChoice also needs choices. maxResponseLength defaults to 300.
consents { description, required } checkboxes
locale { country, language }

Pass form.id as leadGenFormId on a LinkedIn ad with goal: "lead_generation" (see LinkedIn Ads). Creating a form is not idempotent.

LinkedIn has no lead webhook. Read leads with GET /v1/ads/leads?accountId=...&adAccountId=..., optionally with formId, on your own schedule. LinkedIn keeps responses for 90 days, so copy them out regularly.

Reading LinkedIn leads needs one more permission

If GET /v1/ads/leads returns 400 ads_connection_required for a LinkedIn account, the connection predates the permission that reads lead responses. Run the LinkedIn connect flow again to grant it. Creating, listing and archiving forms work without it.

#Read leads

GET /v1/ads/leads is one list across every form and both networks, newest first. It is the easiest feed for a CRM sync.

cURL
curl "https://api.pozy.ai/v1/ads/leads?accountId=66b2e19d8c3f5a7e9d0b1c2d&status=new&limit=50" \
  -H "Authorization: Bearer $POZY_API_KEY"
Response (trimmed)
{
  "status": "success",
  "leads": [
    {
      "id": "66f1a2b3c4d5e6f7a8b9c0d1",
      "leadgenId": "5566778899001122",
      "formId": "1029384756102938",
      "formName": "Kitchen remodel quote",
      "adId": "120260000000000000",
      "campaignId": "120250000000000000",
      "isOrganic": false,
      "createdTime": "2027-02-14T10:31:05.000Z",
      "fields": { "full_name": "Jamie Rivera", "email": "jamie@example.com", "phone_number": "+14155550199", "budget": "over_10k" },
      "consents": [],
      "status": "new"
    }
  ],
  "pagination": { "hasMore": false, "cursor": null }
}
Filter Meaning
formId, campaignId, accountId, accountIds, adAccountId Scope the list. LinkedIn needs adAccountId.
platform meta or linkedin
status One of the statuses below
q Free-text search, up to 200 characters
since Unix seconds. A millisecond value returns 400.
limit, cursor Up to 100 per page; pass pagination.cursor back for the next page

GET /v1/ads/lead-forms/{formId}/leads reads one Meta form. GET /v1/ads/lead-forms lists forms and DELETE /v1/ads/lead-forms/{formId} archives one; neither network has a hard delete.

fields maps each question key to the answer. In the webhook, multiple-choice answers are the option key (over_10k); from LinkedIn they are the choice text.

#Track a lead to a sale

Each stored Meta lead has a status you can move along your funnel: new, contacted, unreachable, qualified, converted, disqualified. PATCH /v1/ads/leads/{id} sets it, with an optional value and currency when it converts.

cURL
curl -X PATCH https://api.pozy.ai/v1/ads/leads/66f1a2b3c4d5e6f7a8b9c0d1 \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "converted", "value": 18500, "currency": "USD" }'

{id} is Pozy's lead id, or Meta's leadgenId with accountId in the body. GET /v1/ads/leads/counts returns how many leads sit in each status, every status present with 0 when empty, for a badge or a pipeline view.

To teach Meta which leads turn into customers, send the outcome back as a conversion with user.leadId set to Meta's lead id. See Conversions.

#Route leads to a CRM

The lead.received payload carries the lead and the account it came from:

lead.received (trimmed)
{
  "id": "evt_8a1f4c2e9b7d3a60",
  "event": "lead.received",
  "timestamp": "2027-02-14T10:31:07.000Z",
  "profiles": [{ "id": "66b2e19d8c3f5a7e9d0b1c2a", "externalId": "client_482" }],
  "lead": {
    "id": "66f1a2b3c4d5e6f7a8b9c0d1",
    "leadgenId": "5566778899001122",
    "formId": "1029384756102938",
    "formName": "Kitchen remodel quote",
    "adId": "120260000000000000",
    "campaignId": "120250000000000000",
    "fields": { "full_name": "Jamie Rivera", "email": "jamie@example.com", "budget": "over_10k" },
    "consents": [{ "checkboxKey": "call_consent", "isChecked": true }],
    "isOrganic": false,
    "createdAt": "2027-02-14T10:31:05.000Z"
  },
  "account": { "id": "66b2e19d8c3f5a7e9d0b1c2d", "platform": "facebook", "profileId": "66b2e19d8c3f5a7e9d0b1c2a" }
}

A handler that verifies the signature, drops repeats and upserts a contact:

Node (Express)
import crypto from "node:crypto";
import express from "express";

const app = express();
const seen = new Set<string>(); // use a database table in production

app.post("/webhooks/pozy", express.raw({ type: "application/json" }), async (req, res) => {
  const expected = crypto.createHmac("sha256", process.env.POZY_WEBHOOK_SECRET!).update(req.body).digest("hex");
  const given = req.header("X-Pozy-Signature") ?? "";
  if (given.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body.toString("utf8"));
  res.status(200).end(); // answer fast, work after

  if (event.event !== "lead.received" || seen.has(event.lead.leadgenId)) return;
  seen.add(event.lead.leadgenId);

  const tenant = event.profiles?.[0]?.externalId; // your customer id, if you build a platform
  await upsertCrmContact(tenant, {
    name: event.lead.fields.full_name,
    email: event.lead.fields.email,
    phone: event.lead.fields.phone_number,
    source: `${event.lead.formName} (ad ${event.lead.adId ?? "organic"})`,
    callConsent: event.lead.consents.find((c: { checkboxKey: string }) => c.checkboxKey === "call_consent")?.isChecked ?? false,
  });
});

declare function upsertCrmContact(tenant: string | undefined, contact: Record<string, unknown>): Promise<void>;
  • Dedupe on lead.leadgenId (Meta's lead id) or on the event id. Retries reuse the same event id.
  • lead.adId is null for organic and test leads.
  • consents lists every custom disclaimer checkbox. Use it to record call and text consent.
  • account.profileId and profiles[].externalId tell you which of your customers the lead belongs to. See Route webhooks to tenants.
  • For LinkedIn, run the same mapping on a schedule over GET /v1/ads/leads?platform=linkedin.

#When leads stop arriving

Symptom Cause Fix
No lead.received from a Page that used to send them The Page lost its lead subscription while other events still arrive Read GET /v1/accounts/{accountId}/webhook-subscription. If it shows leadgen: false, call POST on the same path to subscribe the Page again.
No leads from a Facebook account connected before ads were enabled The connection lacks the lead retrieval permission Reconnect the Facebook account.
Ad creation fails with subcode 1815089 The Page has not accepted the Lead Generation terms Accept them once at https://www.facebook.com/ads/leadgen/tos/.
422 on form create, Meta code 3 Meta refused the form without naming a field If you set isPhoneSmsVerifyEnabled, drop it and set it in Meta's form builder instead.
400 ads_connection_required on LinkedIn leads Missing lead reading permission Reconnect LinkedIn Ads.
400 on since A millisecond timestamp Divide by 1,000.

#Endpoints

Endpoint What it does
GET /v1/ads/leads List submitted leads
GET /v1/ads/leads/counts Count leads by status
PATCH /v1/ads/leads/{id} Update a lead's status
GET /v1/ads/lead-forms List lead forms
POST /v1/ads/lead-forms Create a lead form
GET /v1/ads/lead-forms/{formId} Get a lead form
DELETE /v1/ads/lead-forms/{formId} Archive a lead form
GET /v1/ads/lead-forms/{formId}/leads List leads for a single form
POST /v1/ads/lead-forms/{formId}/test-leads Create a test lead
DELETE /v1/ads/lead-forms/{formId}/test-leads Delete a test lead

#Webhook events

Event When it fires
lead.received Lead received.