Developers

API reference

Build your own chat interface, connect the assistant to your app, or react to events in your own systems. Everything the standard widget does is available through this API.

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.

StatusMeaning
403The request came from a domain that is not allowed, or the feature is turned off for this widget.
404The widget or conversation does not exist, or the widget is inactive.
409The AI cannot reply because a human agent has taken over the conversation.
422The request body is missing a field or a value is not valid.
429Too many requests, or the monthly message allowance has been used up.
5xxSomething 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 parameterDescription
page_urlOptional. The page the visitor is on. Used for page-specific proactive messages.
fingerprintOptional. 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.

FieldDescription
fingerprintRequired. Up to 64 characters. Your stable, random visitor identifier.
page_url, referrerOptional. Full URLs for the current and referring page.
name, emailOptional. Pass these if the visitor is already signed in to your site.
timezone, device_typeOptional. 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.

FieldDescription
contentRequired. 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

FieldDescription
ratingRequired. A whole number from 1 to 5.
commentOptional. 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.

FieldDescription
emailRequired. Where your team should reply.
messageRequired. Up to 2,000 characters.
name, page_url, conversation_uuidOptional.

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:

EventWhen it is sent
lead.qualifiedThe assistant captured or updated a lead and scored it.
conversation.handoffA visitor asked to talk to a person.
conversation.feedbackA visitor rated a conversation.
webhook.testYou 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.

Still have questions?

A real person on our team reads every message and replies within one business day.