API and webhooks

For connecting a booking tool, a CRM or another channel. Every conversation you create here rings the team like a chat, and lands in the same inbox.

Keys

An owner creates a key under Settings → Integrations. A key belongs to one website and sees only its conversations. Send it on every request, from your server only (browsers can't call the API):

Authorization: Bearer key_…

Up to 120 requests a minute per key. Errors come back as { "error": { "code": "…", "message": "…" } } with a 4xx or 5xx status.

Create a conversation

Give the visitor's email or phone, so the team can reply. They get a short "we have your message" email (or text), the team is rung during your hours, and replies go to them by email (or text).

curl -X POST https://helpnera.com/api/public/v1/conversations \
  -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"message":"Can you quote for a new boiler?","name":"Maria","email":"maria@example.com"}'

Fields: message (required, up to 4,000 characters), name, email, phone (E.164, like +12065550100), page_url. Answers 201 with the conversation.

Read conversations

GET https://helpnera.com/api/public/v1/conversations?state=open&limit=50
GET https://helpnera.com/api/public/v1/conversations?state=closed&before=<next_before from the last page>
GET https://helpnera.com/api/public/v1/conversations/<id>

Lists are newest first, with next_before for the next page. One conversation comes with its messages (visitor, team and assistant; internal notes stay inside).

Add a visitor message, or close

POST https://helpnera.com/api/public/v1/conversations/<id>/messages   {"body":"Here are the photos."}
POST https://helpnera.com/api/public/v1/conversations/<id>/close      {"summary":"Quote for a new boiler"}

Webhooks

Add an https address under Settings → Integrations and choose the events. We POST JSON to it, and count any 2xx answer as delivered. Anything else is retried after 1, 5, 30, 120, 360, 720 minutes, then given up. An address that keeps failing is switched off until an owner turns it back on.

POST https://example.com/hooks/chat
webhook-id: 6f1c…           (the same on every retry: use it to ignore repeats)
webhook-event: conversation.created
webhook-signature: t=1792330000,v1=5d41…

{"id":"6f1c…","event":"conversation.created","created_at":"…","data":{"conversation":{
  "id":"…","source":"widget","state":"ringing","visitor":{"name":null,"email":null,"phone":null},
  "first_message":"Do you service boilers?","url":"…/inbox?c=…", …}}}

Checking the signature

v1 is the HMAC-SHA256 of <t>.<raw body> with the webhook's signing secret, in hex. Compare in constant time, and refuse a t more than five minutes old.

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, header, secret) {
  const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const want = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return v1.length === want.length && timingSafeEqual(Buffer.from(v1), Buffer.from(want));
}

Contact forms

A plain HTML form doesn't need a key: point it at the address on Settings → Install. It only works from your own website.