Introduction
The widget API is a simple JSON API over HTTPS. It is the same API our own widget.js uses, so anything the standard widget can do, your code can do too. Common uses include:
- building a fully custom chat interface inside a mobile app or web app;
- adding the assistant to an internal tool or kiosk;
- receiving webhook events when leads are qualified or visitors ask for a person.
All requests and responses use JSON, except streaming replies, which use server-sent events. Timestamps use ISO 8601 format in UTC.
Base URL
https://chat.porifyx.com/api/widget
In the endpoint paths below, {widget} is your widget ID, shown on the assistant's Embed page, and {conversation} is the conversation ID returned when you start a conversation.
Authentication and allowed domains
The widget API is designed to be called from the browser, so it does not use secret keys. A secret key placed in front-end code would not stay secret for long. Instead, access is controlled in three ways:
- Widget ID: every request must include a valid ID for an active widget.
- Allowed domains: if you have set allowed domains for the widget, requests from any other origin are refused with
403. - Conversation ID: messages can only be read or sent by someone who holds the conversation's ID, which is a long random value.
Each visitor is identified by a fingerprint: a random string you generate once and store on the visitor's device. Reusing the same fingerprint lets a visitor continue the same conversation.
Rate limits
By default each widget accepts up to 30 requests per minute from the same source. You can change this limit in the widget settings. When you go over the limit, the API returns 429 Too Many Requests. Wait a few seconds and try again, ideally with increasing delays between retries.
Your plan also sets a monthly message allowance. When it is used up, sending a message returns 429 with the error Message limit reached.
Errors
Errors return a standard HTTP status code and a JSON body with an error message. Validation errors return 422 with details for each field.
| Status | Meaning |
|---|---|
403 | The request came from a domain that is not allowed, or the feature is turned off for this widget. |
404 | The widget or conversation does not exist, or the widget is inactive. |
409 | The AI cannot reply because a human agent has taken over the conversation. |
422 | The request body is missing a field or a value is not valid. |
429 | Too many requests, or the monthly message allowance has been used up. |
5xx | Something went wrong on our side. Retry with a delay, and check the status page. |
{ "error": "Domain not authorized." }
Get widget configuration
GET /{widget}/config
Returns everything needed to display the widget: name, colours, position, welcome message, starter questions, enabled features and any active A/B test variant. Call this first.
| Query parameter | Description |
|---|---|
page_url | Optional. The page the visitor is on. Used for page-specific proactive messages. |
fingerprint | Optional. The visitor's fingerprint, used to assign A/B test variants consistently. |
Start a conversation
POST /{widget}/conversations
Starts a new conversation, or resumes the visitor's open one if it exists.
| Field | Description |
|---|---|
fingerprint | Required. Up to 64 characters. Your stable, random visitor identifier. |
page_url, referrer | Optional. Full URLs for the current and referring page. |
name, email | Optional. Pass these if the visitor is already signed in to your site. |
timezone, device_type | Optional. For example Europe/London and mobile. |
curl -X POST https://chat.porifyx.com/api/widget/{widget}/conversations \
-H "Content-Type: application/json" \
-d '{"fingerprint":"v_8f2c1a9e","page_url":"https://example.com/pricing"}'
{
"conversation_uuid": "9b7e4c2a-…",
"conversation_status": "active",
"messages": [ … ],
"visitor": { "name": null, "email": null }
}
Send a message
POST /{widget}/conversations/{conversation}/messages
Sends the visitor's message and waits for the full reply. Use this when you do not need the text to appear word by word.
| Field | Description |
|---|---|
content | Required. The visitor's message, up to 2,000 characters by default. |
{
"visitor_message": {
"uuid": "…", "role": "visitor", "content": "Do you ship to Ireland?",
"sentiment_score": 0.1, "sentiment_label": "neutral", "created_at": "2026-11-05T10:24:31Z"
},
"ai_response": { "uuid": "…", "role": "ai", "content": "Yes, we ship to Ireland in 2 to 4 working days…" }
}
If a human agent has taken over, ai_response is null and the agent's reply arrives through polling.
Stream a reply
POST /{widget}/conversations/{conversation}/stream
Takes the same body as Send a message, but returns the reply as server-sent events, so you can show it as it is written. Each event contains a piece of text. The last event says the reply is complete and may include suggested follow-up questions.
data: {"chunk":"Yes, we ship"}
data: {"chunk":" to Ireland in 2 to 4 working days."}
data: {"done":true,"message_uuid":"…","smart_suggestions":["How much is shipping?"]}
Because this is a POST request, use fetch and read the response body as a stream rather than the browser's EventSource, which only supports GET.
Poll for agent messages
GET /{widget}/conversations/{conversation}/poll?after={message}
Returns messages added after the given message ID, such as replies from a human agent or system notices. Poll every few seconds while a human is handling the conversation.
Request a human
POST /{widget}/conversations/{conversation}/handoff
Asks for a human agent. The conversation moves to your team's inbox, online agents are notified and the conversation.handoff webhook is sent. Returns 403 if handoff is turned off for the widget.
Rate a conversation
POST /{widget}/conversations/{conversation}/feedback
| Field | Description |
|---|---|
rating | Required. A whole number from 1 to 5. |
comment | Optional. Up to 500 characters. |
Leave an offline message
POST /{widget}/offline
Saves a message for your team when nobody is available. It appears in the dashboard and is sent to you by email.
| Field | Description |
|---|---|
email | Required. Where your team should reply. |
message | Required. Up to 2,000 characters. |
name, page_url, conversation_uuid | Optional. |
React to a message
POST /{widget}/conversations/{conversation}/messages/{message}/react
Adds an emoji reaction to an assistant message, for example {"emoji":"👍"}. Reactions help you find the answers visitors liked, and the ones they did not.
Webhooks
Webhooks let your systems react the moment something happens. Set your endpoint URL and a secret in the widget's settings. We send a POST request with a JSON body for each event:
| Event | When it is sent |
|---|---|
lead.qualified | The assistant captured or updated a lead and scored it. |
conversation.handoff | A visitor asked to talk to a person. |
conversation.feedback | A visitor rated a conversation. |
webhook.test | You pressed the test button in the dashboard. |
POST /your-endpoint
Content-Type: application/json
X-PFX-Event: lead.qualified
X-PFX-Widget: 3f1c…
X-PFX-Signature: sha256=5d41402abc4b2a76b9719d911017c592…
{
"event": "lead.qualified",
"widget_uuid": "3f1c…",
"timestamp": "2026-11-05T10:31:02+00:00",
"data": { … }
}
Reply with any 2xx status within 15 seconds. If your endpoint fails or times out, we try the delivery up to three times in total. Every attempt and its response are listed in the widget's Webhook logs.
Deliveries can occasionally arrive more than once or out of order. Make your handler safe to run twice, for example by ignoring an event you have already processed.
Verifying signatures
When you set a webhook secret, each request includes an X-PFX-Signature header. It contains an HMAC-SHA256 hash of the raw request body, made with your secret. Always check it before trusting the data, and compare the values in constant time.
PHP
$body = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $body, getenv('PFX_WEBHOOK_SECRET'));
if (! hash_equals($expected, $_SERVER['HTTP_X_PFX_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
Node.js
import crypto from 'node:crypto';
// Use the raw body, before any JSON parsing.
function isValid(rawBody, header, secret) {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
return header?.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}
Questions about the API? Talk to our developers.