Pidge API

A REST API for your backend, signed webhooks when something happens in a chat, and commands for the widget on your page. Both plans get all of it, except that on the free plan the REST API can only read.

Base URL https://app.pidge.chat/v1. The paths, auth and webhook format follow Crisp's REST API v1, so a client written for Crisp works once you change the base URL and key.

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 codes
StatusReasonWhen
200listed, resolved, updated, deletedIt worked.
202dispatchedA message or typing indicator was accepted.
400invalid_dataThe body or a parameter is wrong. data lists what failed.
401invalid_sessionThe key is missing, wrong or revoked.
404not_foundNo such website, conversation or message, or the key can't reach it.
409ai_paused and othersThe request clashes with the chat's state. See Running a bot.
429rate_limitedMore than 600 requests from one key in a minute.
500internal_errorOur 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.

Message fields
FieldValue
contentThe text, up to 4,000 characters. Required.
typetext (default) or note.
fromoperator (default), or user to post as the visitor, for example a message you relayed from another channel.
originFree text up to 100 characters. Defaults to chat.
user.nicknameThe name the visitor sees. Defaults to the key's name.
automatedPidge 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:

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
}
Webhook events
EventWhendata
message:sendThe visitor sent a message, in the chat or by answering a reply email.A message, as above, plus currentPage.
message:receivedYour side sent the visitor a message, from the inbox or the API. Notes don't count.A message.
message:removedA message was deleted.session_id, website_id, fingerprint
session:set_emailThe visitor's email changed or was verified.session_id, email, verified
session:set_nicknameTheir name changed.session_id, nickname
session:set_dataTheir data changed.session_id, data
session:set_segmentsTheir segments changed.session_id, segments
session:set_stateThe chat was resolved, reopened or closed.session_id, state (resolved or unresolved)
session:sync:pagesA visitor with a chat opened another page.session_id, pages (one page)
session:removedThe 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:

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.

Widget commands
CommandWhat 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.

  1. 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.
  2. When a customer is logged in, your backend computes HMAC-SHA256 of their email with that secret, as lowercase hex.
  3. 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.

Try it on your site

Start free

For your first 14 days the full API works on any new account. No card needed.