Keys and authentication
API keys are made on the Developers page in the inbox, which only owners can open. A key has two parts, an identifier and a secret key. Both are shown once, when you create it, so copy them somewhere safe. Pidge stores only a hash of the key.
A key can reach every website in the workspace, or be limited to one. A limited key gets
404 not_found for any other website, as if it didn't exist.
Send the key with HTTP Basic auth, identifier as the user name and key as the password.
Clients written for Crisp often send an X-Crisp-Tier: plugin header. Pidge
accepts it and ignores it, so you don't need it.
curl -u "$PIDGE_ID:$PIDGE_KEY" \ https://app.pidge.chat/v1/website/$WEBSITE_ID/conversations/1
Your website ID is on the Websites page in the inbox. It's the same ID that goes in the widget's script tag.
Responses, errors and limits
Every response, good or bad, has the same three fields:
{
"error": false,
"reason": "listed",
"data": [ … ]
}
error is true when something went wrong, and reason says what.
Timestamps everywhere are milliseconds since 1970 (UTC).
| Status | Reason | When |
|---|---|---|
| 200 | listed, resolved, updated, deleted | It worked. |
| 202 | dispatched | A message or typing indicator was accepted. |
| 400 | invalid_data | The body or a parameter is wrong. data lists what failed. |
| 401 | invalid_session | The key is missing, wrong or revoked. |
| 404 | not_found | No such website, conversation or message, or the key can't reach it. |
| 409 | ai_paused and others | The request clashes with the chat's state. See Running a bot. |
| 429 | rate_limited | More than 600 requests from one key in a minute. |
| 500 | internal_error | Our fault. Try again in a moment. |
Lists come in pages of 20. Page numbers start at 1. A page past the end returns an empty list.
Conversations
A conversation is one chat with one visitor, named by its session_id
(session_ followed by a UUID). All paths below start with
https://app.pidge.chat/v1.
GET/website/{website_id}/conversations/{page_number}
Lists conversations, 20 at a time. Open chats come first, then resolved ones, most recent activity first.
GET/website/{website_id}/conversation/{session_id}
One conversation.
{
"session_id": "session_7d0c5e2a-…",
"website_id": "1f3e…",
"state": "unresolved",
"is_blocked": false,
"active": { "now": true },
"last_message": "Does the yearly plan include VAT?",
"unread": { "operator": 1 },
"created_at": 1761300000000,
"updated_at": 1761300420000,
"meta": { … the same object as GET …/meta },
"ai_paused": false,
"closed": false,
"currentPage": { "url": "https://example.com/pricing", "title": "Pricing", "at": 1761300400000 }
}
state is pending (the visitor wrote and nobody has looked yet),
unresolved or resolved. active.now says whether the
visitor has the site open right now. ai_paused, closed and
currentPage are Pidge additions: a closed chat is archived, and the visitor's
next message starts a new one.
Messages
GET/website/{website_id}/conversation/{session_id}/messages
The latest 40 messages, oldest first. Add ?timestamp_before= with the oldest timestamp you have to page further back, as far as the chat's latest 500 messages. Deleted messages are left out.
{
"session_id": "session_7d0c5e2a-…",
"website_id": "1f3e…",
"type": "text",
"from": "user",
"origin": "chat",
"content": "Does the yearly plan include VAT?",
"fingerprint": "msg_Qm9vS2xh…",
"timestamp": 1761300420000,
"user": { "type": "participant", "user_id": "session_7d0c5e2a-…", "nickname": "Ada" },
"automated": false
}
from is user for the visitor and operator for your
side. type is text, note (internal, never shown to
the visitor) or file. For a file, content is
{ name, url, type } and any text sent with it is in caption.
A translated message also has translation.
POST/website/{website_id}/conversation/{session_id}/message
Sends a message. Answers 202 with the new message's fingerprint.
| Field | Value |
|---|---|
content | The text, up to 4,000 characters. Required. |
type | text (default) or note. |
from | operator (default), or user to post as the visitor, for example a message you relayed from another channel. |
origin | Free text up to 100 characters. Defaults to chat. |
user.nickname | The name the visitor sees. Defaults to the key's name. |
automated | Pidge addition. true (default) marks the message as sent by a bot. Send false when a person wrote it. |
curl -u "$PIDGE_ID:$PIDGE_KEY" \
-H "Content-Type: application/json" \
-d '{"type":"text","from":"operator","content":"Yes, VAT is added at checkout."}' \
https://app.pidge.chat/v1/website/$WEBSITE_ID/conversation/$SESSION_ID/message
DELETE/website/{website_id}/conversation/{session_id}/message/{fingerprint}
Deletes a message for everyone. It disappears from the visitor's chat window, and the inbox shows who deleted it.
PATCH/website/{website_id}/conversation/{session_id}/compose
Shows or hides the typing indicator in the visitor's chat. Body: {"type": "start"} or {"type": "stop"}, with an optional user.nickname.
Visitors and pages
GET/website/{website_id}/conversation/{session_id}/meta
Who the visitor is: nickname, email, ip, data, segments and device (country, city, user agent, time zone, languages). Pidge adds verified (the email was signed, see below), language and currentPage.
PATCH/website/{website_id}/conversation/{session_id}/meta
Changes any of nickname, email, data and segments. data is an object of strings: keys up to 40 characters, values up to 500. segments is a list of up to 20 labels.
curl -u "$PIDGE_ID:$PIDGE_KEY" -X PATCH \
-H "Content-Type: application/json" \
-d '{"data":{"plan":"Pro","orders":"3"},"segments":["customer"]}' \
https://app.pidge.chat/v1/website/$WEBSITE_ID/conversation/$SESSION_ID/meta
GET/website/{website_id}/conversation/{session_id}/pages/{page_number}
The pages the visitor has opened, newest first, so the first one is where they are now. Each is { page_title, page_url, page_referrer, timestamp }; page_referrer is always null. …/pages without a number is page 1.
GET/website/{website_id}/people/profiles/{page_number}?search_text=
Finds visitors who have given a name or email, matching search_text anywhere in either, most recently seen first. Each result has people_id, email, verified, person.nickname, segments, data, and the session_id and currentPage of their open chat, if they have one.
Page addresses and titles come from the visitor's browser. Treat them as untrusted text, for example before you put them in a prompt or a page.
State and blocking
GET/website/{website_id}/conversation/{session_id}/state
Returns { "state": "pending" | "unresolved" | "resolved" }.
PATCH/website/{website_id}/conversation/{session_id}/state
Body {"state": "resolved"} resolves the chat. unresolved reopens it. pending reopens it and marks it unread, which is how a bot hands a chat to your team.
PATCH/website/{website_id}/conversation/{session_id}/block
Body {"blocked": true} hides the chat from that visitor, without telling them. A key for the whole workspace also blocks their IP address on every website. A key limited to one website blocks only the visitor's browser. {"blocked": false} lifts it.
Running a bot
Your own bot or AI can answer visitors through the API. A few things help it behave:
- Subscribe a webhook to
message:sendto hear each visitor message. It includescurrentPage, the page they wrote from. - Answer with
POST …/message. Leaveautomatedat its default,true. - Once someone on your team replies in the inbox, the chat's
ai_pausedturns true and the bot's messages there are refused with409 ai_paused. Notes still work. This keeps the bot from talking over a person. - To hand a chat to a person, set its state to
pending. It shows up as unread in the inbox.
Webhooks
Add a webhook on the Developers page: a public address (HTTPS is best), the events you want, and
optionally one website. Its signing secret is shown once. A workspace can have up to 20
webhooks. Send test sends a test:ping event so you can check
your receiver end to end. Webhooks work on both plans.
Every delivery is a POST with a JSON body like this:
{
"website_id": "1f3e…",
"event": "message:send",
"data": { … depends on the event, see below },
"timestamp": 1761300420000
}
| Event | When | data |
|---|---|---|
message:send | The visitor sent a message, in the chat or by answering a reply email. | A message, as above, plus currentPage. |
message:received | Your side sent the visitor a message, from the inbox or the API. Notes don't count. | A message. |
message:removed | A message was deleted. | session_id, website_id, fingerprint |
session:set_email | The visitor's email changed or was verified. | session_id, email, verified |
session:set_nickname | Their name changed. | session_id, nickname |
session:set_data | Their data changed. | session_id, data |
session:set_segments | Their segments changed. | session_id, segments |
session:set_state | The chat was resolved, reopened or closed. | session_id, state (resolved or unresolved) |
session:sync:pages | A visitor with a chat opened another page. | session_id, pages (one page) |
session:removed | The chat was deleted. | session_id |
session:set_block is also on the list of events you can pick, but Pidge
doesn't send it yet.
Checking the signature
Each delivery carries these headers:
X-Crisp-Request-Timestamp: when this attempt was sent, in milliseconds.X-Crisp-Signature: hex HMAC-SHA256 of[timestamp;body], keyed with the webhook's secret. The same value is also sent asX-Pidge-Signature.User-Agent: Pidge-Webhooks/1.0
The string that's signed is a [, the timestamp header, a ;, the
raw request body, and a ]. Check it against the raw body, before you parse
it. The timestamp is new on every attempt, so you can also refuse old ones. Crisp's Node
library's verifyHook accepts these signatures too.
// Node 18+ with Express
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const SECRET = process.env.PIDGE_WEBHOOK_SECRET;
const app = express();
app.post("/pidge-hook", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-Crisp-Request-Timestamp") ?? "";
const signature = req.get("X-Crisp-Signature") ?? "";
const body = req.body.toString("utf8");
const expected = createHmac("sha256", SECRET)
.update(`[${timestamp};${body}]`)
.digest("hex");
const valid =
signature.length === expected.length &&
timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
const fresh = Math.abs(Date.now() - Number(timestamp)) < 5 * 60_000;
if (!valid || !fresh) return res.sendStatus(401);
const { event, data } = JSON.parse(body);
if (event === "message:send") {
console.log(`${data.session_id} wrote: ${data.content}`);
}
res.sendStatus(200);
});
app.listen(3000);
Retries
Answer with any 2xx status within 5 seconds. Anything else, or no answer, counts as a failure, and Pidge tries again after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours, 6 hours and 12 hours. That's 9 attempts over about 23 hours. Events are written to a queue before they're sent, so a restart on our side doesn't lose them.
Order isn't guaranteed, and a delivery can arrive twice if your receiver was slow to
answer. Use the message fingerprint to skip duplicates. The Developers page
shows recent deliveries and their status for 30 days.
Webhook addresses must be public. Addresses that point at private or reserved networks are refused.
Widget commands
The widget's script tag is on the Websites page in the inbox. When you've set up a chat subdomain there, the script loads from it, so ad blockers treat it as part of your site.
<script> window.$pidge = []; window.PIDGE_WEBSITE_ID = "your-website-id"; </script> <script src="https://chat.yoursite.com/client/l.js" async></script>
Talk to the widget with $pidge.push([...]). Commands pushed before the
script has loaded wait in the list and run once it has. Unknown commands are ignored.
| Command | What it does |
|---|---|
["set", "user:email", [email, signature]] | Sets the visitor's email. The signature is optional, see signed emails. |
["set", "user:nickname", [name]] | Sets the name your team sees. |
["set", "session:data", [[["plan", "Pro"], ["orders", "3"]]]] | Adds key and value pairs, shown next to the chat in the inbox. |
["set", "session:segments", [["customer"], overwrite]] | Adds segments. With overwrite set to true, replaces them. |
["do", "chat:open"] | Opens the chat. Also chat:close and chat:toggle. |
["do", "chat:show"] | Shows the chat bubble. chat:hide hides it. |
["do", "message:send", ["text", "Hi"]] | Sends a message as the visitor. |
["do", "message:show", ["text", "Hi"]] | Shows a message in the visitor's chat window only. It isn't sent to your inbox. |
["do", "session:reset"] | Forgets the visitor and starts an empty chat. Call it when someone logs out. |
Once the script has loaded, $pidge.is(state) answers true or false for
chat:opened, chat:closed, chat:visible,
chat:hidden and session:ongoing.
The same object is also installed as window.$crisp, and
CRISP_WEBSITE_ID works in place of PIDGE_WEBSITE_ID. A site
moving from Crisp can keep its existing calls and only change the script's address.
Signed visitor emails
Any page can claim any email, so the inbox only marks an email as verified when your backend has signed it. A verified customer who switches from phone to laptop stays in the same chat.
- On the Developers page, under Signed visitor emails, create a secret for the website. It's shown once. Keep it on your server, never in the browser.
- When a customer is logged in, your backend computes HMAC-SHA256 of their email with that secret, as lowercase hex.
- Pass the email and the signature to the widget.
// on your server (Node) import { createHmac } from "node:crypto"; const signature = createHmac("sha256", process.env.PIDGE_IDENTITY_SECRET) .update(user.email.trim()) .digest("hex"); <!-- in the page you render --> <script> $pidge.push(["set", "user:email", ["ada@example.com", "<signature>"]]); </script>
It's the same scheme as Crisp's identity verification, so a backend that already signs emails for Crisp works unchanged with the new secret. Rotating the secret on the Developers page makes old signatures stop counting.
Something missing, or not working the way this page says? Tell us in the chat on this page.