# 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 `<script>` adds a floating chat, a `<div>` adds an
  embedded help center, or the `@helpeen/react` components do the same. Look and texts are set in
  the Helpeen panel. Best when the developer wants support working fast and doesn't need their own
  design for it.
- **API**: a plain JSON HTTP API; the product renders everything with its own design. Best for a
  help page that must look native, or an inbox inside the app.

Helpeen has **no end-user accounts**: the product identifies its own users with its own ids.

- Base URL: `https://helpeen.com/api/v1`
- Auth: `Authorization: Bearer <key>`

## Rules you must follow

1. **The secret key (`hp_sec_…`) only ever runs on a server.** Never put it in browser code, a
   mobile app, or any variable that reaches a client bundle (`NEXT_PUBLIC_*`, `VITE_*`,
   `REACT_APP_*`, `EXPO_PUBLIC_*`, …). Helpeen rejects secret-key requests that carry an `Origin`
   header with `403`.
2. **`user_id` comes from the server-side session**, never from a request parameter, form field or
   header the client controls. Otherwise one user could read another user's conversations.
3. **Through the API, conversations are server-only.** All `/conversations` calls go through the
   product's backend (route handler, server action, API endpoint). From the browser, use the widget,
   which handles visitors and signed users safely.
4. **Render `body_html` as is**; it is already sanitized. Do not render the Markdown `body` with a
   library that allows raw HTML. Message `body` in conversations is **plain text**: escape it and
   keep line breaks (`white-space: pre-wrap`).
5. **Cache content** (articles, FAQs, videos) for a few minutes on the server. Do not cache
   conversations.
6. **Do not send support emails yourself.** Helpeen already emails the team when a user writes and
   emails the user when the team replies.
7. Keys live in environment variables. Suggested names: `HELPEEN_SECRET_KEY`, `HELPEEN_PUBLIC_KEY`,
   and `HELPEEN_API_URL` (default `https://helpeen.com/api/v1`). Add them to `.env.example` without
   values.

## Plan

Before coding, find out (from the codebase, or ask the developer):

1. **Stack and rendering**: server-rendered (Next.js, Remix, Nuxt, SvelteKit, Rails, Django,
   Laravel…), static site, SPA with its own backend, or mobile app with a backend.
2. **What they want**: a help page with FAQs/articles/videos, a contact form, an in-app "my
   conversations" inbox, or all of them.
3. **How users are authenticated**, so you know where to read the current user's id, email and name.
4. **The help-center language(s)**, to pass `?locale=`.

Then pick the architecture:

| Product | Content | Conversations |
| --- | --- | --- |
| Server-rendered web app | Fetch on the server (secret or public key) | Server actions / API routes with the secret key |
| Static site, no backend | Fetch from the browser with the **public** key, or use the widget's embedded help center; the site's origin must be in Helpeen › Settings › Allowed origins | The widget (floating chat), with anonymous visitors |
| SPA + own API | Browser with the public key, or proxy through the API | Through the product's API only |
| Mobile app + backend | App with the public key (no `Origin` header, so no origin check), or through the backend | Through the backend only |

## Widget recipes

The widget's full reference is in the "Widget" section of the reference below. The essentials:

```html
<!-- Floating chat: before </body> -->
<script src="https://helpeen.com/widget.js" data-key="hp_pub_xxxxxxxxxxxxxxxx" async></script>

<!-- Embedded help center: where it should appear -->
<div data-helpeen="help"></div>
<script src="https://helpeen.com/widget.js" data-key="hp_pub_xxxxxxxxxxxxxxxx" data-launcher="off" async></script>
```

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.
<HelpeenChat publicKey={process.env.NEXT_PUBLIC_HELPEEN_PUBLIC_KEY!} user={helpeenUser} />

// On the help page.
<HelpeenHelpCenter publicKey={process.env.NEXT_PUBLIC_HELPEEN_PUBLIC_KEY!} />
```

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<string, unknown>;
  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<T>(path: string, init: RequestInit & { revalidate?: number } = {}): Promise<T> {
  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<string, string | number | undefined>) => {
  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<HelpResponse>(`/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<string, unknown>;
}) => 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 (
    <main>
      <h1>Help</h1>

      {help.faqs.map((faq) => (
        <details key={faq.id}>
          <summary>{faq.title}</summary>
          <div dangerouslySetInnerHTML={{ __html: faq.body_html! }} />
        </details>
      ))}

      {help.video_collections.map((collection) => (
        <section key={collection.id}>
          <h2>{collection.title}</h2>
          {collection.videos.map((video) => (
            <iframe key={video.id} src={video.embed_url} title={video.title} allowFullScreen loading="lazy" />
          ))}
        </section>
      ))}
    </main>
  );
}
```

Article detail: call `getArticle(slug)`, return your framework's 404 when it is `null`, and render
`body_html`. Article lists come **without** `body`; FAQs always include it.

### Contact form (server action)

```ts
// app/help/actions.ts
"use server";
import { openConversation } from "@/lib/helpeen";

export async function contactSupport(formData: FormData) {
  const user = await getCurrentUser(); // from YOUR session; never trust the client for the id
  if (!user) throw new Error("Not signed in");

  const subject = String(formData.get("subject") ?? "").trim().slice(0, 200);
  const message = String(formData.get("message") ?? "").trim().slice(0, 10000);
  if (!subject || !message) return { error: "Subject and message are required." };

  const { data } = await openConversation({
    user: { id: String(user.id), email: user.email, name: user.name ?? undefined },
    subject,
    message,
    // Anything that helps the team, up to 4 KB: plan, app version, current page…
    metadata: { plan: user.plan },
  });
  return { conversationId: data.id };
}
```

### In-app inbox

1. List: `listConversations(user.id)`. Show `subject`, `status` and a badge when `unread` is true.
   Status labels for end users: `open` → "Waiting for the team", `pending` → "Replied",
   `closed` → "Closed".
2. Thread: `getConversation(id, user.id)`, then `markConversationRead(id, user.id)` so `unread`
   clears. A `404` means it is not this user's conversation: show your not-found page.
3. Reply: `replyToConversation(id, user.id, body)`. Writing reopens a closed conversation.
4. Unread badge in the app's navigation: `conversations.some((c) => c.unread)`. There are no
   webhooks or realtime events: refresh on navigation or poll every minute or so while the inbox is
   open.

### Static site with the public key

```js
const res = await fetch("https://helpeen.com/api/v1/faqs?locale=en", {
  headers: { Authorization: "Bearer hp_pub_xxxxxxxxxxxxxxxx" },
});
const { data } = await res.json();
// Render each FAQ: escape `title`, insert `body_html` as HTML.
```

Remind the developer to add the site's origin (for example `https://example.com`) in Helpeen ›
Settings › Allowed origins, or leave the list empty to allow any origin.

## Errors

Every error is `{ "error": { "code", "message" } }`. Handle them like this:

| Status | Code | What to do |
| --- | --- | --- |
| 400 | `invalid_request` | A field is missing or too long: validate before calling and show a form error |
| 401 | `unauthorized` | Wrong, missing or revoked key: check the environment variable |
| 403 | `forbidden` | Public key on a conversations endpoint, secret key from a browser, or origin not allowed |
| 404 | `not_found` | Show not-found (unknown slug, category, or someone else's conversation) |
| 500 | `internal` | Retry later; show a generic error |

`message` is for logs, not for end users.

## Checklist before you finish

- [ ] The secret key and the identity secret are only read in server code and are not in any client-exposed variable.
- [ ] Every `user_id` comes from the authenticated session on the server.
- [ ] `body_html` is rendered as HTML; titles and message bodies are escaped.
- [ ] Content is cached; conversations are not.
- [ ] The `locale` passed matches the language of the page.
- [ ] 404s from Helpeen become not-found pages, other errors are handled.
- [ ] `.env.example` lists `HELPEEN_SECRET_KEY` (and `HELPEEN_PUBLIC_KEY` if used) without values.
- [ ] You told the developer which keys to create in Helpeen › Integration and, for browser calls,
      which origin to allow.

---

# Full API reference

What follows is the complete reference, in Spanish. Field names, paths and JSON are the same in any
language.

# API de Helpeen

Con la API, tu web o tu app muestra el contenido de tu centro de ayuda (artículos, FAQs y vídeos)
con su propio diseño, y abre conversaciones con tu equipo en nombre de sus usuarios.

- **URL base:** `https://helpeen.com/api/v1`
- **Formato:** JSON en peticiones y respuestas, UTF-8.
- **Sin SDK:** cualquier cliente HTTP vale (`fetch`, `curl`, `axios`, `requests`…).

## Primeros pasos

1. En el panel, entra en **Integración** y crea una clave **pública** (`hp_pub_…`).
2. Pide todo el contenido de tu centro de ayuda en una llamada:

```bash
curl https://helpeen.com/api/v1/help \
  -H "Authorization: Bearer hp_pub_xxxxxxxxxxxxxxxx"
```

3. Pinta la respuesta con tu diseño. Si además quieres que tus usuarios escriban al equipo, crea una
   clave **secreta** (`hp_sec_…`) y llama a `/conversations` desde tu servidor.

¿Prefieres no programar? Con el [widget](#widget) añades un chat flotante o un centro de ayuda
completo con una línea de HTML.

## Widget

Tres formas de poner Helpeen en una web sin llamar a la API:

| | Qué añade | Cómo |
| --- | --- | --- |
| **Chat flotante** | Un botón que abre un panel para escribir al equipo y seguir las respuestas | Una línea `<script>` |
| **Centro de ayuda** | Un bloque dentro de la página con buscador, FAQs, artículos, vídeos y formulario de contacto | Un `<div>` y la misma línea |
| **React** | Los dos anteriores como componentes | `npm install @helpeen/react` |

El aspecto (color, posición, estilo del botón, tema y textos) se configura en el panel, en
**Widget**, y llega a las webs que ya lo tienen sin tocar su código. Todo se sirve en iframes desde
Helpeen, así que no choca con los estilos de tu web. Usa tu clave **pública**.

### Chat flotante

Pega esta línea antes de `</body>` (o en el `<head>`):

```html
<script src="https://helpeen.com/widget.js" data-key="hp_pub_xxxxxxxxxxxxxxxx" async></script>
```

El panel solo se descarga la primera vez que alguien pulsa el botón, así que no ralentiza la carga.

### Centro de ayuda incrustado

Pon el `<div>` donde quieras que aparezca. Con `data-launcher="off"` no sale el botón flotante:

```html
<div data-helpeen="help"></div>
<script src="https://helpeen.com/widget.js" data-key="hp_pub_xxxxxxxxxxxxxxxx" data-launcher="off" async></script>
```

Se ajusta en alto a su contenido. Puedes poner varios `<div data-helpeen="help">` en la misma página.

### React

```bash
npm install @helpeen/react
```

```tsx
import { HelpeenChat, HelpeenHelpCenter } from "@helpeen/react";

// Chat flotante, una vez, en tu layout:
<HelpeenChat publicKey="hp_pub_xxxxxxxxxxxxxxxx" />

// Centro de ayuda, en tu página de ayuda:
<HelpeenHelpCenter publicKey="hp_pub_xxxxxxxxxxxxxxxx" />
```

Las dos aceptan `locale`. `HelpeenHelpCenter` acepta además `className` y `style` para el contenedor.

### Atributos del script

| Atributo | | |
| --- | --- | --- |
| `data-key` | **obligatorio** | Tu clave pública |
| `data-locale` | opcional | Idioma (`es`, `en`, `pt`, `fr`, `de`, `it`). Por defecto, el `<html lang>` de la página |
| `data-launcher` | opcional | `off` para no mostrar el botón flotante |
| `data-user-id`, `data-user-email`, `data-user-name`, `data-user-hash` | opcional | Usuario identificado, ver abajo |

### Visitantes y usuarios identificados

Sin hacer nada más, quien escribe desde el widget es un **visitante**: deja su email, y el widget
recuerda sus conversaciones en ese navegador. Las respuestas le llegan también por email.

Si tu web tiene login, puedes **identificar** al usuario: el widget no le pide el email y le
enseña sus conversaciones, las mismas que abras por la API con su `user_id`. Para que nadie pueda
hacerse pasar por otro, **tu servidor** firma el id con el secreto de identidad del panel
(**Widget › Usuarios identificados**):

```js
// En tu servidor (Node.js). Nunca en el navegador.
import { createHmac } from "node:crypto";

const userHash = createHmac("sha256", process.env.HELPEEN_IDENTITY_SECRET).update(user.id).digest("hex");
```

Y se lo pasas al widget:

```html
<script src="https://helpeen.com/widget.js" data-key="hp_pub_xxxxxxxxxxxxxxxx"
  data-user-id="u_123" data-user-email="ana@ejemplo.com" data-user-name="Ana"
  data-user-hash="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" async></script>
```

```tsx
<HelpeenChat publicKey="hp_pub_xxxxxxxxxxxxxxxx" user={{ id: "u_123", email: "ana@ejemplo.com", name: "Ana", hash: userHash }} />
```

Si la firma no es válida, el usuario entra como visitante. Si regeneras el secreto, todos los
usuarios identificados pasan a visitantes hasta que tu servidor firme con el nuevo.

### Controlarlo desde tu código

```js
window.Helpeen("open");     // abre el chat
window.Helpeen("close");
window.Helpeen("toggle");
window.Helpeen("identify", { id: "u_123", email: "ana@ejemplo.com", name: "Ana", hash: "…" });
window.Helpeen("logout");   // al cerrar sesión en tu web
window.Helpeen("mount");    // rellena <div data-helpeen="help"> añadidos después de cargar
```

Las llamadas que hagas antes de que cargue el script se ejecutan al cargar si defines la cola:
`window.Helpeen = window.Helpeen || function () { (window.Helpeen.q = window.Helpeen.q || []).push(arguments); };`.
Con React, usa `helpeen("open")`, que importas de `@helpeen/react`.

### Dónde funciona

El widget solo se deja incrustar en los dominios de **Ajustes › Orígenes permitidos** (en
cualquiera si la lista está vacía). Para evitar abusos, cada visitante puede abrir 5 conversaciones
por hora y cada empresa recibe como mucho 100 por hora desde el widget.

Para recordar la sesión, el widget guarda un identificador en el almacenamiento local del navegador
(dentro del iframe de Helpeen). Es necesario para el servicio que pide el usuario, así que no
requiere consentimiento, pero conviene mencionarlo en tu política de cookies.

## Autenticación

Todas las peticiones llevan la clave en la cabecera `Authorization`:

```
Authorization: Bearer <clave>
```

| Clave | Prefijo | Qué puede hacer | Dónde se usa |
| --- | --- | --- | --- |
| Pública | `hp_pub_` | Leer el contenido publicado | Navegador, app móvil o servidor |
| Secreta | `hp_sec_` | Todo, incluidas las conversaciones | **Solo en tu servidor** |

Reglas:

- **La clave secreta nunca va en un navegador ni en una app.** Una petición con clave secreta que
  lleve cabecera `Origin` (es decir, que sale de un navegador) se rechaza con `403`. Si una clave
  secreta acaba en una página, revócala en **Integración** y crea otra.
- **Orígenes permitidos.** Con la clave pública desde un navegador, el origen de la página tiene que
  estar en **Ajustes › Orígenes permitidos** (por ejemplo `https://tuapp.com`). Si la lista está
  vacía, se acepta cualquier origen. Las peticiones sin `Origin` (servidor, app móvil) no se ven
  afectadas.
- **CORS** está activado para `GET` y `POST`, con las cabeceras `Authorization` y `Content-Type`.
- Una clave revocada deja de funcionar al momento (`401`).

## Convenciones

- **Ids:** UUID en texto.
- **Fechas:** ISO 8601 en UTC, por ejemplo `2026-10-01T10:00:00.000Z`.
- **Campos vacíos:** un campo opcional sin valor viene como `null`, nunca se omite (salvo `body` y
  `body_html` en los listados de artículos, ver abajo).
- **Orden:** el contenido viene en el orden que le has dado en el panel (`position`) y, a igualdad,
  por título.
- **Solo lo publicado:** la API nunca devuelve borradores, sea cual sea la clave.

### Idioma

Los endpoints de contenido aceptan `?locale=` con un código de dos letras (`es`, `en`, `fr`…). Sin
él, o si no es válido, se usa el idioma por defecto de tu empresa. Solo se devuelve contenido de ese
idioma: un artículo escrito en `es` no aparece al pedir `locale=en`.

### Paginación

`/articles` y `/faqs` admiten `limit` (de 1 a 100, 50 por defecto) y `offset` (desde 0). La
respuesta repite los valores aplicados:

```json
{ "data": [], "limit": 50, "offset": 0 }
```

Si `data` trae menos elementos que `limit`, no hay más páginas.

## Errores

Todos los errores tienen la misma forma:

```json
{
  "error": {
    "code": "not_found",
    "message": "No article `conectar-un-banco`."
  }
}
```

| `code` | HTTP | Cuándo |
| --- | --- | --- |
| `invalid_request` | 400 | Falta un campo, sobra longitud o el cuerpo no es un objeto JSON |
| `unauthorized` | 401 | No hay clave, tiene un formato raro, no existe o está revocada |
| `forbidden` | 403 | Clave pública en un endpoint de conversaciones, clave secreta desde un navegador u origen no permitido |
| `not_found` | 404 | El artículo, la categoría o la conversación no existen (o no son de ese usuario) |
| `internal` | 500 | Error nuestro. Reintenta más tarde |

`message` está en inglés y sirve para depurar; no lo enseñes tal cual a tus usuarios.

---

## Contenido

Se puede llamar con la clave pública o con la secreta.

### `GET /help`

Todo lo que necesita una página de soporte, en una sola llamada: categorías, FAQs (con su
respuesta, hasta 100) y colecciones de vídeos.

| Parámetro | Tipo | |
| --- | --- | --- |
| `locale` | query, opcional | Idioma del contenido |

```bash
curl "https://helpeen.com/api/v1/help?locale=es" \
  -H "Authorization: Bearer hp_pub_xxxxxxxxxxxxxxxx"
```

`200 OK`

```json
{
  "organization": { "name": "Monestic", "slug": "monestic" },
  "locale": "es",
  "categories": [
    {
      "id": "6f1c2a8e-4b0d-4e8a-9a51-2f7d3c9b1e20",
      "slug": "bancos",
      "name": "Bancos",
      "description": "Conectar, sincronizar y desconectar cuentas.",
      "position": 0
    }
  ],
  "faqs": [
    {
      "id": "b2e4d6f8-1a3c-4e5f-8a9b-0c1d2e3f4a5b",
      "slug": "es-seguro",
      "kind": "faq",
      "title": "¿Es seguro conectar mi banco?",
      "excerpt": null,
      "category": { "slug": "bancos", "name": "Bancos" },
      "locale": "es",
      "position": 0,
      "published_at": "2026-09-12T08:30:00.000Z",
      "updated_at": "2026-09-20T16:02:11.000Z",
      "body": "Sí. Nunca vemos tus claves: la conexión se hace a través de tu banco.",
      "body_html": "<p>Sí. Nunca vemos tus claves: la conexión se hace a través de tu banco.</p>\n"
    }
  ],
  "video_collections": [
    {
      "id": "0d9e8f7a-6b5c-4d3e-2f1a-0b9c8d7e6f5a",
      "slug": "primeros-pasos",
      "title": "Primeros pasos",
      "description": null,
      "locale": "es",
      "position": 0,
      "videos": [
        {
          "id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
          "title": "Conecta tu banco",
          "description": "En menos de dos minutos.",
          "url": "https://youtu.be/dQw4w9WgXcQ",
          "provider": "youtube",
          "embed_url": "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ",
          "thumbnail_url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg",
          "position": 0
        }
      ]
    }
  ]
}
```

### `GET /categories`

Las categorías, para montar la navegación de tu centro de ayuda.

```bash
curl https://helpeen.com/api/v1/categories \
  -H "Authorization: Bearer hp_pub_xxxxxxxxxxxxxxxx"
```

`200 OK`

```json
{
  "data": [
    {
      "id": "6f1c2a8e-4b0d-4e8a-9a51-2f7d3c9b1e20",
      "slug": "bancos",
      "name": "Bancos",
      "description": "Conectar, sincronizar y desconectar cuentas.",
      "position": 0
    },
    {
      "id": "7a2d3b9f-5c1e-4f9b-8b62-3a8e4d0c2f31",
      "slug": "facturacion",
      "name": "Facturación",
      "description": null,
      "position": 1
    }
  ]
}
```

Las categorías no dependen del idioma: se devuelven todas.

### `GET /articles`

Artículos y FAQs publicados, con filtros y búsqueda.

| Parámetro | Tipo | |
| --- | --- | --- |
| `kind` | query, opcional | `article` o `faq`. Sin él, los dos |
| `category` | query, opcional | Slug de categoría. `404` si no existe |
| `q` | query, opcional | Texto a buscar en título, extracto y cuerpo (hasta 100 caracteres, sin distinguir mayúsculas) |
| `locale` | query, opcional | Idioma del contenido |
| `limit`, `offset` | query, opcional | Paginación |

En el listado, los **artículos** van sin `body` ni `body_html` (pide cada uno con
`/articles/{slug}`), y las **FAQs** van con ellos, porque son cortas y se leen en el sitio.

```bash
curl "https://helpeen.com/api/v1/articles?category=bancos&q=sincroniz&limit=10" \
  -H "Authorization: Bearer hp_pub_xxxxxxxxxxxxxxxx"
```

`200 OK`

```json
{
  "data": [
    {
      "id": "c3f5e7a9-2b4d-4f6a-9b8c-1d2e3f4a5b6c",
      "slug": "conectar-un-banco",
      "kind": "article",
      "title": "Cómo conectar un banco",
      "excerpt": "Paso a paso, y qué hacer si la sincronización se para.",
      "category": { "slug": "bancos", "name": "Bancos" },
      "locale": "es",
      "position": 0,
      "published_at": "2026-09-10T09:00:00.000Z",
      "updated_at": "2026-09-28T11:45:00.000Z"
    }
  ],
  "limit": 10,
  "offset": 0
}
```

### `GET /articles/{slug}`

Un artículo o una FAQ, siempre con `body` (Markdown) y `body_html`.

| Parámetro | Tipo | |
| --- | --- | --- |
| `slug` | ruta | Slug del artículo |
| `locale` | query, opcional | Idioma. El mismo slug puede existir en varios idiomas |

```bash
curl https://helpeen.com/api/v1/articles/conectar-un-banco \
  -H "Authorization: Bearer hp_pub_xxxxxxxxxxxxxxxx"
```

`200 OK`

```json
{
  "data": {
    "id": "c3f5e7a9-2b4d-4f6a-9b8c-1d2e3f4a5b6c",
    "slug": "conectar-un-banco",
    "kind": "article",
    "title": "Cómo conectar un banco",
    "excerpt": "Paso a paso, y qué hacer si la sincronización se para.",
    "category": { "slug": "bancos", "name": "Bancos" },
    "locale": "es",
    "position": 0,
    "published_at": "2026-09-10T09:00:00.000Z",
    "updated_at": "2026-09-28T11:45:00.000Z",
    "body": "## Antes de empezar\n\nTen a mano el acceso a tu banca online.\n\n1. Ve a **Cuentas**.\n2. Pulsa **Añadir banco**.",
    "body_html": "<h2>Antes de empezar</h2>\n<p>Ten a mano el acceso a tu banca online.</p>\n<ol>\n<li>Ve a <strong>Cuentas</strong>.</li>\n<li>Pulsa <strong>Añadir banco</strong>.</li>\n</ol>\n"
  }
}
```

`404 Not Found` si no hay un artículo publicado con ese slug en ese idioma:

```json
{ "error": { "code": "not_found", "message": "No article `conectar-un-banco`." } }
```

**`body_html` ya viene saneado:** el HTML que se hubiera escrito a mano en el Markdown sale
escapado, y los enlaces o imágenes con esquemas peligrosos (`javascript:`, `data:`) pierden la URL.
Se puede insertar tal cual (`innerHTML`, `dangerouslySetInnerHTML`, `v-html`).

### `GET /faqs`

Atajo de `/articles?kind=faq`. Mismos parámetros salvo `kind`; las FAQs siempre traen su respuesta.

```bash
curl "https://helpeen.com/api/v1/faqs?category=facturacion" \
  -H "Authorization: Bearer hp_pub_xxxxxxxxxxxxxxxx"
```

`200 OK`

```json
{
  "data": [
    {
      "id": "d4a6b8c0-3e5f-4a7b-8c9d-2e3f4a5b6c7d",
      "slug": "cambiar-tarjeta",
      "kind": "faq",
      "title": "¿Cómo cambio la tarjeta de pago?",
      "excerpt": null,
      "category": { "slug": "facturacion", "name": "Facturación" },
      "locale": "es",
      "position": 0,
      "published_at": "2026-09-15T10:00:00.000Z",
      "updated_at": "2026-09-15T10:00:00.000Z",
      "body": "En **Ajustes › Suscripción**, pulsa *Cambiar tarjeta*.",
      "body_html": "<p>En <strong>Ajustes › Suscripción</strong>, pulsa <em>Cambiar tarjeta</em>.</p>\n"
    }
  ],
  "limit": 50,
  "offset": 0
}
```

### `GET /videos`

Colecciones de vídeos publicadas, con sus vídeos ordenados. Helpeen no aloja vídeo: guarda el
enlace de YouTube o Vimeo y te da la URL para incrustarlo.

| Parámetro | Tipo | |
| --- | --- | --- |
| `locale` | query, opcional | Idioma de las colecciones |

```bash
curl https://helpeen.com/api/v1/videos \
  -H "Authorization: Bearer hp_pub_xxxxxxxxxxxxxxxx"
```

`200 OK`

```json
{
  "data": [
    {
      "id": "0d9e8f7a-6b5c-4d3e-2f1a-0b9c8d7e6f5a",
      "slug": "primeros-pasos",
      "title": "Primeros pasos",
      "description": null,
      "locale": "es",
      "position": 0,
      "videos": [
        {
          "id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
          "title": "Conecta tu banco",
          "description": "En menos de dos minutos.",
          "url": "https://youtu.be/dQw4w9WgXcQ",
          "provider": "youtube",
          "embed_url": "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ",
          "thumbnail_url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg",
          "position": 0
        },
        {
          "id": "8b7c6d5e-4f3a-4b2c-1d0e-9f8a7b6c5d4e",
          "title": "Exporta tus datos",
          "description": null,
          "url": "https://vimeo.com/123456789",
          "provider": "vimeo",
          "embed_url": "https://player.vimeo.com/video/123456789",
          "thumbnail_url": null,
          "position": 1
        }
      ]
    }
  ]
}
```

Para incrustarlo: `<iframe src="{embed_url}" allowfullscreen></iframe>`. `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 `<iframe>`. YouTube va en su versión sin cookies |
| `thumbnail_url` | texto o `null` | `null` en Vimeo |
| `position` | entero | |

### Conversation

| Campo | Tipo | |
| --- | --- | --- |
| `id` | uuid | |
| `subject` | texto | |
| `status` | `open`, `pending` o `closed` | Ver [Estados](#estados) |
| `metadata` | objeto | El que mandaste al abrirla (`{}` si ninguno) |
| `created_at` | fecha | |
| `last_message_at` | fecha | |
| `unread` | booleano | Hay respuesta del equipo sin ver |
| `messages` | [Message] | Solo en `GET /conversations/{id}` |

### Message

| Campo | Tipo | |
| --- | --- | --- |
| `id` | uuid | |
| `author` | `user` o `agent` | `agent` es alguien de tu equipo |
| `author_name` | texto o `null` | |
| `body` | texto | Texto plano |
| `created_at` | fecha | |

---

## Límites

| | |
| --- | --- |
| `user_id` | 200 caracteres |
| `user.email` | 320 caracteres |
| `user.name` | 120 caracteres |
| `subject` | 200 caracteres |
| `message`, `body` | 10.000 caracteres |
| `metadata` | 4 KB en JSON |
| `q` | 100 caracteres (se recorta) |
| `limit` | 1–100 |
| Conversaciones por usuario en el listado | 100 |
| FAQs en `/help` | 100 |

## Ejemplo: página de soporte en Next.js

Contenido leído en el servidor y cacheado cinco minutos, y un formulario de contacto que abre la
conversación desde una server action:

```ts
// lib/helpeen.ts
const HELPEEN = "https://helpeen.com/api/v1";

async function helpeen<T>(path: string, init: RequestInit & { next?: { revalidate?: number } } = {}) {
  const res = await fetch(`${HELPEEN}${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${process.env.HELPEEN_SECRET_KEY}`,
      "Content-Type": "application/json",
      ...init.headers,
    },
  });
  const json = await res.json();
  if (!res.ok) throw new Error(`Helpeen ${res.status}: ${json.error?.message}`);
  return json as T;
}

export const getHelp = (locale: string) =>
  helpeen<HelpResponse>(`/help?locale=${locale}`, { next: { revalidate: 300 } });

export const openConversation = (input: {
  user: { id: string; email: string; name?: string };
  subject: string;
  message: string;
  metadata?: Record<string, unknown>;
}) => helpeen<{ data: Conversation }>("/conversations", { method: "POST", body: JSON.stringify(input) });
```

```ts
// app/soporte/actions.ts
"use server";

export async function contact(formData: FormData) {
  const user = await requireUser(); // tu sesión: el user_id sale de aquí, no del formulario
  await openConversation({
    user: { id: user.id, email: user.email, name: user.name },
    subject: String(formData.get("subject")),
    message: String(formData.get("message")),
    metadata: { plan: user.plan },
  });
}
```

## Ejemplo: FAQs en una web estática

Con la clave pública, directamente desde el navegador (añade el dominio en **Orígenes permitidos**):

```html
<div id="faqs"></div>
<script>
  // `title` is plain text: escape it. `body_html` is already safe.
  const escape = (text) =>
    text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);

  fetch("https://helpeen.com/api/v1/faqs?locale=es", {
    headers: { Authorization: "Bearer hp_pub_xxxxxxxxxxxxxxxx" },
  })
    .then((r) => r.json())
    .then(({ data }) => {
      document.getElementById("faqs").innerHTML = data
        .map((faq) => `<details><summary>${escape(faq.title)}</summary>${faq.body_html}</details>`)
        .join("");
    });
</script>
```
