# Helpeen integration guide for AI coding assistants You are helping a developer integrate **Helpeen** into their product. This file has everything you need: what Helpeen is, the rules you must follow, a plan, ready-to-adapt code, and the complete API reference (at the end, in Spanish). Read it all before writing code. ## What Helpeen is A small customer-support service. The developer's company manages, in the Helpeen panel: - **Content**: help articles, FAQs (short articles: question + answer), categories and video tutorials (YouTube/Vimeo links). - **Conversations**: support threads between the product's end users and the company's team. There are two ways to integrate, and they can be combined: - **Widget** (no code against the API): one `
``` React (`npm install @helpeen/react`): ```tsx import { HelpeenChat, HelpeenHelpCenter } from "@helpeen/react"; // Once, in the root layout. `user` is null when nobody is signed in. // On the help page. ``` The public key is safe in the browser, so a public env var is fine for it. For **signed-in users**, compute the hash on the server with the identity secret (Helpeen › Widget) and pass it down; the identity secret itself is server-only, like the secret API key: ```ts // Server side (e.g. in the root layout, a server component) import { createHmac } from "node:crypto"; const helpeenUser = user ? { id: String(user.id), email: user.email, name: user.name, hash: createHmac("sha256", process.env.HELPEEN_IDENTITY_SECRET!).update(String(user.id)).digest("hex"), } : null; ``` With the same `id` the API uses as `user_id`, the widget shows the same conversations. On sign-out, call `helpeen("logout")` (or render `user={null}`). ## API recipes Adapt these to the stack in front of you. They are TypeScript, but the HTTP calls are the same in any language; the reference at the end has a `curl` example for every endpoint. ### Types ```ts export type Category = { id: string; slug: string; name: string; description: string | null; position: number }; export type Article = { id: string; slug: string; kind: "article" | "faq"; title: string; excerpt: string | null; category: { slug: string; name: string } | null; locale: string; position: number; published_at: string; updated_at: string; body?: string; // Markdown; only in article detail and in FAQs body_html?: string; // sanitized HTML; only in article detail and in FAQs }; export type Video = { id: string; title: string; description: string | null; url: string; provider: "youtube" | "vimeo"; embed_url: string; thumbnail_url: string | null; position: number; }; export type Collection = { id: string; slug: string; title: string; description: string | null; locale: string; position: number; videos: Video[]; }; export type HelpResponse = { organization: { name: string; slug: string }; locale: string; categories: Category[]; faqs: Article[]; video_collections: Collection[]; }; export type Conversation = { id: string; subject: string; status: "open" | "pending" | "closed"; metadata: Record; created_at: string; last_message_at: string; unread: boolean; }; export type Message = { id: string; author: "user" | "agent"; author_name: string | null; body: string; // plain text created_at: string; }; export type HelpeenError = { error: { code: "invalid_request" | "unauthorized" | "forbidden" | "not_found" | "internal"; message: string } }; ``` ### Server-side client ```ts // lib/helpeen.ts — server only import "server-only"; // or your framework's equivalent const BASE = process.env.HELPEEN_API_URL ?? "https://helpeen.com/api/v1"; export class HelpeenApiError extends Error { constructor(public status: number, public code: string, message: string) { super(message); } } async function call(path: string, init: RequestInit & { revalidate?: number } = {}): Promise { const { revalidate, ...rest } = init; const res = await fetch(`${BASE}${path}`, { ...rest, headers: { Authorization: `Bearer ${process.env.HELPEEN_SECRET_KEY}`, "Content-Type": "application/json", ...rest.headers, }, // Next.js caching; drop this outside Next.js and cache your own way. ...(revalidate ? { next: { revalidate } } : { cache: "no-store" }), }); const json = await res.json(); if (!res.ok) { const { error } = json as HelpeenError; throw new HelpeenApiError(res.status, error?.code ?? "internal", error?.message ?? "Helpeen error"); } return json as T; } const q = (params: Record) => { const search = new URLSearchParams(); for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== "") search.set(k, String(v)); const s = search.toString(); return s ? `?${s}` : ""; }; // Content: cached for 5 minutes. export const getHelp = (locale?: string) => call(`/help${q({ locale })}`, { revalidate: 300 }); export const listArticles = (p: { kind?: "article" | "faq"; category?: string; q?: string; locale?: string; limit?: number; offset?: number } = {}) => call<{ data: Article[]; limit: number; offset: number }>(`/articles${q(p)}`, { revalidate: 300 }); /** Returns null when the article does not exist (404). */ export async function getArticle(slug: string, locale?: string) { try { const { data } = await call<{ data: Article }>(`/articles/${encodeURIComponent(slug)}${q({ locale })}`, { revalidate: 300 }); return data; } catch (e) { if (e instanceof HelpeenApiError && e.status === 404) return null; throw e; } } // Conversations: never cached. `userId` must come from your session. export const listConversations = (userId: string) => call<{ data: Conversation[] }>(`/conversations${q({ user_id: userId })}`); export const openConversation = (input: { user: { id: string; email: string; name?: string }; subject: string; message: string; metadata?: Record; }) => call<{ data: Conversation }>("/conversations", { method: "POST", body: JSON.stringify(input) }); export const getConversation = (id: string, userId: string) => call<{ data: Conversation & { messages: Message[] } }>(`/conversations/${id}${q({ user_id: userId })}`); export const replyToConversation = (id: string, userId: string, body: string) => call<{ data: Message }>(`/conversations/${id}/messages`, { method: "POST", body: JSON.stringify({ user_id: userId, body }) }); export const markConversationRead = (id: string, userId: string) => call<{ data: { id: string; unread: false } }>(`/conversations/${id}/read`, { method: "POST", body: JSON.stringify({ user_id: userId }) }); ``` ### Help page (Next.js App Router) ```tsx // app/help/page.tsx import { getHelp } from "@/lib/helpeen"; export default async function HelpPage() { const help = await getHelp("en"); return (

Help

{help.faqs.map((faq) => (
{faq.title}
))} {help.video_collections.map((collection) => (

{collection.title}

{collection.videos.map((video) => ( `. `thumbnail_url` es `null` en Vimeo. --- ## Conversaciones Solo con **clave secreta** y **desde tu servidor** (desde el navegador, usa el [widget](#widget)). Helpeen no tiene cuentas de usuarios finales: tu app ya sabe quién es su usuario y lo dice con `user_id`, el id que tenga en tu base de datos (texto, hasta 200 caracteres). Una conversación solo se puede leer o contestar con el mismo `user_id` que la abrió; con cualquier otro, la respuesta es `404`. **El `user_id` tiene que salir de la sesión de tu servidor**, nunca de un parámetro que mande el navegador: si no, cualquiera podría leer las conversaciones de otro usuario. ### Estados | Estado | Significa | Pasa a este estado cuando… | | --- | --- | --- | | `open` | Le toca responder al equipo | El usuario abre la conversación o escribe en ella, **aunque estuviera cerrada** | | `pending` | Le toca al usuario | El equipo responde desde el panel | | `closed` | Resuelta | El equipo la cierra desde el panel | `unread` es `true` cuando lo último es una respuesta del equipo y el usuario aún no la ha visto (no se ha llamado a `/read` ni ha escrito después). Úsalo para el globo de "tienes respuesta". ### Avisos por email Helpeen los manda solo, no hace falta que lo hagas tú: - Cuando un usuario abre una conversación o escribe en ella → email al correo de avisos de tu empresa (**Ajustes**). - Cuando el equipo responde → email al usuario, a la dirección con la que abrió la conversación. ### `GET /conversations` Las conversaciones de un usuario, la de actividad más reciente primero (hasta 100). | Parámetro | Tipo | | | --- | --- | --- | | `user_id` | query, **obligatorio** | Id del usuario en tu app | ```bash curl "https://helpeen.com/api/v1/conversations?user_id=u_123" \ -H "Authorization: Bearer hp_sec_xxxxxxxxxxxxxxxx" ``` `200 OK` ```json { "data": [ { "id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9", "subject": "No me sincroniza el banco", "status": "pending", "metadata": { "plan": "optimum", "page": "/bancos" }, "created_at": "2026-10-01T09:12:00.000Z", "last_message_at": "2026-10-01T10:03:27.000Z", "unread": true } ] } ``` ### `POST /conversations` Abre una conversación con el primer mensaje del usuario. | Campo | Tipo | | | --- | --- | --- | | `user.id` | texto, **obligatorio** | Id del usuario en tu app (hasta 200) | | `user.email` | texto, **obligatorio** | Donde le llegarán las respuestas | | `user.name` | texto, opcional | Nombre para el equipo (hasta 120) | | `subject` | texto, **obligatorio** | Asunto (hasta 200) | | `message` | texto, **obligatorio** | Primer mensaje (hasta 10.000) | | `metadata` | objeto, opcional | Lo que quieras que vea el equipo junto a la conversación: plan, versión de la app, página… (hasta 4 KB) | ```bash curl https://helpeen.com/api/v1/conversations \ -H "Authorization: Bearer hp_sec_xxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "user": { "id": "u_123", "email": "ana@ejemplo.com", "name": "Ana" }, "subject": "No me sincroniza el banco", "message": "Desde ayer no veo movimientos nuevos.", "metadata": { "plan": "optimum", "page": "/bancos" } }' ``` `201 Created` ```json { "data": { "id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9", "subject": "No me sincroniza el banco", "status": "open", "metadata": { "plan": "optimum", "page": "/bancos" }, "created_at": "2026-10-01T09:12:00.000Z", "last_message_at": "2026-10-01T09:12:00.000Z", "unread": false } } ``` `400 Bad Request` si falta algo o no cumple los límites: ```json { "error": { "code": "invalid_request", "message": "`user.email` is not an email." } } ``` Guarda el `id`: lo necesitas para leer la conversación y contestar en ella. ### `GET /conversations/{id}` Una conversación con todos sus mensajes, del más antiguo al más nuevo. | Parámetro | Tipo | | | --- | --- | --- | | `id` | ruta | Id de la conversación | | `user_id` | query, **obligatorio** | Tiene que ser el usuario que la abrió | ```bash curl "https://helpeen.com/api/v1/conversations/5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9?user_id=u_123" \ -H "Authorization: Bearer hp_sec_xxxxxxxxxxxxxxxx" ``` `200 OK` ```json { "data": { "id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9", "subject": "No me sincroniza el banco", "status": "pending", "metadata": { "plan": "optimum", "page": "/bancos" }, "created_at": "2026-10-01T09:12:00.000Z", "last_message_at": "2026-10-01T10:03:27.000Z", "unread": true, "messages": [ { "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "author": "user", "author_name": "Ana", "body": "Desde ayer no veo movimientos nuevos.", "created_at": "2026-10-01T09:12:00.000Z" }, { "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "author": "agent", "author_name": "Iñaki", "body": "Hola, Ana. Tu banco pidió renovar el permiso: entra en Cuentas y pulsa «Reconectar».", "created_at": "2026-10-01T10:03:27.000Z" } ] } } ``` `body` es texto plano: escápalo al pintarlo y respeta los saltos de línea (por ejemplo con `white-space: pre-wrap`). Leer la conversación **no** la marca como leída: para eso está `/read`. ### `POST /conversations/{id}/messages` El usuario escribe en una conversación. La deja en `open` (aunque estuviera cerrada), cuenta como leída y avisa al equipo por email. | Campo | Tipo | | | --- | --- | --- | | `user_id` | texto, **obligatorio** | El usuario que la abrió | | `body` | texto, **obligatorio** | El mensaje (hasta 10.000) | ```bash curl https://helpeen.com/api/v1/conversations/5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9/messages \ -H "Authorization: Bearer hp_sec_xxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "user_id": "u_123", "body": "Gracias, ya funciona." }' ``` `201 Created` ```json { "data": { "id": "3c4d5e6f-7a8b-4c9d-0e1f-2a3b4c5d6e7f", "author": "user", "author_name": "Ana", "body": "Gracias, ya funciona.", "created_at": "2026-10-01T10:20:41.000Z" } } ``` ### `POST /conversations/{id}/read` Marca como vistas las respuestas del equipo: `unread` pasa a `false`. Llámalo cuando el usuario abra la conversación en tu app. | Campo | Tipo | | | --- | --- | --- | | `user_id` | texto, **obligatorio** | El usuario que la abrió | ```bash curl https://helpeen.com/api/v1/conversations/5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9/read \ -H "Authorization: Bearer hp_sec_xxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "user_id": "u_123" }' ``` `200 OK` ```json { "data": { "id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9", "unread": false } } ``` --- ## Objetos ### Category | Campo | Tipo | | | --- | --- | --- | | `id` | uuid | | | `slug` | texto | Único en tu empresa | | `name` | texto | | | `description` | texto o `null` | | | `position` | entero | Orden en el panel | ### Article | Campo | Tipo | | | --- | --- | --- | | `id` | uuid | | | `slug` | texto | Único por idioma | | `kind` | `article` o `faq` | En una FAQ, `title` es la pregunta y `body` la respuesta | | `title` | texto | | | `excerpt` | texto o `null` | Resumen corto para listados | | `category` | `{ slug, name }` o `null` | | | `locale` | texto | Código de dos letras | | `position` | entero | | | `published_at` | fecha | | | `updated_at` | fecha | | | `body` | texto | Markdown. Solo en el detalle y en las FAQs | | `body_html` | texto | HTML saneado. Solo en el detalle y en las FAQs | ### Collection | Campo | Tipo | | | --- | --- | --- | | `id` | uuid | | | `slug` | texto | | | `title` | texto | | | `description` | texto o `null` | | | `locale` | texto | | | `position` | entero | | | `videos` | [Video] | Ordenados por `position` | ### Video | Campo | Tipo | | | --- | --- | --- | | `id` | uuid | | | `title` | texto | | | `description` | texto o `null` | | | `url` | texto | El enlace original | | `provider` | `youtube` o `vimeo` | | | `embed_url` | texto | Para un `