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"
    }
  }'
Before wiring this into production, append ?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).

The token is a password. Keep it server-side: in Make.com, n8n or your own backend. Never in website code, where it becomes public and anyone can inject leads.

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

POSThttps://app.palyri.com/api/leads/make-ingest

We 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.

FieldTypeDescription
dataobjectLead 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).
sourcestringSource label, e.g. "Quiz". Defaults to "Meta Ads".
external_idstringYour own idempotency key. Sending the same id again returns the existing lead instead of creating a duplicate.
campaign_attributionobjectUTMs, ad identifiers and clickid. Details in the Ad attribution section.
ads_consentboolWhether the form collected marketing consent. Without true we send no Meta events for this lead.
fbp / fbc / fbclidstringThe _fbp and _fbc cookies plus fbclid from the URL. They tie a later conversion back to the ad click.
event_idstringEvent identifier for Meta. Send the same one your pixel fires and Meta merges both copies into a single conversion.
value / currencynumber / stringValue of the submission, if already known at write time. Currency in ISO 4217 format, e.g. PLN.
funnel_iduuidThe Palyri funnel to assign the lead to. Ignored if it does not belong to your clinic.

Response:

HTTP 200
{ "ok": true, "accepted": true }
Why 200 instead of a lead id: the Make.com HTTP module drops the connection after 40 seconds. We acknowledge as soon as the token and payload check out, and do the rest (write, coupon, notifications, Meta delivery) right after responding. The result shows up in the panel under 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.

Condition for cost computation: we check the ad identifier against the ad accounts connected to Palyri. If the account is not connected yet, we store the campaign, creative and ad set labels, but the acquisition cost column stays empty, because there is nowhere to get spend data from. Once the account is connected, the nightly sync attaches spend to previously collected leads as well.

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:

EventWhenDescription
Leadon submissionThe submission reached the CRM. Sent right after the lead is accepted.
Scheduleon depositThe lead paid a deposit. A quality signal Meta uses to find similar people.
Purchaseon revenueThe lead paid for a treatment. Carries value and currency, so Meta can compute campaign return.
Without "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

POSThttps://app.palyri.com/api/leads/conversion

Deposits 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
  }'
FieldTypeDescription
lead_id / external_id / phone / emailstringHow to identify the lead. Checked in this order, first match wins.
event_namestringPurchase (default), Schedule, CompleteRegistration, Contact, SubmitApplication, StartTrial, Subscribe.
value / currencynumber / stringConversion value and ISO 4217 currency.
event_idstringYour 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_leadboolWhen 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.

EventTypeDescription
lead.createdeventA lead reached the CRM from any source, including your own webhook.
lead.status_changedeventThe lead's status changed. The payload carries the old and new status.
lead.convertedeventThe 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)
}
Respond fast. Any 2xx status is an acknowledgement. We wait 8 seconds. If your processing takes longer, store the event and process it asynchronously instead of holding our connection open.

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.

An endpoint that fails 20 times in a row is disabled and flagged in the panel. A 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

CodeCategoryDescription
200 / 202successRequest accepted.
400payload errorInvalid JSON, or an unsupported event name or currency.
401authenticationMissing token, token unknown, revoked or expired, or the HMAC signature does not match.
403permissionsLead intake disabled in the panel, or the token lacks the required scope.
404not foundOnly when reporting conversions: we cannot match the lead.
413payloadRequest body exceeds 256 kB.
429rate limitOver 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.