Leadferno delivers events to your endpoint as HTTP POSTs. You manage subscriptions with the /v0/zapier/* endpoints — despite the name, they are general-purpose and not tied to Zapier.
Subscribe
JavaScriptPOST /v0/zapier/subscribe
Authorization: Bearer <access_token>
Content-Type: application/json
{
"profile": "<profile-uuid>",
"url": "https://yourapp.example.com/incoming/leadferno",
"trigger": "new_message"
}profileandurlare required.triggeris validated — an unknown or missing trigger returns422 "Invalid trigger".Requires
staffscope.The response is
{ "uuid": "<subscription-uuid>" }. Store that UUID — it's the only handle you have to unsubscribe later.
Available triggers
Trigger | Fires when |
|---|---|
| A message is sent or received on a thread — inbound and outbound (see Receive and resolve webhook deliveries) |
| An inbound message opens a new thread |
| A lead arrives through a web lead form |
| A new contact is created |
| A tag is added to a thread |
| A thread's status changes to a specific value (see below) |
updated_lead_status needs a target status
JSON{
"profile": "<profile-uuid>",
"url": "https://yourapp.example.com/incoming/leadferno",
"trigger": "updated_lead_status",
"metadata": { "state": "closed_won" }
}The event fires only when a thread changes to exactly that status. Valid values: new, open, closed_won, closed_lost, closed_other, inactive. Subscribe once per status you care about.
One subscription per (profile, trigger)
Duplicates are not rejected, and each duplicate produces a duplicate delivery. Keep one subscription per trigger and de-duplicate on your side anyway.
Unsubscribe
JavaScriptDELETE /v0/zapier/unsubscribe/{subscription_uuid}Store subscription UUIDs on the connection record so that when a customer disconnects your integration, you actually stop the event flow.
See also: Receive and resolve webhook deliveries
Receive and resolve webhook deliveries
Delivery format
Method:
POST, content typeapplication/json.No authentication header and no signature. Treat every inbound POST as unverified.
Hardening while there's no signature
Put a high-entropy secret token in the webhook URL path or query string you register.
Validate that every UUID in the payload is a well-formed v4 UUID before using it.
For the reference-style payloads, treat the webhook as a trigger to fetch — the authenticated API call you make next is the trustworthy part.
Make your handler idempotent, keyed on the payload UUIDs, so a duplicate delivery is a no-op.
new_message fires for messages you send, too
new_message fires whenever a message is delivered on a thread — including messages your own integration sends through the API. When you resolve the message, check direction: egress is outbound (likely yours), ingress is from the contact. Keeping your handler idempotent on the message UUID prevents a send/receive loop. Draft and scheduled messages don't fire it; they fire when actually sent.
Payload shapes
Some payloads are thin references you resolve with an API call; two carry data inline.
JSON// new_message — reference
{ "thread": "<uuid>", "message": "<uuid>", "profile": "<uuid>" }
// new_lead — reference, same shape as new_message
{ "thread": "<uuid>", "message": "<uuid>", "profile": "<uuid>" }
// new_leadform_lead — reference; "leadform_fields" is a message UUID
{ "thread": "<uuid>", "leadform_fields": "<uuid>", "profile": "<uuid>" }
// updated_lead_status — reference
{ "thread": "<uuid>", "profile": "<uuid>" }
// thread_new_tag — inline. Note the thread key is "uuid", not "thread".
{
"uuid": "<thread-uuid>",
"profile": "<profile-uuid>",
"tag": { "uuid": "<tag-uuid>", "label": "VIP", "color": "#RRGGBB" }
}
// new_contact — inline, and a larger object
{
"profile": { ...profile fields... },
"prospect": {
"uuid": "<uuid>",
"first_name": "...", "last_name": "...",
"email": "...", "cellphone": "15551234567",
"creation": "<ISO-8601>",
"metadata": [ { "uuid": "...", "value": "...", "type": "...", "label": "..." } ]
}
}Even for the inline payloads, fetch the canonical record (below) rather than mapping the webhook fields directly — the inline shapes differ from the REST responses (raw field names, cellphone without a leading +).
Response codes
You return | Leadferno does |
|---|---|
| Nothing further — delivery succeeded |
| Treats the endpoint as permanently gone and auto-unsubscribes that trigger |
Anything else | Logs it. The subscription stays active. No retry. |
There is no retry. A failed delivery is a lost event. So:
Acknowledge fast, process asynchronously — return
2xxas soon as you've durably queued the payload, then do resolution and CRM writes.Monitor your endpoint's error rate, and run a periodic reconciliation poll of recent threads so a delivery gap doesn't become permanent data loss.
Only return 410 when you actually want the subscription cancelled.
Resolve an event into a full record
JavaScriptGET /v0/profiles/{profile_uuid}/threads/{thread_uuid}
Authorization: Bearer <access_token>Returns the thread with:
uuid,state,read,spam,isCallRequest,creation,modificationa nested
prospect—first_name,last_name,cellphone(as+<digits>or""),email(omitted entirely when unset),consent,blocked,metadataassigneeandtagswhen presentmessages— the 10 most recent only, newest first
For full conversation history, page through:
JavaScriptGET /v0/profiles/{profile_uuid}/threads/{thread_uuid}/messages?offset=0&limit=50which also accepts state and type filters.
Determining the channel / lead source
There is no
sourcefield on a contact — you can't read "web form vs. Facebook vs. SMS" off theprospect.Use the message
type(sms,fb,leadform_fields,call_request, …) as the channel, anddirection(ingress/egress) for inbound vs. outbound.A message's
sourcefield is free text, set only for messages that originate in a Leadbox, lead form, or web widget. It is not set for inbound SMS or Facebook, so don't rely on it as a channel indicator.