# 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](https://pozy.ai/docs/ads/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`.

```bash 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": "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"
    }
  }'
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/ads/lead-forms", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    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",
    },
  }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
console.log(data.form.id);
```
```python Python
import os, requests

resp = requests.post(
    "https://api.pozy.ai/v1/ads/lead-forms",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    json={
        "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",
        },
    },
)
print(resp.json()["form"]["id"])
```

```json
{ "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](https://pozy.ai/docs/ads/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.

```bash
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](https://pozy.ai/docs/webhooks) 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.

```bash
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](https://pozy.ai/docs/ads/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.

```bash
curl "https://api.pozy.ai/v1/ads/leads?accountId=66b2e19d8c3f5a7e9d0b1c2d&status=new&limit=50" \
  -H "Authorization: Bearer $POZY_API_KEY"
```

```json
{
  "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.

```bash
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](https://pozy.ai/docs/ads/conversions).

## Route leads to a CRM

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

```json
{
  "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:

```ts
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](https://pozy.ai/docs/build/route-webhooks).
- 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`](https://pozy.ai/docs/api/listLeads) | List submitted leads |
| [`GET /v1/ads/leads/counts`](https://pozy.ai/docs/api/countLeads) | Count leads by status |
| [`PATCH /v1/ads/leads/{id}`](https://pozy.ai/docs/api/updateLead) | Update a lead's status |
| [`GET /v1/ads/lead-forms`](https://pozy.ai/docs/api/listLeadForms) | List lead forms |
| [`POST /v1/ads/lead-forms`](https://pozy.ai/docs/api/createLeadForm) | Create a lead form |
| [`GET /v1/ads/lead-forms/{formId}`](https://pozy.ai/docs/api/getLeadForm) | Get a lead form |
| [`DELETE /v1/ads/lead-forms/{formId}`](https://pozy.ai/docs/api/archiveLeadForm) | Archive a lead form |
| [`GET /v1/ads/lead-forms/{formId}/leads`](https://pozy.ai/docs/api/listFormLeads) | List leads for a single form |
| [`POST /v1/ads/lead-forms/{formId}/test-leads`](https://pozy.ai/docs/api/createTestLead) | Create a test lead |
| [`DELETE /v1/ads/lead-forms/{formId}/test-leads`](https://pozy.ai/docs/api/deleteTestLead) | Delete a test lead |

## Webhook events

| Event | When it fires |
| --- | --- |
| [`lead.received`](https://pozy.ai/docs/webhooks/events#lead-received) | Lead received. |
