Developers
CRM sync
Pixl posts each lead and contact to your CRM as it is now, whenever it changes. Every post is signed, so you can check it came from Pixl.
Lead and contact updates
When a lead or a contact is created or changes in Pixl, Pixl posts its current state to your HTTPS URL, within a few minutes. Use it to keep your CRM in step. For calls, messages and consent as they happen, use event webhooks instead; they are a separate feature, with their own signature.
Events
lead.created, lead.updated- A lead, with its contact.
createduntil your system answers with its own id;updatedafter that. contact.created, contact.updated- A contact, the same way.
contact.erased- A person was erased in Pixl. It carries ids only; erase them on your side too.
ping- A test, when you press Send test or call the test endpoint.
No messages, ever
Set it up
The simplest way is in Pixl: Connections › Developers › Send leads to your CRM. Pixl makes the signing secret for you. To set it up from your own code instead:
POST/crm/connectionsScope: contacts:write
Body
namestringRequired- A name for the connection.
providerstringwebhook.config.urlstringRequired- Your endpoint. It must start with https://.
config.secretstringRequired- At least 16 random characters. Pixl signs every post with it.
config.fieldsstring[]- What may be sent:
contact(name, phone, email),status(stage, score, source, value, dates),ads(which ad brought them),enquiry(what they asked about, and your notes). Left out, all four; in a health business, all butenquiry, which can hold health details. field_mappingobject- Rename keys for your CRM:
{ "lead_score": "score" }. stage_mappingobject- Your CRM's name for each stage. The mapped value arrives as
stage.
curl -X POST "https://api.pixlagents.com/api/v1/crm/connections" \
-H "Authorization: Bearer $PIXL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Clinic CRM",
"provider": "webhook",
"config": {
"url": "https://crm.example.com/pixl/webhook",
"secret": "replace-with-a-long-random-secret",
"fields": [
"contact",
"status"
]
}
}'The secret never comes back in a response; keep your own copy. The rest of the connection:
GET /crm/connectionsandPUT /crm/connections/{connection_id}. Leaveconfig.secretout of an update to keep the old one.POST /crm/connections/{connection_id}/testsends a signedping.POST /crm/connections/{connection_id}/sync-nowsends what's waiting now, and resumes a connection that paused.DELETE /crm/connections/{connection_id}removes it.
What Pixl sends
A POST with a JSON body and these headers:
Headers
X-Pixl-Event- The event, like
lead.updated. X-Pixl-Signaturesha256=and the signature of the body (below).X-Pixl-Timestamp- When it was sent, in Unix seconds.
{
"data": {
"booked_at": null,
"contact": {
"email": "asha@example.com",
"first_name": "Asha",
"last_name": "Rao",
"name": "Asha Rao",
"phone": "+919876543210"
},
"contact_id": "contact_321",
"created_at": "2026-10-26T04:58:00",
"deal_value": null,
"expected_close_date": null,
"lead_score": 40,
"lead_source": "web",
"stage_name": "New enquiry",
"stage_type": "open"
},
"entity_id": "lead_555",
"entity_type": "lead",
"event": "lead.created",
"external_id": null,
"workspace_id": "workspace_123"
}dataholds only the groups you allowed inconfig.fields.- Each post is the record as it is now. Several quick changes may arrive as one post.
external_idis your own id for the record, once your system has given one.
Check the signature
The signature is an HMAC-SHA256 of the raw request body, keyed with your secret, in hex, after sha256=. Work it out on the bytes exactly as they arrive, before parsing the JSON, and compare it in constant time.
import crypto from "node:crypto";
import express from "express";
const app = express();
// The raw body: the signature covers the exact bytes Pixl sent.
app.post("/pixl/webhook", express.raw({ type: "application/json" }), (req, res) => {
const expected =
"sha256=" +
crypto.createHmac("sha256", process.env.PIXL_WEBHOOK_SECRET).update(req.body).digest("hex");
const given = req.get("X-Pixl-Signature") || "";
const ok =
given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
// Handle event.event, event.entity_type, event.entity_id and event.data here.
res.json({ external_id: "crm_123" }); // Optional: your own id for this record.
});
app.listen(3000);About the timestamp
X-Pixl-Timestamp isn't part of the signature, so don't rely on it alone to refuse a replayed post. Applying the same post twice is safe: it carries the record's full current state.Answering, retries and the log
- Answer with any
2xxwithin 15 seconds. Do slow work after you answer. - Optionally answer with JSON
{ "external_id": "crm_123" }. Pixl keeps it and sends it with every later post for that record. - Anything else, or no answer in time, is a failure. Pixl tries each post up to three times, waiting longer each time.
- After five failed posts in a row, the connection pauses and your workspace's owner is told. Fix your endpoint, then send now to resume.
GET/crm/connections/{connection_id}/deliveriesScope: contacts:read
The latest delivery attempts, newest first: 20 by default, up to 100 with limit. Ids and outcomes only, never the record itself.
curl -G "https://api.pixlagents.com/api/v1/crm/connections/connection_123/deliveries" \
-H "Authorization: Bearer $PIXL_API_KEY" \
--data-urlencode 'limit=20'{
"items": [
{
"id": "delivery_1",
"event": "lead.updated",
"entity_type": "lead",
"entity_id": "lead_555",
"status": "ok",
"http_status": 200,
"error": null,
"at": "2026-10-26T05:10:02"
},
{
"id": "delivery_2",
"event": "contact.created",
"entity_type": "contact",
"entity_id": "contact_321",
"status": "error",
"http_status": 500,
"error": "webhook returned HTTP 500",
"at": "2026-10-26T05:08:40"
}
]
}