Developers
Webhooks
Pixl tells your system when something happens: a call ends, a message arrives or is read, someone opts out, a lead comes in. Every event is signed, so you can check it came from Pixl.
Events
Pick the events each endpoint receives.
Event types
call.completed- Every call that ended, answered or not, with its outcome: phone calls in and out, and calls in a browser.
call.refused- A call you asked for was refused or held back, with the reason and the fix.
message.received- A customer wrote to you, on any channel.
message.sent- Your agent, a teammate or a campaign sent a message to a customer.
message.status- A message you sent moved to
sent,delivered,readorfailed. conversation.handed_off- A conversation now needs a person.
conversation.resolved- A conversation was marked resolved.
contact.opted_out- Someone asked you to stop messaging or calling them.
contact.opted_in- Someone agreed to hear from you, or an opt-out was lifted.
lead.created- A new lead arrived.
Test conversations, the agent chat inside Pixl and the turns of a phone call aren't sent as messages; a call is one call.completed. The list, with labels, is also at GET /webhook-endpoints/event-types.
Lead and contact sync is a separate feature
Add an endpoint
The simplest way is in Pixl: Connections › Developers › Webhooks, then Add an endpoint. The workspace's owner or an admin can do it. From your own code, use a key with webhooks:write:
POST/webhook-endpointsScope: webhooks:write
Body
urlstringRequired- Your endpoint: https://, on port 443 (the default) or 8443, at most 500 characters, and not a private, internal or shared address. A URL that can't be requested as written (a control character, a malformed international name) is refused too. Where it points is checked again before every send.
eventsstring[]Required- At least one event type from the list above.
descriptionstring- Up to 200 characters, for your team.
include_contentboolean- The content switch. Only the owner, signed in to Pixl, can turn it on: a key that sends
trueis refused (see below).
curl -X POST "https://api.pixlagents.com/api/v1/webhook-endpoints" \
-H "Authorization: Bearer $PIXL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://crm.example.com/pixl/events",
"events": [
"call.completed",
"call.refused",
"message.status"
],
"description": "Clinic CRM"
}'{
"id": "endpoint_123",
"url": "https://crm.example.com/pixl/events",
"events": [
"call.completed",
"call.refused",
"message.status"
],
"include_content": false,
"status": "active",
"secret": "whsec_example-secret-shown-only-once-abcd",
"secret_hint": "whsec_…abcd",
"created_at": "2026-10-26T09:00:00Z"
}secretis shown once, here. Store it as you would an API key.- A new or changed endpoint starts receiving events within about 30 seconds.
- Up to 10 endpoints a workspace.
Manage endpoints
Endpoints
ListGET /webhook-endpointsandGET /webhook-endpoints/{endpoint_id}.ChangePATCH /webhook-endpoints/{endpoint_id}with any ofurl,events,description,include_content,enabled.enabled: falseswitches it off;trueswitches it on, and resumes a paused one at once.New secretPOST /webhook-endpoints/{endpoint_id}/rotate-secret, withkeep_previous_for_hoursfrom 0 to 72 (24 when left out). Pixl signs with both secrets until then, so you can switch without missing an event.TestPOST /webhook-endpoints/{endpoint_id}/testsends onewebhook.testnow and waits up to 15 seconds for your answer.RemoveDELETE /webhook-endpoints/{endpoint_id}. Its waiting events and its log go with it.
curl -X POST "https://api.pixlagents.com/api/v1/webhook-endpoints/endpoint_123/test" \
-H "Authorization: Bearer $PIXL_API_KEY"{
"ok": true,
"http_status": 200,
"latency_ms": 184,
"error": null
}status on an endpoint is active, paused (Pixl paused it after failures, and keeps every event) or disabled (someone switched it off; nothing is kept for it).
Adding an endpoint, changing one and making a new secret aren't available to an AI assistant connected to Pixl: those stay a person's choice.
What Pixl sends
A POST to your URL, with a JSON body and these headers:
Headers
X-Pixl-Event- The event type, like
call.completed. X-Pixl-Delivery- One id for this event at this endpoint. It stays the same on every retry and redeliver.
X-Pixl-Signaturet=<unix time>,v1=<signature>. See below.User-AgentPixl-Webhooks/1
{
"content_included": false,
"content_omitted": "switch_off",
"created_at": "2026-10-26T05:04:40Z",
"data": {},
"external_ref": null,
"id": "evt_123",
"type": "call.completed",
"workspace_id": "workspace_123"
}idis the event's own id: the same on every endpoint and every retry.created_atis when it happened in Pixl, not when this attempt was sent. Times are UTC, with aZ.external_refis your own reference for the call or message, when one was given;nullotherwise.content_includedandcontent_omittedsay whether words are in it (see the content switch).- A field that doesn't apply is
null, never missing. The keys come sorted, without spaces.
Most events carry the person as contact:
{
"id": "contact_321",
"name": "Asha Rao",
"phone": "+919876543210",
"email": null,
"external_ref": null
}contact is null when there's no known person. The person is read when each attempt is sent: someone erased meanwhile arrives with their name, phone and email as null.
Check the signature
v1 is the HMAC-SHA256, in hex, of t, a dot and the raw body, keyed with your whole whsec_… secret. To check a request:
- Read the raw body, before any JSON parsing. Never re-serialize it before checking.
- Split the header on commas: take
tand everyv1. During a new-secret switch there are two; accept the request if any matches. - Work out the HMAC of
t + "." + bodyand compare it in constant time. - Refuse it when
tis more than 5 minutes from now.tis inside the signature, so an old request can't be replayed with a new time.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.PIXL_WEBHOOK_SECRET; // The whole whsec_… string.
const TOLERANCE_SECONDS = 300;
function verify(rawBody, header) {
const parts = header.split(",").map((p) => p.trim());
const t = Number(parts.find((p) => p.startsWith("t="))?.slice(2));
const signatures = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3));
if (!t || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac("sha256", SECRET).update(`${t}.`).update(rawBody).digest("hex");
return signatures.some(
(v) => v.length === expected.length && crypto.timingSafeEqual(Buffer.from(v), Buffer.from(expected)),
);
}
// The raw body: the signature covers the exact bytes Pixl sent.
app.post("/pixl/events", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.get("X-Pixl-Signature") || "")) return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
res.status(200).end(); // Answer first, within 15 seconds.
// Then handle it. Skip an event.id you have already handled.
console.log(event.type, event.id);
});
app.listen(3000);Each event's data
call.completed
{
"call": {
"id": "call_654",
"direction": "outbound",
"test": false,
"agent_id": "agent_123",
"conversation_id": "conv_987",
"campaign_id": "campaign_456",
"recipient_id": "recipient_789",
"from_number": "+918012345678",
"to_number": "+919876543210",
"started_at": "2026-10-26T05:02:11Z",
"answered_at": "2026-10-26T05:02:19Z",
"ended_at": "2026-10-26T05:04:40Z",
"duration_seconds": 141,
"outcome": "answered",
"attempt_outcome": "answered",
"end_reason": "agent_hangup",
"disposition": "call_back_later",
"refused": false,
"callback_at": "2026-10-27T05:30:00Z",
"recording": {
"status": "pending",
"available": false,
"api_path": "/api/v1/calls/call_654/recording"
},
"external_ref": null,
"summary": null,
"transcript": null,
"callback_text": null,
"note": null
},
"contact": {
"id": "contact_321",
"name": "Asha Rao",
"phone": "+919876543210",
"email": null,
"external_ref": null
}
}direction:inbound,outboundorweb. Awebcall is in a browser: your team trying the agent in Pixl, or a call your own site or app starts with an API key.testistruefor your team trying the agent in Pixl, and for a call in a test conversation. It'sfalsefor every call with a customer, phone or web. Most systems skip test calls.outcome:answered,no_answer,busy,voicemail,invalid_number,carrier_rejected,cancelledorfailed(Pixl refused it at pickup, or it never connected).recipient_idmatches the onePOST /voice/call-nowreturned. It'snullfor an inbound call.- The recording is usually still
pendingwhen the call ends. AskGET /calls/{call_id}/recordinga few minutes later; the event never carries a link. - Content fields (only with the content switch):
summary,transcript(up to 200 turns),callback_textandnote. Without it, read them withGET /calls/{call_id}.
call.refused
{
"call_request": {
"campaign_id": "campaign_456",
"recipient_id": "recipient_789",
"agent_id": "agent_123",
"test": false,
"external_ref": null
},
"refusal": {
"code": "window_closed",
"label": "Outside calling hours",
"fix": "Not a refusal — a wait. The next legal instant is on the row; the dialer retries then.",
"will_retry": true,
"retry_at": "2026-10-27T04:30:00Z"
},
"contact": {
"id": "contact_321",
"name": "Asha Rao",
"phone": "+919876543210",
"email": null,
"external_ref": null
}
}code, label and fix are the ones GET /voice/call-now/{campaign_id} shows. will_retry is true when Pixl holds the call and tries again by itself, and false when it won't ring. test is always false here: only phone calls are refused.
message.received and message.sent
{
"message": {
"id": "msg_458",
"conversation_id": "conv_987",
"channel": "whatsapp",
"direction": "inbound",
"sender": {
"type": "customer",
"agent_id": null,
"user_id": null,
"campaign_id": null
},
"template_name": null,
"status": "received",
"failure": null,
"created_at": "2026-10-26T05:04:02Z",
"external_ref": null,
"text": null
},
"conversation": {
"id": "conv_987",
"channel": "whatsapp",
"status": "active",
"agent_id": "agent_123"
},
"contact": {
"id": "contact_321",
"name": "Asha Rao",
"phone": "+919876543210",
"email": null,
"external_ref": null
}
}sender.type:customer,agent,person(a teammate),campaign,api(sent withPOST /whatsapp/messages) orsystem.- On
message.sent,statusis where the message is at that moment; later moves come asmessage.status. textonly with the content switch. Attachments are never in the event: read them withGET /chat/{conversation_id}.
message.status
{
"message": {
"id": "msg_456",
"conversation_id": "conv_987",
"channel": "whatsapp",
"direction": "outbound",
"status": "failed",
"previous_status": "sent",
"status_at": "2026-10-26T05:00:09Z",
"failure": {
"code": "131049",
"reason": "WhatsApp held back a marketing message to this person."
},
"template_name": "appointment_reminder",
"campaign_id": null,
"external_ref": null
},
"contact": {
"id": "contact_321",
"name": "Asha Rao",
"phone": "+919876543210",
"email": null,
"external_ref": null
}
}One event for each move to sent, delivered, read or failed. failed is final, and a status never goes back. failure.code is the channel's own code (see WhatsApp failure codes).
conversation.handed_off and conversation.resolved
{
"conversation": {
"id": "conv_987",
"channel": "whatsapp",
"status": "needs_human",
"agent_id": "agent_123",
"assigned_to": null,
"external_ref": null
},
"handoff": {
"source": "customer"
},
"contact": {
"id": "contact_321",
"name": "Asha Rao",
"phone": "+919876543210",
"email": null,
"external_ref": null
}
}handoff.source (on handed_off only) is customer (they asked for a person), rule (one of your rules), system, or null when the agent or a teammate did it without a recorded reason.
contact.opted_out and contact.opted_in
{
"consent": {
"channel": "whatsapp",
"scope": "all",
"category": null,
"method": "stop_keyword",
"at": "2026-10-26T06:10:00Z"
},
"contact": {
"id": "contact_321",
"name": "Asha Rao",
"phone": "+919876543210",
"email": null,
"external_ref": null
}
}channel:whatsapp,instagram,messengerorvoice(opt-outs only).- Opted out:
scopeisallorpromotions(updates still go), orcallsfor voice. - Opted in:
categoryispromotionsorupdates.methodsays where it came from:form,import,inbound_messageand others. - A lifted opt-out (your team lifting a STOP, WhatsApp's own resume, a START on Instagram or Messenger) is a
contact.opted_in, withmethod: "lifted"on WhatsApp. contactis the saved contact that had that number when it happened (or, if none did then, one saved with it since), for a lifted opt-out too. It isnullwhen there is none: a consent record can outlive an erased contact, and an erased person's number is never sent. For Instagram and Messenger it is alwaysnull.- One person's STOP is one event, however many records it touched.
lead.created
{
"lead": {
"id": "lead_555",
"contact_id": "contact_321",
"conversation_id": null,
"source": "lead_push",
"stage": {
"id": "stage_1",
"name": "New"
},
"created_at": "2026-10-26T04:58:00Z",
"external_ref": null,
"title": null,
"notes": null
},
"contact": {
"id": "contact_321",
"name": "Asha Rao",
"phone": "+919876543210",
"email": null,
"external_ref": null
}
}title and notes only with the content switch: for a clinic they often name the treatment.
The content switch
Off by default. With it off, events carry ids and facts: who, when, status, outcome, codes. The words (message text, call summaries, transcripts and notes, lead titles and notes) are null, and your system reads them by API when it needs them.
- Only the workspace's owner, signed in to Pixl, can turn it on. No API key can, not even the owner's.
- Anyone who can manage endpoints can turn it off.
- Changing the URL turns it off, unless the owner does it signed in. Asking to keep it on in the same change is refused with a
403. - It stops by itself if the person who turned it on is no longer the owner.
- Content is read from the record when each attempt is sent, and never kept in the delivery log.
In a health business
A key that sends include_content: true gets:
{
"detail": {
"code": "content_switch_needs_owner",
"message": "Only the workspace owner, signed in to Pixl, can include message and call content."
}
}Retries, duplicates and order
- Answer with any
2xxwithin 15 seconds, counted from connecting to your last byte. Anything else, a timeout, a redirect or a refused connection is a failure. Pixl reads at most the first 64 KB of your answer and ignores what it says. - A failure is retried after 30 seconds, then 1, 2, 4 minutes and so on, doubling up to 6 hours apart: at most 12 tries, over about 15 hours.
- An event not delivered 3 days after it happened is given up, with
last_error: "expired". - Delivery is at least once: the same event can arrive twice. Skip an
id(orX-Pixl-Delivery) you have already handled. - Events about one call, message or conversation go out in order: a newer one waits until every older one about it has had its first try. One that failed and is retrying holds nothing back, so it can arrive after a newer one: order by
created_atand the object'sstatus. - Facts are frozen when the event happens: a retry an hour later still says delivered, not read. People and content are read when each attempt is sent.
- Pixl may send several requests to one endpoint at once. Only events about the same call, message or conversation are sent one after another. Sending is shared fairly between endpoints: a long backlog goes out a few events at a time. Most events go out within seconds; a call's, or one waiting behind an earlier event about the same object, can take about 15.
When your endpoint keeps failing
After 5 failed attempts in a row, Pixl pauses the endpoint and tells the workspace's owner in Pixl. Nothing is lost: new events keep waiting.
- While it's paused, Pixl tries the oldest waiting event now and then, from 30 seconds up to every 6 hours. Waiting events show when they'll be tried as
next_attempt_at; one that came in since the last try showsnull. The first2xxresumes it, and everything waiting is sent in order. - A test that gets a
2xx, orenabled: true, resumes it at once. - Waiting events still expire 3 days after they happened.
The delivery log
GET/webhook-endpoints/{endpoint_id}/deliveriesScope: webhooks:read
Every attempt for 30 days, newest first. Filter with status or event; page with limit (up to 100) and cursor. The log never keeps the event's body.
curl -G "https://api.pixlagents.com/api/v1/webhook-endpoints/endpoint_123/deliveries" \
-H "Authorization: Bearer $PIXL_API_KEY" \
--data-urlencode 'status=retrying' \
--data-urlencode 'limit=50'{
"items": [
{
"id": "dlv_456",
"event_id": "evt_123",
"event_type": "call.completed",
"status": "retrying",
"attempts": 2,
"last_http_status": 500,
"last_latency_ms": 210,
"last_error": "HTTP 500",
"next_attempt_at": "2026-10-26T05:06:10Z",
"delivered_at": null,
"created_at": "2026-10-26T05:04:41Z",
"is_test": false
}
],
"next_cursor": null
}status:pending,retrying,deliveredorfailed.next_attempt_atsays when aretryingevent goes again, or when apendingone waiting behind a paused endpoint will be tried. It'snullotherwise, and for an event that came in since the paused endpoint's last try.last_erroris short and plain:HTTP 500(your endpoint answered with that),no answer from the endpoint(nothing in 15 seconds, for any reason),the URL points at an address Pixl can't send to,The URL must use port 443 or 8443.,the URL can't be used as written,expired,endpoint switched off.- To send one again, with the same ids:
POST /webhook-endpoints/{endpoint_id}/deliveries/{delivery_id}/redeliver. A test can't be redelivered, and neither can anything for an endpoint that is off.
