Developer documentation
Palyri Leads API
Send leads into Palyri from any source: your own funnel, Make.com, n8n, Zapier or your backend. Report back which lead converted and how much it was worth. Everything counts in Palyri statistics exactly the same way as leads from our own funnels.
Start here
You need a single token. Generate it in the panel: Settings → Integrations → API tokens. The token is shown once. We only store its hash.
curl -X POST 'https://app.palyri.com/api/leads/make-ingest' \
-H 'X-Palyri-Token: pal_xxxxxxxxxxxxxxxx' \
-H 'Content-Type: application/json' \
-d '{
"source": "Quiz",
"external_id": "zgloszenie-8842",
"ads_consent": true,
"data": {
"imię": "Anna",
"telefon": "500600700",
"email": "anna@example.com",
"Jaki masz problem?": "Zmarszczki"
},
"campaign_attribution": {
"utm_campaign": "Botoks Q3",
"utm_content": "Kreacja A",
"ad_id": "120210000000000000"
}
}'?dry_run=1 to the URL. We respond with how we parsed your fields and what is missing, without creating a lead.Authentication
Every request carries the X-Palyri-Token header. A token belongs to one clinic and has a permission scope: leads:write (creating leads) and optionally conversions:write (reporting revenue).
For integrations with higher requirements, enable HMAC signing when creating the token. The token alone then stops being enough: the request must also carry X-Palyri-Timestamp (unix time in seconds) and X-Palyri-Signature. The signature binds the request to its body and time, so a captured request cannot be replayed after 5 minutes.
// Node.js
const crypto = require("crypto")
const body = JSON.stringify(payload)
const timestamp = Math.floor(Date.now() / 1000)
const signature = crypto
.createHmac("sha256", SIGNING_SECRET)
.update(`${timestamp}.${body}`)
.digest("hex")
await fetch("https://app.palyri.com/api/leads/make-ingest", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Palyri-Token": TOKEN,
"X-Palyri-Timestamp": String(timestamp),
"X-Palyri-Signature": `sha256=${signature}`,
},
body,
})Creating leads
https://app.palyri.com/api/leads/make-ingestWe accept three payload shapes and normalize them ourselves: the native Meta Lead Ads format (field_data), the Make.com Facebook Lead Ads module format (mappable_field_data) and the simplest one, a data object with field/value pairs. Field names are recognized in both Polish and English.
| Field | Type | Description |
|---|---|---|
data | object | Lead fields. Name/phone/email are detected automatically; any other field is stored as a question and answer. Values can be a string, number, boolean or array (multiple choice). |
source | string | Source label, e.g. "Quiz". Defaults to "Meta Ads". |
external_id | string | Your own idempotency key. Sending the same id again returns the existing lead instead of creating a duplicate. |
campaign_attribution | object | UTMs, ad identifiers and clickid. Details in the Ad attribution section. |
ads_consent | bool | Whether the form collected marketing consent. Without true we send no Meta events for this lead. |
fbp / fbc / fbclid | string | The _fbp and _fbc cookies plus fbclid from the URL. They tie a later conversion back to the ad click. |
event_id | string | Event identifier for Meta. Send the same one your pixel fires and Meta merges both copies into a single conversion. |
value / currency | number / string | Value of the submission, if already known at write time. Currency in ISO 4217 format, e.g. PLN. |
funnel_id | uuid | The Palyri funnel to assign the lead to. Ignored if it does not belong to your clinic. |
Response:
HTTP 200
{ "ok": true, "accepted": true }Recent incoming.We reject duplicates three ways: by Meta's leadgen_id, by your external_id, and, as a safety net for submissions without any identifier, by the same phone number within a 10 minute window.
Ad attribution
This is the part that decides whether Palyri can compute your cost per lead. We need the ad identifier, not just its name: a name can be changed in Meta on the fly, the identifier never changes.
In Ads Manager, set the URL parameters for the ad pointing at your funnel:
utm_source=facebook
utm_medium=paid
utm_campaign={{campaign.name}}
utm_term={{adset.name}}
utm_content={{ad.name}}
ad_id={{ad.id}}
adset_id={{adset.id}}
campaign_id={{campaign.id}}Your funnel reads these parameters from the URL and passes them to us with the lead, either inside the campaign_attribution object or as top-level fields. Both forms work.
Consent & conversion feedback to Meta
Palyri sends events back to Meta through the Conversions API, so the campaign optimizes on what actually happened, not just on clicks. We send three events:
| Event | When | Description |
|---|---|---|
Lead | on submission | The submission reached the CRM. Sent right after the lead is accepted. |
Schedule | on deposit | The lead paid a deposit. A quality signal Meta uses to find similar people. |
Purchase | on revenue | The lead paid for a treatment. Carries value and currency, so Meta can compute campaign return. |
"ads_consent": true we send none of these events. It is still ad profiling, because a hashed email and phone go to Meta, so a missing field is treated as no consent, not as consent by default.You improve match quality by sending fbp, fbc and fbclid. A deposit lands in the CRM days after the submission, when there is no browser and no cookies left, so we store them at submission time and replay them with every later event.
If your funnel fires its own pixel, send us the same event_id. Meta deduplicates the pair and counts one conversion instead of two.
Reporting revenue
https://app.palyri.com/api/leads/conversionDeposits and revenue entered in Palyri flow to Meta automatically. This endpoint is for sales that close outside Palyri: in your system, at your register. It requires a token with the conversions:write scope.
curl -X POST 'https://app.palyri.com/api/leads/conversion' \
-H 'X-Palyri-Token: pal_xxxxxxxxxxxxxxxx' \
-H 'Content-Type: application/json' \
-d '{
"external_id": "zgloszenie-8842",
"event_name": "Purchase",
"value": 2400,
"currency": "PLN",
"update_lead": true
}'| Field | Type | Description |
|---|---|---|
lead_id / external_id / phone / email | string | How to identify the lead. Checked in this order, first match wins. |
event_name | string | Purchase (default), Schedule, CompleteRegistration, Contact, SubmitApplication, StartTrial, Subscribe. |
value / currency | number / string | Conversion value and ISO 4217 currency. |
event_id | string | Your own event identifier. By default one event of a given type per lead, using the same pattern Palyri uses, so the same revenue reported from both sides counts once. Report another, separate purchase with your own event_id. |
update_lead | bool | When true, the value is also written on the lead, so revenue reports in Palyri show the same numbers as Meta. |
HTTP 202
{ "ok": true, "lead_id": "…", "event_name": "Purchase", "event_id": "purchase-…", "queued": true }202means “accepted for delivery”, not “delivered to Meta”. The event goes through a retrying queue, and the delivery state is visible in the integrations panel.Outgoing webhooks
The other direction of the integration: Palyri sends a request to you when something happens to a lead. You register the endpoint in the panel (Settings → Integrations → Outgoing webhooks) and receive a secret for signature verification.
| Event | Type | Description |
|---|---|---|
lead.created | event | A lead reached the CRM from any source, including your own webhook. |
lead.status_changed | event | The lead's status changed. The payload carries the old and new status. |
lead.converted | event | The lead recorded non-zero revenue for the first time. The payload carries value and currency. |
An example request we will send:
POST https://your-server.example/palyri
Content-Type: application/json
X-Palyri-Event: lead.status_changed
X-Palyri-Delivery: 3f2b… // delivery id, stable across retries
X-Palyri-Timestamp: 1787327270
X-Palyri-Signature: sha256=…
{
"event": "lead.status_changed",
"delivery_id": "3f2b…",
"sent_at": "2026-08-21T15:44:15.155Z",
"clinic_id": "…",
"data": {
"lead": {
"id": "…",
"name": "Anna Kowalska",
"phone": "+48500600700",
"email": "anna@example.com",
"status": "deposit_paid",
"source": "Meta Ads",
"ad_name": "Kreacja A",
"external_id": "zgloszenie-8842",
"treatment_value": null,
"campaign_attribution": { "utm_campaign": "Botoks Q3" }
},
"status": "deposit_paid",
"previous_status": "contacted"
}
}Verifying the signature on your side:
// Node.js: the comparison MUST be constant-time
const crypto = require("crypto")
function verify(req, rawBody, SECRET) {
const ts = Number(req.headers["x-palyri-timestamp"])
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${ts}.${rawBody}`)
.digest("hex")
const got = String(req.headers["x-palyri-signature"]).replace(/^sha256=/, "")
const a = Buffer.from(expected), b = Buffer.from(got)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}A failed delivery is retried six times with growing backoff (1 min, 5 min, 25 min, ~2 h, ~10 h). X-Palyri-Delivery stays the same across retries, so use it as an idempotency key, because the same event can reach you twice.
4xx response (except 408 and 429) is treated as a configuration error and not retried, which saves your server and our delivery runs. Redirects are not followed: provide the final URL directly.Limits & errors
| Code | Category | Description |
|---|---|---|
200 / 202 | success | Request accepted. |
400 | payload error | Invalid JSON, or an unsupported event name or currency. |
401 | authentication | Missing token, token unknown, revoked or expired, or the HMAC signature does not match. |
403 | permissions | Lead intake disabled in the panel, or the token lacks the required scope. |
404 | not found | Only when reporting conversions: we cannot match the lead. |
413 | payload | Request body exceeds 256 kB. |
429 | rate limit | Over 120 requests per minute per token. The Retry-After header says when to try again. |
After rotating a token in the panel, the old one keeps working for 48 hours, so you can swap it in your scenarios without interrupting lead intake. The response then carries a warning field. If the token leaked, revoke the old one immediately. The button sits next to the warning in the panel.