Ads
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 | ||
|---|---|---|
| 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
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"
}
}'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);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"]){ "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.
{
"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 -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 -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.
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 "https://api.pozy.ai/v1/ads/leads?accountId=66b2e19d8c3f5a7e9d0b1c2d&status=new&limit=50" \
-H "Authorization: Bearer $POZY_API_KEY"{
"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 -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:
{
"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:
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 eventid. Retries reuse the same event id. lead.adIdisnullfor organic and test leads.consentslists every custom disclaimer checkbox. Use it to record call and text consent.account.profileIdandprofiles[].externalIdtell 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. |