# PumaHelp — Documentação para desenvolvedores > API REST, widget embarcável e bot do Discord do PumaHelp, uma plataforma de suporte multi-inquilino. Documentação do PumaHelp, plataforma de suporte multi-inquilino. Ao gerar código de integração, considere: - A URL base é por organização: `https://{subdominio}.pumahelp.com/api`. Não existe host único. - Todo JSON usa snake_case, inclusive nos parâmetros de rota (`{public_id}`, `{user_id}`). - Há dois modos de autenticação: `Authorization: Bearer {jwt}` para usuários e `X-API-Key: {chave}` para integrações servidor a servidor. Uma chave carrega escopos (`ticket:read`, `ticket:create`...) e age como o usuário que a criou. - Filtros de ticket usam uma linguagem própria no parâmetro `query`, não parâmetros separados. Repetir a mesma chave significa "ou" (`status:open status:pending`); chaves diferentes, "e". - Para separar dados por cliente, filtre por `id`, `external_id` ou `group_id`. Filtrar por nome casa por trecho e traz homônimos. - Confirme campos e endpoints nesta documentação antes de assumir: os nomes são os desta API. Ao gerar código do widget, considere ainda: - A tag do script precisa de `type="module"`. O widget é um módulo ES e carrega o painel sob demanda; sem isso o navegador nem executa o arquivo. - A versão atual é a v3, em `https://cdn.pumahelp.com/widget/v3/widget.js`. O elemento é ``, a API global é `pumahelp('comando', ...)` e a configuração vem de atributos `data-*` (`data-app`, `data-api-key`). A v2 usava `` e `PumaHelp.init()` — não misture as duas. - Todo evento é prefixado (`pumahelp:ready`, `pumahelp:ticket-created`...) e o detalhe vai em `event.detail`, tipado por evento. Não existe `event.detail.payload`. ## Painel O painel é onde a sua equipe atende e onde você configura o PumaHelp. Esta página percorre cada tela na ordem em que você vai encontrá-la, com os nomes exatamente como aparecem — para você saber o que cada botão faz antes de clicar. --- ## 🚀 Antes de começar O painel da sua organização fica em `https://sua-organizacao.pumahelp.com` — o subdomínio é o que você escolheu ao criar a conta (ele aparece em **Configurações → Organização**). O que você vê depende do seu papel: | Papel | Enxerga | |---|---| | **Agente** | Caixa de Entrada e Perfil | | **Administrador** | Tudo do agente, mais **Dashboard**, **Usuários** e **Configurações** | | **Dono** | Tudo do administrador, mais **Cobrança** e **Exportar dados** | O **Usuário final** é o seu cliente. Ele não entra no painel: fala com você pelo widget, pelo Discord ou por quem integrou o PumaHelp ao seu sistema. No canto superior direito ficam o **sino** de notificações e o menu do seu avatar: **Perfil**, **Modo claro** / **Modo escuro** e **Sair**. --- ## 📥 Caixa de Entrada A tela **Tickets** lista o que a sua equipe tem para atender. Cada linha é um ticket; a barra lateral esquerda guarda as **Visualizações**. **Colunas.** ID, Status, Assunto, Solicitante, Atribuído, Prioridade, SLA, Tipo, Tags, Data de Criação e Última Atualização. O botão **Colunas** esconde as que você não usa; a escolha fica salva. **Filtrar** abre o painel de filtros: **Buscar** (palavras no assunto), **Tags**, **Status do ticket**, **Atribuído a**, **Solicitante** (nome, e-mail ou ID), **Grupo**, **Data de Solicitação**, **Atualizado em**, **Tipo**, **Prioridade** e **Arquivado**. Os filtros ativos viram etiquetas acima da lista; **Limpar filtros** remove todas. Clicar no cabeçalho de uma coluna ordena — inclusive por **SLA**, que põe no topo o prazo mais apertado. **Visualizações** são filtros salvos com nome. Monte o filtro que você usa todo dia, clique em **Salvar visualização**, dê um nome e escolha quem vê: - **Só para mim** — aparece apenas na sua barra lateral. - **Para toda a organização** — todos os agentes passam a ver. Só Dono e Administrador criam estas. Cada pessoa tem até **5 visualizações próprias**, e a organização até **5 compartilhadas**. O número ao lado de cada uma é a quantidade de tickets que ela contém, atualizado sozinho. A linha **Tickets arquivados**, no rodapé, é fixa e não conta como visualização. Excluir uma visualização compartilhada a remove para todos os agentes — os tickets não são afetados. **Prazos na lista.** A coluna SLA mostra um chip por ticket: **vence em 2h15** (verde), o mesmo em âmbar quando o prazo está perto de estourar, **estourado há 40 min** (vermelho) ou **pausado**. Os prazos vêm das [políticas de SLA](#️-sla). **Arquivar** (no menu da linha, Dono e Administrador) tira o ticket das listas sem apagar nada. Ele continua em **Tickets arquivados**, de onde pode ser desarquivado. --- ## 🎫 Dentro do ticket O cabeçalho mostra o status, o número (**Ticket #123**) e, quando o ticket nasceu de uma resposta a um ticket fechado, o link **Acompanhamento de #98**. À direita, quem mais está com o ticket aberto e **… está digitando**, em tempo real. ### O painel lateral - **Solicitante** — quem abriu o ticket. - **Atribuído** — o responsável. **Aceitar** atribui o ticket a você em um clique. - **Tags**, **Tipo** e **Prioridade** — editáveis ali mesmo. - **Acompanhamento** — aparece quando este ticket continua a conversa de um ticket fechado, com o número e o assunto do original. - **Aguardando o cliente** — aparece em ticket pendente: há quanto tempo o cliente não responde e, se a automação estiver ligada, quando o ticket será marcado como resolvido pelo sistema. - **SLA** — um chip por prazo em andamento (por exemplo, **Primeira resposta · Urgente**). - **Avaliação do cliente** — a nota que o cliente deu (**Bom**, **Regular** ou **Ruim**), a data e o comentário dele, se houver. ### Responder No rodapé, o seletor **Tipo de resposta** decide quem vê o que você escreve: - **Resposta pública** — vai para o cliente. - **Observação interna** — só a equipe vê. Fica marcada como **Interno** na conversa. Formate com **Negrito** e **Itálico**, use **Emojis** e **Anexar arquivo**. As **macros** ficam no menu do rodapé e preenchem a resposta e os campos do ticket — nada é salvo até você enviar. O botão de envio diz o status com que o ticket vai ficar: **Enviar como Aberto**, **Pendente** ou **Resolvido**. Não existe "Enviar como Fechado": fechar é [automático](#-status-e-ciclo-de-vida). `Ctrl+Enter` envia. ### Atividades O interruptor **Atividades**, no cabeçalho, mostra entre as mensagens tudo o que aconteceu com o ticket, em linhas compactas: *alterou o status de Aberto para Pendente*, *assumiu o ticket*, *transferiu o ticket de Maria para Bruno*, *moveu o ticket do grupo Suporte para Financeiro*, *fechou o ticket automaticamente (Resolvido → Fechado)*, *SLA de primeira resposta estourou*, *respondeu a este ticket fechado — a conversa continua no acompanhamento*. Quem fez aparece no início da linha; o que o PumaHelp faz sozinho vem como **Sistema**, e o que chega por uma integração como **Integração**. A escolha de mostrar ou esconder fica salva para você. As atividades são **só da equipe**: o cliente não as vê. Quando outra pessoa mexe num ticket que você está olhando, a linha nova entra na hora. ### Corrigir o que já foi enviado No menu de cada mensagem: - **Marcar texto para supressão** abre a **Supressão de conteúdo**: selecione o trecho e clique em **Marcar para supressão** — ele vira uma tarja preta. Para tirar o endereço de um link, selecione-o e use **Suprimir link**. **Suprimir anexo** remove um arquivo da conversa. - **Converter para nota interna** transforma uma resposta pública em observação interna: o solicitante deixa de vê-la. :::danger Supressão é permanente O conteúdo original sai do PumaHelp e não pode ser recuperado. E ela vale só aqui dentro: uma mensagem que já foi entregue por e-mail ou no Discord não é alterada lá fora. ::: As abas de tickets no topo da tela podem ser **fixadas** para você voltar a elas sem procurar. --- ## 🔄 Status e ciclo de vida | Status | Significa | |---|---| | **Novo** | Chegou e ninguém mexeu ainda | | **Aberto** | Está com a equipe | | **Pendente** | Está aguardando o cliente | | **Resolvido** | A equipe deu o assunto por encerrado; o cliente ainda pode responder | | **Fechado** | Encerrado em definitivo | Duas regras valem para todos os tickets: - **Responder a um ticket resolvido o reabre.** Se o cliente escrever, o ticket volta para Aberto. - **Fechado é final.** Uma resposta do cliente abre um ticket de acompanhamento ligado a este. O acompanhamento herda assunto, grupo, tipo, prioridade e tags, e os dois ficam ligados pelos links **Acompanhamento de #…** e **Acompanhamento**. Ninguém fecha um ticket à mão. Quem fecha é a automação de **Configurações → Organização**, que também pode resolver sozinha os tickets que ficaram pendentes tempo demais — veja [Organização](#️-configurações--organização). --- ## 📊 Dashboard (Estatísticas) Só Dono e Administrador. A tela **Estatísticas** tem um seletor de período — **Últimas 24 horas**, **Últimos 7 dias**, **Últimos 30 dias**, **Últimos 90 dias** ou **Personalizado** — e cinco abas. Passe o mouse sobre qualquer número para ler como ele é calculado. **Visão geral.** Os cards **Criados** e **Resolvidos** (resolvidos pela primeira vez no período — reabrir depois não tira o ticket daqui), **Resolução (mediana)** (metade dos tickets foi resolvida em até esse tempo), **Sem 1ª resposta** e **Backlog aberto** (os dois refletem o agora, não o período). Abaixo, **Idade do backlog** (há quanto tempo os tickets abertos estão esperando), o **Tempo de primeira resposta por faixa** e a tabela **Precisa de atenção**: abertos sem nenhuma resposta, mais antigos primeiro. **Tempos.** Três medidas, cada uma com mediana e distribuição por faixa: - **Primeira resposta** — da criação até a primeira resposta pública de um agente. Notas internas e a descrição de abertura não contam. - **Resposta subsequente** — da mensagem mais antiga do cliente ainda sem resposta até a próxima resposta do agente. Mensagens seguidas do cliente contam como uma só espera. - **Resolução** — da criação até o ticket ser resolvido. **SLA.** A frase do topo resume: *X% dentro do SLA no período*. Depois, **Vai estourar** (prazos em andamento, do mais urgente ao mais folgado), **Aderência por alvo e prioridade**, **Cumpridos × estourados** ao longo do tempo e **Violações recentes**. Sem nenhum prazo no período, a aba fica vazia e aponta para **Configurações → SLA**. Com o SLA da organização desligado, uma faixa no topo da aba avisa, com o atalho para ligá-lo; os números do período continuam aparecendo, porque são dos prazos medidos enquanto ele estava ligado, e os que já estavam correndo seguem medidos até terminar. **Satisfação.** *N% positivo* e a taxa de resposta (quantos tickets resolvidos foram avaliados), a **Distribuição** entre boas, neutras e ruins, **Avaliações ao longo do tempo** e os **Comentários recentes**. As avaliações são coletadas no widget, quando o ticket é resolvido, e no Discord, pelos botões na conversa; integrações próprias também podem enviá-las. **Equipe.** Uma linha por agente, em ordem alfabética: **Carga atual** (abertos atribuídos a ele agora), **Resolvidos** (creditados a quem resolveu; reatribuir depois não muda), **Resolução (mediana)** e **Recontato 24h** (tickets resolvidos cujo cliente abriu outro em até 24 horas). Tickets sem responsável aparecem na Visão geral, não aqui. --- ## 👥 Usuários Dono e Administrador. A lista tem as visualizações **Todos**, **Agentes** e **Clientes**, com Nome, E-mail, Cargo e ID externo. Os visitantes anônimos do widget não entram na lista; eles aparecem como solicitantes nos próprios tickets. **Adicionar Usuário** pede **Nome**, **E-mail**, **ID externo** (opcional — o identificador da pessoa no seu sistema), **Cargo** e **Grupos**. Um cliente precisa de e-mail ou ID externo; quem é da equipe precisa de e-mail, porque é com ele que entra no painel — o formulário não deixa criar alguém da equipe sem ele, e a pessoa recebe um convite nesse endereço. Cada pessoa da equipe ocupa uma vaga de agente do plano. Os cargos são **Dono**, **Administrador**, **Agente** e **Usuário final (cliente)** — e só um Dono pode dar o cargo de Dono a alguém. Um e-mail identifica uma pessoa só na organização. Se o endereço já for de alguém — um cliente, um visitante que confirmou o e-mail no widget ou alguém da equipe —, o painel avisa **"Este e-mail já está em uso."**. A busca da lista acha a pessoa pelo nome ou pelo e-mail. Para trazer um cliente para a equipe, mude a **Função** dele no perfil em vez de criar outro usuário. --- ## 🙍 Perfil O seu perfil (ou o de outra pessoa, se você for Administrador ou Dono) tem, na lateral, **Função**, **Grupos**, **Email**, **ID Externo** e **Observações** — as observações salvam ao apertar Enter. Uma etiqueta diz se o usuário está **verificado**. Mudar a **Função** de um cliente para Agente, Administrador ou Dono dá a ele acesso ao painel. Antes de gravar, abre o diálogo **Trazer cliente para a equipe**, com o campo **E-mail de login** — preenchido com o e-mail do cliente, e que você pode trocar; sem e-mail, a promoção não é feita. Em **O que acontece**, o diálogo lista os efeitos: a pessoa recebe um convite nesse endereço para entrar no painel (se ela já tiver login no PumaHelp, o convite vai para o e-mail desse login) e ela passa a ocupar uma vaga de agente do plano. **Trazer para a equipe** confirma, e o aviso de sucesso mostra o endereço para onde o convite vai; **Cancelar** mantém a pessoa como cliente. Se o e-mail já estiver em uso ou o limite de agentes do plano tiver sido atingido, o painel avisa e o diálogo continua aberto. Em **Segurança**, **Alterar E-mail** pede o novo endereço e a sua senha atual, e manda uma confirmação para a caixa nova. **Alterar Senha** pede a atual e a nova duas vezes; a senha precisa de pelo menos 6 caracteres, com letras e números. ### Notificações O interruptor **Avisos no painel** liga o **sino**: tickets novos, respostas de clientes e prazos de SLA chegam em tempo real, com o número no ícone e no título da aba do navegador. Abrir o sino mostra as mais recentes e **Carregar mais** traz as antigas; **Marcar todas como lidas** limpa o contador. Abrir um ticket marca as notificações dele como lidas. Quem recebe o quê: o **responsável** pelo ticket é avisado do que acontece nele. Um ticket sem responsável só avisa o grupo se a organização ligou essa opção em [Organização](#️-configurações--organização) — os alertas de **prazo de SLA**, esses vão sempre ao grupo. --- ## ⚙️ Configurações → Organização O cabeçalho mostra o nome, o **Subdomínio** (com um botão para copiar) e a data de criação. ### Ciclo de vida dos tickets Duas automações por tempo, contadas em dias corridos e verificadas continuamente — a ação pode sair alguns minutos depois do prazo. Cada uma tem um interruptor e o prazo **Após** N **horas** ou **dias**: - **Fechar tickets resolvidos automaticamente** — um ticket resolvido sem resposta do cliente por esse prazo é fechado. O fechamento entra nas Atividades como ação do Sistema. Máximo de 30 dias. - **Resolver tickets aguardando o cliente** — um ticket pendente sem resposta do cliente por esse prazo é marcado como resolvido pelo sistema, com o responsável creditado. O cliente ainda pode responder e reabrir, e avaliar o atendimento. Vem desligada. Máximo de 90 dias. Ao ligar, os tickets que já estão aguardando o cliente há mais tempo que esse prazo são resolvidos logo em seguida — a tela avisa. O mínimo das duas é 1 hora. Se o valor atual estiver abaixo desse mínimo, a tela avisa qual prazo será salvo no lugar. ### Notificações da equipe **Avisar todo o grupo quando chega um ticket sem responsável.** Desligado (o padrão), só quem tem o ticket é notificado, e a equipe acompanha os tickets sem responsável pela lista e pelas visualizações. Ligado, todos do grupo recebem — e quando alguém assume, a notificação dos outros é recolhida. Alertas de prazo (SLA) vão sempre ao grupo, independente desta escolha. Tudo nesta tela grava pelo botão **Salvar configurações**, no rodapé. --- ## 👥 Grupos Grupos são os times para onde os tickets vão — Suporte, Financeiro, Nível 2. Cada grupo tem **Nome** e **Descrição**, e um deles é o **Padrão**: recebe todo ticket que chega sem grupo definido. **Todo membro da equipe pertence ao grupo Padrão**, e ele não pode ser removido de ninguém — é por isso que o Padrão nem aparece na lista de grupos ao criar ou editar um usuário: já está incluído. Um agente vê os tickets dos grupos a que pertence — o Padrão e os demais em que você o incluir. Dono e Administrador veem tudo sempre. --- ## ⚡ Macros Macros são respostas e ajustes prontos, aplicados com um clique no rodapé do ticket. Cada macro tem **Título**, **Descrição** e um **Tipo**: **Apenas eu** ou **Agentes no grupo** (aí você escolhe os **Grupos**). As ações possíveis são **Adicionar comentário**, **Definir status**, **Definir tipo** e **Definir prioridade** — uma de cada por macro. "Fechado" não está entre os status: fechar é sempre [automático](#-status-e-ciclo-de-vida). Aplicar uma macro preenche a resposta e os campos na tela; só vale quando você envia. --- ## 🔑 Chaves de API A tela **Chaves de API** aparece no menu como **API Keys**. É aqui que nasce toda integração: o widget no seu site, o bot do Discord e qualquer sistema seu que fale com o PumaHelp precisam de uma chave. Ao criar, dê um **Nome** e responda **Para que esta chave será usada?** Dois presets resolvem os casos comuns com um clique — **Bot do Discord** e **Widget** — e preenchem as permissões necessárias; você pode ajustar depois. Para outros usos, marque as **Permissões (Scopes)** uma a uma. Se marcar todas, a tela avisa: a chave terá **acesso total** à organização — trate-a como uma senha de administrador. :::danger A chave aparece uma única vez Ao clicar em **Gerar Chave**, copie e guarde. Depois disso a lista mostra só o **Prefixo**, e o valor completo não pode ser recuperado. Perdeu? **Revogar e Rotacionar** gera uma chave nova e invalida a antiga na hora — atualize onde ela estava em uso antes. ::: :::warning Uma chave para o site, outra para o servidor A chave do preset **Widget** só sabe abrir sessões de visitante: pode ficar no seu site, porque quem abrir o código não consegue fazer nada além disso com ela. Já uma chave que **emite sessão para um usuário já logado** dá acesso em nome de qualquer pessoa da organização — ela só pode viver num sistema seu que ninguém de fora acessa. Nunca as misture. ::: **Excluir Chave** é definitivo: a chave para de funcionar para sempre. A lista completa de permissões, com o que cada uma libera, está na [referência da API](/docs/api#catálogo-de-escopos). --- ## 🪝 Webhooks Um webhook é um endereço seu que o PumaHelp chama quando algo acontece. Ao criar, informe o **Nome**, a **Url do Webhook (POST)** e marque os **Eventos**: **Criar Ticket**, **Atualizar Ticket**, **Criar Usuário**, **Atualizar Usuário** e **Deletar Usuário**. Cada webhook tem uma **Webhook Key**, que o painel mostra ao criar (**Mostrar** / **Ocultar**, **Copiar Key**). É com ela que o seu sistema confere que a chamada veio mesmo do PumaHelp. **Rotacionar Webhook Key** gera uma nova e invalida a antiga na hora — atualize o seu sistema logo em seguida. As chamadas que ele recusar nesse meio-tempo são reenviadas por até cerca de um dia, já com a chave nova. Quem programa o sistema que recebe as chamadas encontra o formato, a validação e a política de reenvio na [referência da API](/docs/api#-webhooks). --- ## ⏱️ SLA Dono e Administrador. A tela tem três cartões, e cada um grava separadamente. ### SLA da organização O primeiro cartão liga e desliga o SLA da organização inteira, e diz o estado atual: - **Ligado** — *O SLA está ligado. Tickets novos recebem prazos pelas políticas abaixo.* - **Desligado** — *O SLA está desligado. Nenhum prazo novo é contado e ninguém recebe alertas de prazo. Revise o horário comercial e as políticas e ligue quando quiser.* As duas mudanças pedem confirmação. Ao ligar, a confirmação mostra o horário comercial salvo, que é o que os prazos em horas úteis vão seguir. Ao desligar, ela lembra que os prazos que já estão correndo continuam sendo medidos até a resposta ou a resolução, sem alertas, e que o histórico dos relatórios é mantido. Ligar vale dali em diante: para os tickets novos e também nos que já existiam, na próxima resposta do cliente e na reabertura de um ticket que já tinha prazo de resolução. Nenhum alerta acumulado enquanto o SLA estava desligado é enviado. Os detalhes estão em [Ligar e desligar o SLA](/docs/api#ligar-e-desligar-o-sla). Uma organização nova começa com o SLA ligado. ### Horário comercial Define quando os prazos "em horas úteis" contam. Toda organização começa com segunda a sexta, das **09:00 às 18:00**, no horário de Brasília. Escolha o **Fuso horário**, ligue os dias da semana e informe o expediente de cada um (um dia desligado aparece como **sem expediente**). Em **Feriados**, **Adicionar** cria uma linha com data e nome; **todo ano** repete a data anualmente. Horário e feriados gravam juntos, no botão **Salvar horário comercial e feriados**; enquanto houver edição pendente aparece a etiqueta **Alterações não salvas**, e a tela avisa se você tentar sair. Os prazos já em andamento não mudam — a nova regra vale para os próximos. ### Políticas de SLA Uma política diz **para quais tickets** valem **quais prazos**. A lista é avaliada de cima para baixo: cada ticket usa a **primeira** política cujas condições ele atende (na mesma posição, vale a mais antiga). A **política padrão** fica sempre por último, atende os tickets que não se encaixam em nenhuma outra e **completa** os prazos que uma política específica deixou em branco — a lista marca essas com **completa com a padrão**. **Nova política** pede um **Nome**, as condições **Canal**, **Tipo**, **Grupo** e **Tag** (em branco = **Qualquer**; na tag, basta o ticket ter a tag informada), a **Posição** na lista e se está **Ativa**. Os **Alvos** são uma grade de três prazos — **Primeira resposta**, **Resposta subsequente** e **Resolução** — por quatro prioridades — **Urgente**, **Alta**, **Normal** e **Baixa** —, cada um em minutos ou horas e contado em **Horas úteis** ou **Corrido (24×7)**. Deixe em branco o que não deve ter prazo; a resposta subsequente só passa a valer quando tem um prazo aqui. Remover uma política não mexe nos prazos já em andamento; os próximos tickets passam a seguir as outras. --- ## 💳 Cobrança Só o Dono. **Sua assinatura** mostra o estado — **Em teste**, **Ativo**, **Pagamento pendente**, **Inativo**, **Cancelado**, **Incompleto** ou **Expirado** —, o plano, quantos **Agentes** a organização usa do limite, quando o teste termina e quando a assinatura renova ou cancela. **Gerenciar cobrança** abre o portal de pagamento (cartão, notas fiscais, cancelamento). Em **Planos**, escolha entre **Mensal** e **Anual** e clique em **Assinar** ou **Trocar plano**. Quando algo precisa da sua atenção, uma faixa aparece no topo de todas as telas: o fim do período de teste, um **pagamento pendente** ou a **assinatura inativa** — neste último caso a organização entra em **modo somente leitura**: a equipe continua vendo tudo, mas não responde nem altera tickets até a cobrança ser regularizada. Exportar os dados continua liberado. --- ## 📦 Exportar dados Só o Dono. **Exportar meus dados** gera um arquivo JSON com a organização, os usuários, os grupos, as macros e **todos os tickets com as conversas** e os links dos anexos — a sua cópia completa, para guardar ou para levar embora. Leva alguns minutos; os donos recebem um e-mail quando fica pronto, e a lista **Exportações** mostra cada pedido com o estado **Processando**, **Pronto**, **Falhou** ou **Expirado** e o botão **Baixar**. O arquivo fica disponível por **7 dias**, e é possível pedir uma exportação por dia. --- ## ➡️ Integrando o PumaHelp? Com a chave de API criada: - **Colocando o chat no seu site?** [Widget](/docs/widget) - **Ligando ao Discord?** [Bot do Discord](/docs/discord-bot) - **Integrando por HTTP?** [Referência da API](/docs/api) --- ## API # API Documentation A **PumaHelp API** é uma API RESTful completa para gerenciamento de tickets de suporte, permitindo criar, atualizar e gerenciar tickets, comentários, usuários, grupos, macros, webhooks e muito mais. ## 🔗 Base URL Todas as requisições devem ser feitas para o subdomínio da sua organização: ``` https://{seu-subdominio}.pumahelp.com/api ``` **Exemplo:** ``` https://acme.pumahelp.com/api ``` :::note O subdomínio da sua organização aparece em **Configurações → Organização**, logo abaixo do nome, com um botão para copiar. Se não souber qual é, verifique lá ou entre em contato com o suporte. ::: --- ## 🚀 Como Começar ### Passo 1: Obter Acesso Você tem duas opções para começar a usar a API: **Opção A: Usar credenciais de usuário (JWT)** - Ideal para: aplicações que já têm usuários cadastrados - Requer: email e senha de um usuário existente **Opção B: Criar API Key (Recomendado para integrações)** - Ideal para: sistemas externos, automações, integrações de longo prazo - Requer: acesso ao dashboard PumaHelp com permissões de admin/owner ### Passo 2: Fazer Sua Primeira Requisição **Com JWT:** ```bash # 1. Fazer login curl -X POST https://acme.pumahelp.com/api/v1/users/login \ -H "Content-Type: application/json" \ -d '{ "email": "seu-email@example.com", "password": "sua-senha" }' # 2. Usar o token recebido curl -X GET https://acme.pumahelp.com/api/v1/tickets \ -H "Authorization: Bearer SEU_TOKEN_AQUI" ``` **Com API Key:** ```bash # 1. Criar API Key no dashboard (Configurações → API Keys) # 2. Usar a API Key diretamente curl -X GET https://acme.pumahelp.com/api/v1/tickets \ -H "X-API-Key: SEU_API_KEY_SECRET" ``` ### Passo 3: Criar Seu Primeiro Ticket ```bash curl -X POST https://acme.pumahelp.com/api/v1/tickets \ -H "Authorization: Bearer SEU_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "subject": "Meu primeiro ticket via API", "priority": "normal", "type": "question", "comment": { "body": "Testando a integração com sucesso!", "public": true } }' ``` ### Passo 4: Próximos Passos - ✅ Configure [webhooks](#-webhooks) para receber notificações em tempo real - ✅ Configure [scopes adequados](#-sistema-de-scopes) para sua API Key - ✅ Implemente [tratamento de erros](#-códigos-de-erro) robusto --- ## 📝 Convenções de Nomenclatura A API utiliza **snake_case** para todos os campos de request e response. **Exemplo:** ```json { "public_id": 12345, "created_at": "2025-01-01T10:00:00Z", "assignee_id": "uuid", "public": true } ``` :::important Todos os nomes de campos devem usar **snake_case_lower** (letras minúsculas com underscores). ::: --- ## 🔐 Autenticação A PumaHelp API suporta **dois métodos de autenticação**: ### 1. JWT (JSON Web Token) Para usuários que fazem login via email/senha. **Como obter:** ```http POST https://acme.pumahelp.com/api/v1/users/login Content-Type: application/json { "email": "user@example.com", "password": "sua-senha" } ``` **Resposta:** ```json { "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "verified": true } ``` **Como usar:** ```http GET https://acme.pumahelp.com/api/v1/tickets Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... ``` O token de um usuário **excluído ou desativado** deixa de autenticar, mesmo antes de expirar: as requisições com ele devolvem `401`. --- ### 2. API Key Para integrações automatizadas e sistemas externos. **Como obter:** 1. Faça login na plataforma PumaHelp 2. Navegue até Configurações → API Keys 3. Clique em "Criar Nova API Key" 4. Selecione os scopes necessários 5. Copie o secret (visível apenas uma vez!) **Como usar:** ```http GET https://acme.pumahelp.com/api/v1/tickets X-API-Key: seu-api-key-secret-aqui ``` :::important O secret da API Key é exibido **apenas uma vez** durante a criação. Guarde-o em local seguro! ::: ### Cabeçalho opcional: `X-PumaHelp-Prior-Session` ```http GET https://acme.pumahelp.com/api/v1/tickets Authorization: Bearer {token_novo} X-PumaHelp-Prior-Session: {token_anonimo_anterior} ``` Declara que quem faz a requisição **era** o convidado daquele token. Se houver o que absorver — e quem decide isso é o servidor —, o histórico do convidado passa para a identidade autenticada. É o caminho recomendado para o caso "o visitante conversou como anônimo e depois fez login". O cabeçalho é uma declaração, não uma ordem: uma sessão anterior inválida, já absorvida ou de outra organização é simplesmente ignorada, e a requisição segue normalmente. Envie-o até a primeira resposta bem-sucedida e depois descarte o token antigo. :::note Um convidado já absorvido **deixa de autenticar** — as requisições com o token dele passam a devolver `401`. Isso é intencional: é o sinal de que o cliente deve abrir uma sessão nova em vez de continuar falando por uma identidade que já foi fundida. Existe também `POST /v1/users/merge-session`, que faz a fusão de forma explícita. Prefira o cabeçalho: ele não exige que o cliente saiba, de antemão, se há algo a fundir. ::: --- ## 🔒 Sistema de Scopes A API utiliza um sistema granular de permissões baseado em **scopes** (no formato `resource:action`). ### Como Funcionam os Scopes | Tipo de Autenticação | Comportamento | |---------------------|---------------| | **JWT (Login)** | Scopes atribuídos automaticamente baseados na role do usuário | | **API Key** | Scopes devem ser explicitamente selecionados na criação | :::note `reports:read` vem no login apenas para **Owner** e **Admin**: os relatórios são da organização inteira, sem recorte por grupo. Uma API Key recebe o scope quando ele é selecionado na criação. ::: ### Catálogo de Escopos Os **48** escopos, por categoria. A coluna "No JWT" diz quem já recebe o escopo ao fazer login (uma API Key só tem o que foi selecionado na criação). "Agente e acima" inclui Admin e Owner; "Admin e Owner" exclui agentes. **Tickets** (`ticket`) | Scope | Permite | No JWT | |---|---|---| | `ticket:create` | Criar tickets | End-user e acima | | `ticket:read` | Ler tickets — a posse (agente fora do grupo, cliente em ticket alheio) é aplicada pela regra de negócio | End-user e acima | | `ticket:update` | Atualizar tickets, com a mesma restrição de posse | End-user e acima | | `ticket:delete` | Arquivar tickets | Admin e Owner | | `ticket:events:read` | Ler a [linha do tempo](#linha-do-tempo-do-ticket-eventos) — visão de equipe, nunca do cliente | Agente e acima | **Comentários** (`comment`) | Scope | Permite | No JWT | |---|---|---| | `comment:read` | Ler comentários | End-user e acima | | `comment:update` | Marcar comentários como privados | Agente e acima | | `comment:redact` | Suprimir conteúdo de comentários e anexos | Agente e acima | **Usuários** (`user`) | Scope | Permite | No JWT | |---|---|---| | `user:create` | Criar usuários | Admin e Owner | | `user:read` | Ler usuários | Agente e acima | | `user:update` | Atualizar usuários | Admin e Owner | | `user:delete` | Remover usuários | Admin e Owner | | `user:upsert` | Criar ou atualizar (`create_or_update`) | Admin e Owner | | `user:manage` | Ações de gestão, como enviar a verificação de e-mail | Agente e acima | | `user:impersonate` | Gerar access token em nome de um usuário | Admin e Owner | | `guest:create` | Criar convidados (`/v2/guests`, `/v3/guests`) | End-user e acima | **Perfil e conta** (`profile`) | Scope | Permite | No JWT | |---|---|---| | `profile:read:own` | Ler o próprio perfil (`GET /v1/users/me`) | End-user e acima | | `account:update:own` | Alterar o próprio e-mail e senha | End-user e acima | **Sessão** (`session`) | Scope | Permite | No JWT | |---|---|---| | `session:delete:own` | Logout | End-user e acima | | `session:merge` | Fundir uma sessão de convidado numa autenticada | Só end-user | **Grupos** (`group`) | Scope | Permite | No JWT | |---|---|---| | `group:create` | Criar grupos | Admin e Owner | | `group:read` | Ler grupos e membros | Agente e acima | | `group:update` | Atualizar grupos | Admin e Owner | | `group:delete` | Remover grupos | Admin e Owner | **Macros** (`macro`) | Scope | Permite | No JWT | |---|---|---| | `macro:create` | Criar macros | Agente e acima | | `macro:read` | Ler macros | Agente e acima | | `macro:update` | Atualizar macros | Agente e acima | | `macro:delete` | Remover macros | Agente e acima | **API Keys** (`key`) | Scope | Permite | No JWT | |---|---|---| | `apikey:create` | Criar API Keys | Admin e Owner | | `apikey:read` | Listar API Keys | Admin e Owner | | `apikey:update` | Atualizar metadados e escopos de uma chave | Admin e Owner | | `apikey:delete` | Remover API Keys | Admin e Owner | | `apikey:rotate` | Gerar novo secret para uma chave | Admin e Owner | **Organização** (`organization`) | Scope | Permite | No JWT | |---|---|---| | `organization:read` | Ler a organização | Agente e acima | | `organization:update` | Alterar configurações da organização, políticas de SLA e horário comercial | Admin e Owner | **Webhooks** (`webhook`) | Scope | Permite | No JWT | |---|---|---| | `webhook:create` | Criar webhooks | Admin e Owner | | `webhook:read` | Listar webhooks | Admin e Owner | | `webhook:update` | Atualizar webhooks | Admin e Owner | | `webhook:delete` | Remover webhooks | Admin e Owner | | `webhook:rotate` | Rotacionar a chave de assinatura de um webhook | Admin e Owner | **Visualizações** (`view`) | Scope | Permite | No JWT | |---|---|---| | `view:create` | Criar visualizações salvas (as da organização só por Admin/Owner) | Agente e acima | | `view:read` | Listar visualizações e contagens | Agente e acima | | `view:update` | Editar visualizações, respeitando a posse | Agente e acima | | `view:delete` | Remover visualizações, respeitando a posse | Agente e acima | **Satisfação** (`satisfaction`) | Scope | Permite | No JWT | |---|---|---| | `satisfaction:create` | Registrar a avaliação de um ticket em nome do solicitante | End-user e acima (só o próprio solicitante) | | `satisfaction:read` | Ler o relatório de satisfação (CSAT) sem os demais relatórios | Admin e Owner | **Outros** | Scope | Categoria | Permite | No JWT | |---|---|---|---| | `reports:read` | `report` | Ler os relatórios (`/v1/reports/*`) e as políticas/horário de SLA | Admin e Owner | | `file:upload` | `file` | Enviar arquivos (`POST /v1/uploads`) | End-user e acima | Não existe escopo para a [exportação de dados](#-exportação-de-dados): ela é exclusiva do papel **Owner** e não pode ser feita por API Key. ### Listagem de Scopes Disponíveis Para obter a lista completa de scopes via API, consulte a seção [🔍 Escopos Disponíveis](#-escopos-disponíveis). --- ## 📊 Rate Limiting O limite é um **balde de fichas** por cliente: uma capacidade que se esgota numa rajada e se repõe a uma taxa constante. Cada requisição gasta uma ficha. As respostas das rotas com limite saem com cabeçalhos de cota — leia-os desde já, porque é por eles que a sua integração vai conhecer o próprio limite. | Balde | Quem cai nele | |---|---| | `api-key` | Requisições com `X-API-Key` — uma fatia **por chave** | | `dashboard` | Sessões de agente no painel | | `account` | Teto agregado da organização, acima dos dois anteriores | | `ip` | Login, refresh, logout, redefinição de senha, verificação de e-mail e as rotas de [E-mail do Usuário Final](#-e-mail-do-usuário-final) — **por endereço** | | `anonymous` | Requisição autenticada sem organização no token (o token de organização legado) — por endereço | Cada chave de API tem o próprio balde: uma integração que dispara em excesso não consome a cota do painel dos agentes. Acima deles corre o teto da conta, que soma tudo. :::note O limite por cliente ainda não bloqueia Os baldes acima são medidos e os cabeçalhos saem, mas ainda não recusam requisições. Quando o bloqueio for ativado, o limite de cada cliente é o que vier em `X-RateLimit-Limit`, e os valores serão publicados aqui. Acima deles há um **teto de proteção** por organização (por endereço, nas rotas sem login), bem acima do uso normal, que já vale: uma rajada que passa dele recebe `429` com `Retry-After`. ::: **Headers, nas respostas das rotas com limite** — não só nas recusadas: ```http X-RateLimit-Limit: 600 X-RateLimit-Remaining: 120 X-RateLimit-Reset: 8 X-RateLimit-Resource: api-key ``` `X-RateLimit-Limit` é a capacidade do balde, `Remaining` o que sobra, `Reset` **quantos segundos** faltam para ele voltar a encher (não é um horário) e `Resource` **qual** balde foi consumido — com vários baldes, os números reportam sempre o de menor saldo, e saber contra qual você bateu é o que permite reagir. **Ao exceder: `429 Too Many Requests`**, com `Retry-After` em segundos e **sem corpo**. Espere o que o cabeçalho pedir; repetir na hora só mantém o balde vazio. **Não há fila**: o que excede o limite é recusado na hora, com `429`. Prepare a integração agora: trate `429` lendo `Retry-After` e recue com espera crescente. Quando o bloqueio por cliente ligar, nada muda no seu código. Os limites do código de [E-mail do Usuário Final](#-e-mail-do-usuário-final) são outra coisa: já valem, e o `429` deles vem com `Retry-After` **e** com o motivo no corpo, em `error_messages`. Numa chamada feita do navegador, o `Retry-After` e os `X-RateLimit-*` também podem ser lidos pelo seu código: a API os expõe por CORS. --- ## 📝 Endpoints ### 🎫 Tickets #### Criar Ticket ```http POST https://acme.pumahelp.com/api/v1/tickets Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `ticket:create` **Request Body:** ```json { "subject": "Problema com login", "priority": "high", "type": "question", "group_id": "uuid-do-grupo", "requester": { "name": "Cliente Nome", "email": "cliente@example.com" }, "comment": { "body": "Não consigo fazer login no sistema", "public": true, "author_id": "uuid-autor", "uploads": ["uuid-arquivo-1", "uuid-arquivo-2"], "client_id": "9f1c2b7a-4e5d-4c3b-8a1f-2d6e7f8a9b0c" }, "tags": ["login", "urgent"], "via": { "channel": "api" } } ``` | Campo | Tipo | Obrigatório | Descrição | |-------|------|-------------|-----------| | `subject` | string | Sim | Assunto do ticket | | `priority` | string | Não | Prioridade: `low`, `normal`, `high`, `urgent` (padrão: `normal`) | | `type` | string | Não | Tipo: `question`, `incident`, `problem`, `task` | | `group_id` | uuid | Não | ID do grupo responsável | | `requester` | object | Não | Dados do solicitante (se criar em nome de outro usuário) | | `requester.name` | string | Não | Nome do solicitante | | `requester.email` | string | Não | Email do solicitante | | `requester.external_id` | string | Não | ID externo do solicitante | | `requester.id` | uuid | Não | ID do solicitante | | `comment` | object | Sim | Primeiro comentário do ticket | | `comment.body` | string | Sim, salvo quando há `uploads` | Conteúdo do comentário. Uma mensagem só com anexo é válida: informe `uploads` e deixe `body` vazio ou ausente | | `comment.public` | boolean | Não | Se visível para o cliente (padrão: true) | | `comment.author_id` | uuid | Não | ID do autor (se diferente do usuário autenticado) | | `comment.uploads` | array[uuid] | Não | IDs de arquivos anexados | | `comment.client_id` | string | Não | Identificador gerado por você para tornar a retentativa segura — vai **dentro de `comment`**, não na raiz. Reenviar o mesmo valor para o mesmo solicitante devolve o ticket já criado. Ver [Reenvio seguro com `client_id`](#reenvio-seguro-com-client_id) | | `tags` | array[string] | Não | Tags para categorização | | `via` | object | Não | Canal de origem | | `via.channel` | string | Não | Canal: `api`, `widget`, `discord` | | `follow_up_of_public_id` | int64 | Não | Abre este ticket como **acompanhamento** de um ticket **fechado** da mesma organização (`404` se não existir ou, para um usuário final, se não for dele; `400` se não estiver fechado). Grupo, tipo, prioridade e canal ausentes herdam do original; sem `requester`, o solicitante também é o do original. O original recebe o evento `follow_up_created`. Ver [Ciclo de vida do ticket](#ciclo-de-vida-do-ticket) | **Response: `201 Created`** ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "public_id": 12345, "subject": "Problema com login", "status": "new", "created_at": "2025-01-01T10:00:00Z", "conversation_id": "uuid-conversa", "follow_up_of": null } ``` `follow_up_of` traz `{ "public_id", "subject" }` do ticket fechado quando o ticket criado é um acompanhamento, e vem `null` nos demais — o mesmo campo do [detalhe do ticket](#obter-ticket-por-id). --- #### Listar Tickets ```http GET https://acme.pumahelp.com/api/v1/tickets?page=1&page_size=25 Authorization: Bearer {token} ``` **Scope:** `ticket:read` **Query Parameters:** | Parâmetro | Tipo | Descrição | Obrigatório | |-----------|------|-----------|--------| | `page` | integer | Número da página | Sim | | `page_size` | integer | Itens por página — **entre 10 e 100** | Sim | | `query` | string | Filtros e busca (ver abaixo). Máx. 500 caracteres | Não | | `sort_by` | string | Campo para ordenação: `created_at`, `updated_at`, `priority`, `status`, `sla` | Não | | `sort_order` | string | Ordem: `asc` ou `desc` | Não | | `include` | array[string] | Campos extras: `assignee`, `requester`, `last_comment`, `tags`, `sla`, `unread` | Não | `include=unread` acrescenta `unread_count` a cada ticket: mensagens **públicas de outra pessoa** desde a última leitura de quem está pedindo. Sem o `include`, o campo vem `null` — o que é diferente de `0`, que significa "nada por ler". Marque a leitura com `POST /v1/tickets/{public_id}/read`. `sort_by=sla` ordena por urgência de prazo: usa o menor `due_at` entre os ciclos de SLA abertos e **não pausados** do ticket. Tickets sem ciclo aberto (ou com todos pausados) vão para o fim da lista, independente de `sort_order` — a ordem só decide entre os que têm prazo correndo. ##### Sintaxe do parâmetro `query` A busca é uma sequência de termos `chave:valor` separados por espaço, mais palavras soltas. **Como os termos se combinam:** - **Repetir a mesma chave é "ou"**: `status:open status:pending` traz tickets abertos **ou** pendentes. Vale para todas as chaves — `assignee:joao assignee:maria` traz os tickets dos dois. A exceção é `subject`, onde repetir significa "e" (o assunto precisa conter os dois textos), assim como acontece com as palavras da busca livre. - **Chaves diferentes são "e"**: `status:open priority:high` traz apenas os que são abertos **e** de prioridade alta. - **Não existem operadores `AND`/`OR` escritos**. Se você escrever `status:open AND priority:high`, a palavra `AND` é tratada como texto de busca. Use apenas espaços. - **Palavras sem `chave:` são busca textual** no assunto do ticket: `boleto atrasado` procura tickets cujo assunto contenha "boleto" e "atrasado". Para uma frase exata, use aspas: `"nota fiscal"`. - **Valores com espaço precisam de aspas** (simples ou duplas): `subject:'erro no login'`. **Chaves disponíveis:** | Chave | Valores | Exemplo | |-------|---------|---------| | `status` | `new`, `open`, `pending`, `solved`, `closed` | `status:open` | | `priority` | `low`, `normal`, `high`, `urgent` | `priority:urgent` | | `type` | `question`, `incident`, `problem`, `task` | `type:incident` | | `tags` | nome da tag. Várias na mesma aspa = "e" | `tags:financeiro`, `tags:'fiscal urgente'` | | `assignee` | UUID, e-mail, `external_id`, parte do nome, `me`, ou `none`/`null` para não atribuídos | `assignee:me`, `assignee:none` | | `requester` | UUID, e-mail, `external_id`, parte do nome, ou `me` | `requester:me` | | `subject` | texto contido no assunto | `subject:'erro no login'` | | `group` / `group_id` | nome do grupo / UUID do grupo | `group:suporte` | | `id` | número do ticket | `id:1042` | | `archived` | `true` ou `false` | `archived:true` | | `created` / `updated` | data (`2026-01-31`) ou período: `today`, `yesterday`, `last_24_hours`, `last_7_days`, `last_30_days` | `created:last_7_days` | **Datas** aceitam também os operadores `>`, `>=`, `<` e `<=`, tanto com data quanto com período: `created>=2026-01-01 created<=2026-01-31`, `createdremovido" } ``` **Response: `204 No Content`** :::warning O texto entre as tags `` é substituído por *████████* permanentemente. Não há como desfazer, nem como recuperar o conteúdo original depois. ::: **Você envia o corpo inteiro, não só o trecho.** O `body` da requisição é o comentário completo com o que deve sumir marcado — e o resto tem de continuar **idêntico** ao que está gravado. A rota suprime, não edita: qualquer palavra trocada, acrescentada ou removida fora de uma marcação faz a requisição falhar com `400`, e nada é gravado. Isso vale inclusive para a marcação. Acrescentar um link, mudar o `href` ou o `title` de um que já existe, ou reordenar as tags também devolve `400`. Perder marcação é permitido, porque é o que acontece quando uma supressão atravessa um elemento. Diferenças que **não** contam como edição (normalizações de espaço em branco): ` ` no lugar de espaço, espaços repetidos, e caracteres de largura zero (`U+200B`, `U+FEFF`). **Duas marcações.** | Marcação | Efeito | |---|---| | `texto` | O texto vira `████████`. | | atributo `redact` no elemento | O elemento some, o conteúdo fica. | O atributo existe porque às vezes o dado sensível é o endereço, não o texto: um link assinado, um token na query. As duas se combinam: ```html clique aqui → ████████ clique aqui → clique aqui ``` Um corpo **sem nenhuma marcação** é recusado com `400`: sem ela o pedido não é uma supressão. **A supressão fica registrada** na [linha do tempo do ticket](#linha-do-tempo-do-ticket-eventos) como `comment_redacted`, com quem fez e quando — nunca com o conteúdo removido. Reenviar um corpo que já está exatamente assim continua respondendo `204`, mas não registra uma segunda supressão. --- #### Suprimir Anexo ```http PUT https://acme.pumahelp.com/api/v1/tickets/{public_id}/comments/{comment_id}/attachment/{attachment_id}/redact Authorization: Bearer {token} ``` **Scope:** `comment:redact` **Response: `204 No Content`** O caso do print de tela com dado sensível. O anexo deixa de ser devolvido em qualquer listagem de ticket ou de comentário, e some da exportação de dados. A rota é **idempotente**: chamar de novo num anexo já suprimido continua respondendo `204` e não registra uma segunda supressão. :::warning **É ocultação, não exclusão.** O arquivo permanece no armazenamento, sob uma URL pública e sem assinatura — quem já a tiver continua alcançando o objeto. A API não expõe hoje nenhuma forma de apagar o arquivo de fato, e excluir a conta também não apaga: fale com o suporte se a remoção definitiva for necessária. Duas outras janelas ficam abertas: uma **exportação de dados gerada antes** da supressão continua disponível para download com o anexo dentro até expirar, e integrações que já leram o comentário podem ter guardado a URL. ::: Como na supressão de texto, fica registrado na linha do tempo do ticket como `attachment_redacted`, com quem fez e quando. --- #### Marcar Comentário como Privado ```http PUT https://acme.pumahelp.com/api/v1/tickets/{public_id}/comments/{comment_id}/make_private Authorization: Bearer {token} ``` **Scope:** `comment:update` **Response: `204 No Content`** :::note Torna o comentário invisível para usuários finais; só a equipe continua vendo. Comentários já entregues por e-mail ou por integrações externas não são retirados retroativamente do canal em que foram enviados. ::: --- ### 👥 Usuários #### Login ```http POST https://acme.pumahelp.com/api/v1/users/login Content-Type: application/json ``` **Rate limit:** balde `ip`, por endereço (ver [Rate Limiting](#-rate-limiting)) **Request Body:** ```json { "email": "usuario@example.com", "password": "sua-senha" } ``` | Campo | Tipo | Obrigatório | Descrição | |-------|------|-------------|-----------| | `email` | string | Sim | Email do usuário | | `password` | string | Sim | Senha do usuário | **Response: `200 OK`** ```json { "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "verified": true } ``` | Campo | Tipo | Descrição | |-------|------|-----------| | `access_token` | string | Token JWT para autenticação. A validade não é fixa: ao receber `401`, renove com o `refresh_token` em `POST /v1/users/refresh` | | `refresh_token` | string | Token para renovar o access_token | | `verified` | boolean | Se o email do usuário foi verificado | --- #### Renovar Token (Refresh) ```http POST https://acme.pumahelp.com/api/v1/users/refresh Content-Type: application/json ``` **Rate limit:** balde `ip`, por endereço (ver [Rate Limiting](#-rate-limiting)) **Request Body:** ```json { "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` **Response: `201 Created`** ```json { "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` --- #### Logout ```http POST https://acme.pumahelp.com/api/v1/users/logout Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `session:delete:own` **Rate limit:** balde `ip`, por endereço (ver [Rate Limiting](#-rate-limiting)) **Request Body:** ```json { "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` **Response: `204 No Content`** --- #### Criar Usuário ```http POST https://acme.pumahelp.com/api/v1/users Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `user:create` **Request Body:** ```json { "name": "João Silva", "email": "joao@example.com", "role": "agent", "external_id": "crm-user-123", "verified": false, "group_ids": ["uuid-grupo-1", "uuid-grupo-2"] } ``` | Campo | Tipo | Obrigatório | Descrição | |-------|------|-------------|-----------| | `name` | string | Sim | Nome completo do usuário | | `email` | string | Condicional | E-mail do usuário. Para `end-user` é o endereço de **contato**, sem credencial de acesso, e é opcional. Para a equipe (`agent`, `admin`, `owner`) é o login, e é obrigatório. É gravado e devolvido — já no `201` — em minúsculas, sem espaços nas pontas | | `role` | string | Sim | Role: `end-user`, `agent`, `admin`, `owner` | | `external_id` | string | Não | ID externo para integração com outros sistemas | | `verified` | boolean | Não | Marca o e-mail como verificado sem esperar confirmação — para quem já sabe que o endereço é da pessoa (padrão: false). O usuário final também fica verificado ao confirmar o e-mail com o código no widget | | `group_ids` | array[uuid] | Não | IDs dos grupos aos quais o usuário pertence. Staff (owner, admin, agent) sem `group_ids` cai no **grupo padrão** da organização — todo membro da equipe pertence a ele, sempre. `end-user` não pode ter grupos (`400`) | **Response: `201 Created`** ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "João Silva", "email": "joao@example.com", "role": "agent", "external_id": "crm-user-123", "verified": false } ``` **Response: `409 Conflict`** — o e-mail já identifica outra pessoa da organização: ```json { "error_messages": ["Este e-mail já está em uso."] } ``` **Response: `400 Bad Request`**: - alguém da equipe sem `email`: `"Para fazer parte da equipe, o usuário precisa de um e-mail."`; - alguém da equipe com o limite de agentes do plano atingido: `"Limite de agentes atingido. Esta organização permite no máximo N agente(s)."`. :::note Um e-mail, uma pessoa Dentro de uma organização, um endereço identifica uma pessoa só: o e-mail de contato de um cliente (`end-user`) ou o e-mail de login de alguém da equipe. Criar um cliente com o e-mail de um agente, ou um agente com o de um cliente, é recusado. A comparação ignora maiúsculas e espaços nas pontas, e contam também os visitantes do widget que confirmaram o e-mail. Para achar quem já usa o endereço, busque por ele em [Listar Usuários](#listar-usuários). Para dar acesso à equipe a um cliente que já existe, promova-o em [Atualizar Usuário](#atualizar-usuário) em vez de criar outro usuário. O mesmo e-mail pode existir em outra organização, e excluir um usuário libera o endereço. ::: --- #### Criar ou Atualizar Usuário (Upsert) ```http POST https://acme.pumahelp.com/api/v1/users/create_or_update Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `user:upsert` **Request Body:** ```json { "name": "João Silva", "email": "joao@example.com", "role": "agent", "external_id": "crm-user-123", "verified": false, "group_ids": ["uuid-grupo-1"] } ``` **Response: `201 Created`** — tanto ao criar quanto ao atualizar. ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "João Silva", "email": "joao@example.com", "role": "agent", "external_id": "crm-user-123", "verified": false } ``` :::tip Este endpoint procura primeiro um usuário com o e-mail informado — de contato ou de login, sem diferenciar maiúsculas — e, se não achar, pelo `external_id`. Se existir, atualiza; se não, cria. Um usuário excluído não é mais encontrado: o mesmo e-mail cria uma pessoa nova. Ideal para sincronizações com sistemas externos. ::: **Response: `409 Conflict`** — `"O ExternalId informado já pertence a outro usuário."`, quando o e-mail encontra uma pessoa e o `external_id` é de outra. Nada é alterado. Um `role` da equipe enviado para um cliente que já existe o promove, como em [Atualizar Usuário](#atualizar-usuário). As regras de [Criar Usuário](#criar-usuário) e de Atualizar Usuário valem aqui: alguém da equipe precisa de `email`, ocupa uma vaga de agente, e o e-mail é gravado em minúsculas. --- #### Impersonificar Usuário ```http POST https://acme.pumahelp.com/api/v1/users/impersonate Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `user:impersonate` **Request Body:** ```json { "email": "joao@example.com", "external_id": "crm-user-123", "name": "João Silva", "role": "end-user", "verified": false, "group_ids": ["uuid-grupo-1"] } ``` | Campo | Tipo | Obrigatório | Descrição | |-------|------|-------------|-----------| | `email` | string | Condicional | Email do usuário (obrigatório se `external_id` não fornecido). Trocar o endereço de contato de um `end-user` o deixa **não verificado** até nova confirmação, salvo `verified: true` na mesma requisição | | `external_id` | string | Condicional | ID externo (obrigatório se `email` não fornecido) | | `name` | string | Não | Nome completo (usado apenas se criar novo usuário) | | `role` | string | Não | Role: `end-user`, `agent`, `admin`, `owner` (padrão: `end-user`) | | `verified` | boolean | Não | Marca o e-mail como verificado — para quem já sabe que o endereço é da pessoa | | `group_ids` | array[uuid] | Não | Nova lista de grupos do usuário. O **grupo padrão nunca sai**: enviar `[]` mantém o usuário só nele. Omitir o campo não altera os grupos; promover um `end-user` a staff sem informar grupos o coloca no padrão | **Response: `200 OK`** ```json { "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 86400 } ``` | Campo | Tipo | Descrição | |-------|------|-----------| | `access_token` | string | Token JWT para autenticação em nome do usuário | | `token_type` | string | Sempre "Bearer" | | `expires_in` | integer | Tempo de expiração em segundos | :::tip Este endpoint permite gerar um access token em nome de um usuário específico. Se o usuário não existir, ele será criado automaticamente. Ideal para implementações de "Login as User" ou integração com sistemas de SSO (Single Sign-On). Um usuário excluído não é mais encontrado pelo e-mail: a chamada cria uma pessoa nova e devolve o token dela. ::: :::warning Este é um endpoint sensível que permite assumir a identidade de qualquer usuário. Disponível apenas para roles ADMIN e OWNER, ou via API Key com scope `user:impersonate`. ::: --- #### Listar Usuários ```http GET https://acme.pumahelp.com/api/v1/users?page=1&page_size=50 Authorization: Bearer {token} ``` **Scope:** `user:read` **Query Parameters:** | Parâmetro | Tipo | Descrição | Padrão | |-----------|------|-----------|--------| | `page` | integer | Número da página | — (obrigatório) | | `page_size` | integer | Itens por página — **entre 10 e 100** | — (obrigatório) | | `roles` | array[string] | Filtrar por roles (pode enviar múltiplos) | - | | `query` | string | Busca por nome ou e-mail — de contato (clientes) ou de login (equipe) | - | | `external_id` | string | Filtrar por ID externo | - | | `group_id` | uuid | Filtrar por grupo | - | | `active` | boolean | Filtrar por usuários ativos | - | | `include_guests` | boolean | Incluir os visitantes anônimos do widget | `false` | **Exemplo com múltiplas roles:** ``` GET /v1/users?roles=agent&roles=admin ``` **Response: `200 OK`** ```json { "count": 150, "users": [ { "id": "uuid", "name": "Maria Santos", "email": "maria@example.com", "role": "agent", "external_id": "crm-123", "verified": true, "guest": false } ] } ``` | Campo | Tipo | Descrição | |-------|------|-----------| | `verified` | boolean | E-mail confirmado | | `guest` | boolean | `true` para sessões anônimas do widget. Quem confirma o e-mail pelo widget passa a `false`: vira uma identidade duradoura | :::note Por padrão, a listagem traz as pessoas **identificáveis**: a equipe, os clientes e os convidados que têm e-mail, login ou `external_id`. Convidados **anônimos** — os visitantes do widget sem nenhum desses três — ficam de fora, e `include_guests=true` os inclui. Eles continuam visíveis como solicitantes nos próprios tickets. O visitante que confirmou o e-mail pelo widget aparece, com `guest: false`: é uma pessoa identificável, e é quem ocupa aquele endereço na organização. Um visitante anônimo a quem a equipe deu um e-mail também aparece, ainda com `guest: true`. Convidados anônimos que não deixaram rastro — nenhum ticket e nenhum comentário — são removidos automaticamente depois de um período de abandono. Quem conversou permanece: virou histórico do cliente. Quem tem e-mail, login ou `external_id` também permanece, assim como quem está no meio da confirmação de um e-mail. ::: --- #### Obter Próprio Perfil ```http GET https://acme.pumahelp.com/api/v1/users/me Authorization: Bearer {token} ``` **Scope:** `profile:read:own` **Response: `200 OK`** ```json { "id": "uuid", "name": "João Silva", "email": "joao@example.com", "role": "agent", "notes": "Notas internas sobre o usuário", "external_id": "crm-123", "verified": true, "created_at": "2025-01-01T10:00:00Z", "updated_at": "2025-01-15T14:30:00Z" } ``` --- #### Obter Usuário por ID ```http GET https://acme.pumahelp.com/api/v1/users/{user_id} Authorization: Bearer {token} ``` **Scope:** `user:read` **Response: `200 OK`** ```json { "id": "uuid", "name": "João Silva", "email": "joao@example.com", "role": "agent", "notes": "Notas internas", "external_id": "crm-123", "verified": true, "created_at": "2025-01-01T10:00:00Z", "updated_at": "2025-01-15T14:30:00Z" } ``` --- #### Atualizar Usuário ```http PUT https://acme.pumahelp.com/api/v1/users/{user_id} Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `user:update` **Request Body:** ```json { "name": "João Silva Santos", "role": "admin", "email": "joao.silva@example.com", "notes": "Promovido a admin em Jan/2025", "external_id": "crm-456", "verified": true, "group_ids": ["uuid-grupo-3"] } ``` | Campo | Tipo | Descrição | |-------|------|-----------| | `name` | string | Nome do usuário | | `role` | string | Nova role do usuário. Promover um `end-user` à equipe dá a ele acesso ao painel (veja abaixo) | | `email` | string | Para `end-user`, o e-mail de contato: trocá-lo deixa o usuário **não verificado** até nova confirmação, salvo `verified: true` na mesma requisição. Para a equipe, vale só para quem ainda não tem login: cria o login e envia o convite. Quem já tem login troca o próprio e-mail em [Alterar Email](#alterar-email), e aqui o campo é ignorado | | `notes` | string | Notas internas (não visível para end-users) | | `external_id` | string | ID externo, único na organização | | `verified` | boolean | Status de verificação de email | | `group_ids` | array[uuid] | Grupos do usuário | :::note Todos os campos são opcionais. Apenas os campos enviados serão atualizados. ::: **Promover um cliente à equipe** Mudar o `role` de um `end-user` para `agent`, `admin` ou `owner` dá a ele acesso ao painel: - o login é o e-mail de contato do cliente, ou o `email` enviado na mesma requisição; - a pessoa recebe o e-mail de boas-vindas, o mesmo do convite de um agente, com o link de confirmação quando o endereço ainda não foi confirmado; - a sessão de cliente que ela tiver no widget deixa de ser renovada: continua valendo até expirar, sempre como cliente, e depois o widget abre uma sessão nova; - sem `group_ids`, ela entra no grupo padrão; - ela passa a ocupar uma vaga de agente. Sem e-mail nenhum, a promoção é recusada com `400` (`"Para fazer parte da equipe, o usuário precisa de um e-mail."`). O e-mail pode ser dado antes, nesta mesma rota, ou na própria requisição da promoção. Com o limite de agentes do plano atingido, a promoção também é recusada com `400` (`"Limite de agentes atingido. …"`), e nada é alterado. **Response: `200 OK`** ```json { "id": "uuid", "name": "João Silva Santos", "email": "joao.silva@example.com", "role": "admin", "external_id": "crm-456", "verified": true } ``` **Response: `409 Conflict`** — `"Este e-mail já está em uso."` ou `"O ExternalId informado já pertence a outro usuário."`. Nada é alterado, nem os outros campos da mesma requisição. Reenviar o e-mail que o usuário já tem nunca é conflito. --- #### Deletar Usuário ```http DELETE https://acme.pumahelp.com/api/v1/users/{user_id} Authorization: Bearer {token} ``` **Scope:** `user:delete` **Response: `204 No Content`** A exclusão desativa e anonimiza o usuário: o login deixa de valer nesta organização, e o e-mail de contato e o `external_id` saem do cadastro — ficam livres para um novo cadastro. O histórico de tickets e comentários permanece. --- #### Enviar Email de Verificação ```http POST https://acme.pumahelp.com/api/v1/users/{user_id}/email/send/verification Authorization: Bearer {token} ``` **Scope:** `user:manage` **Response: `204 No Content`** :::note Envia um email para o usuário com link para verificar o endereço de email. ::: --- #### Verificar Email ```http POST https://acme.pumahelp.com/api/v1/users/email/verify?token={verification_token} ``` **Rate limit:** balde `ip`, por endereço (ver [Rate Limiting](#-rate-limiting)) **Sem autenticação necessária** **Query Parameters:** - `token` - Token de verificação enviado por email **Response: `204 No Content`** --- #### Reenviar Email de Verificação ```http POST https://acme.pumahelp.com/api/v1/users/email/resend/verification?email={user_email} ``` **Rate limit:** balde `ip`, por endereço (ver [Rate Limiting](#-rate-limiting)) **Sem autenticação necessária** **Query Parameters:** - `email` - Email do usuário que precisa reenviar verificação **Response: `204 No Content`** — sempre, exista ou não o endereço. O e-mail de verificação só sai para quem tem login e ainda não confirmou o endereço; o e-mail de contato de um cliente é confirmado pelo código do widget ([E-mail do Usuário Final](#-e-mail-do-usuário-final)). --- #### Mesclar Sessão (Merge Session) ```http POST https://acme.pumahelp.com/api/v1/users/merge-session Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `session:merge` **Request Body:** ```json { "target_auth_token": "token-jwt-do-usuario-autenticado" } ``` **Response: `204 No Content`** :::tip Use este endpoint para mesclar a sessão de um usuário convidado (guest) com um usuário autenticado. Útil quando um visitante cria tickets como guest e depois faz login/cadastro. ::: --- ### 👤 Usuários Convidados (Guests) Usuários convidados permitem criar sessões temporárias para visitantes que ainda não possuem uma conta completa no sistema. Ideal para permitir que potenciais clientes criem tickets sem necessidade de registro completo. #### Criar Usuário Convidado ```http POST https://acme.pumahelp.com/api/v2/guests X-API-Key: {authorization_token} Content-Type: application/json ``` **Scope:** `guest:create` **Caso de uso:** Permitir que visitantes do seu site ou aplicativo abram tickets de suporte sem criar uma conta completa, reduzindo fricção no processo de obter ajuda. **Request Body:** ```json { "name": "Visitante João" } ``` **Campos do Request:** | Campo | Tipo | Obrigatório | Descrição | |-------|------|-------------|-----------| | `name` | string | Não | Nome do visitante, até 100 caracteres. Sem ele, fica **"Convidado"** | **Response: `201 Created`** ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Visitante João", "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` **Campos do Response:** | Campo | Tipo | Descrição | |-------|------|-----------| | `id` | uuid | ID único do usuário convidado | | `name` | string | Nome do convidado | | `access_token` | string | Token JWT para autenticar requests em nome deste convidado | **Exemplo Completo:** ```bash # Criar guest user curl -X POST https://acme.pumahelp.com/api/v2/guests \ -H "X-API-Key: SEU_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Visitante da Landing Page"}' # Usar o access_token do guest para criar ticket curl -X POST https://acme.pumahelp.com/api/v1/tickets \ -H "Authorization: Bearer TOKEN_DO_GUEST" \ -H "Content-Type: application/json" \ -d '{ "subject": "Dúvida sobre o produto", "priority": "normal", "type": "question", "comment": { "body": "Gostaria de saber mais sobre os planos disponíveis", "public": true } }' ``` :::tip Use este recurso para implementar um formulário de contato avançado ou chat widget onde visitantes possam criar tickets sem criar conta. Se o que você quer é um chat pronto no seu site, o [widget](/docs/widget) já faz tudo isto — sessão, tempo real, anexos e avaliação — sem você escrever nenhuma dessas chamadas. ::: #### Criar Sessão de Convidado (v3) ```http POST https://acme.pumahelp.com/api/v3/guests X-API-Key: {authorization_token} Content-Type: application/json ``` **Scope:** `guest:create` Mesma finalidade do `/v2/guests`, com um par **access + refresh** no lugar do token único. O access dura horas e pode ficar só em memória; o refresh é o que preserva o histórico do visitante entre visitas. O `access_token` do `/v2/guests` vale **365 dias**. Em integrações novas, prefira a v3: o access é curto e pode ficar em memória, e só o refresh precisa ser persistido. **Request Body:** idêntico ao da v2. ```json { "name": "Visitante João" } ``` **Response: `201 Created`** ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Visitante João", "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expires_in": 3600 } ``` | Campo | Tipo | Descrição | |-------|------|-----------| | `id` | uuid | ID único do usuário convidado | | `name` | string | Nome do convidado | | `access_token` | string | Token JWT para autenticar as requisições | | `refresh_token` | string | Renove em `POST /v1/users/refresh` | | `expires_in` | int | Segundos de validade do `access_token`. Não presuma um valor fixo: use este campo, não o número do exemplo | :::note O `/v2/guests` continua disponível, com o mesmo contrato de resposta e a mesma validade de token. Nenhuma mudança é necessária em integrações existentes. ::: --- ### 📧 E-mail do Usuário Final Permite que um visitante anônimo informe o próprio e-mail para **continuar a conversa em outro dispositivo**. A prova de posse é um **código de 6 dígitos**, enviado para o endereço e digitado **na mesma sessão** que o pediu. O e-mail não traz link. **Declarar não grava nada no usuário.** O endereço só passa a ser dele quando a sessão que o declarou envia o código certo. Digitar o e-mail de outra pessoa, portanto, não dá acesso a nada: o código vai para a caixa dela, não para quem digitou. Os limites por endereço, abaixo, somam todas as sessões — por isso pedidos em excesso para um endereço podem atrasar em até 24 horas a confirmação legítima dele. #### Declarar E-mail ```http POST https://acme.pumahelp.com/api/v1/users/me/email Authorization: Bearer {token} Content-Type: application/json ``` **Rate limit:** balde `ip`, por endereço (ver [Rate Limiting](#-rate-limiting)), além dos limites próprios do código, abaixo Autentica como o próprio usuário — um convidado ou um usuário final identificado. **Só usuário final**: um token de agente, admin ou owner recebe `400` ("Este recurso é apenas para usuários finais."); quem tem login troca o e-mail em `PUT /v1/accounts/email`. **Request Body:** ```json { "email": "visitante@exemplo.com" } ``` | Campo | Tipo | Obrigatório | Descrição | |-------|------|-------------|-----------| | `email` | string | Sim | Endereço a confirmar, até 255 caracteres | **Response: `202 Accepted`** — o código foi gerado e enviado: ```json { "expires_in": 600, "resend_in": 60 } ``` | Campo | Tipo | Descrição | |-------|------|-----------| | `expires_in` | int | Segundos de validade do código | | `resend_in` | int | Segundos até poder pedir outro código para o mesmo endereço, nesta sessão | A resposta é a mesma quando o endereço já pertence a outra pessoa: a diferença só aparece na confirmação, para quem tem o código. O `202` diz que o código foi gerado, não que o e-mail chegou — se ele não chegar, peça outro depois de `resend_in`. **Um código novo pedido pela mesma sessão cancela o anterior dela**: nessa sessão, só o último e-mail recebido vale. **Response: `204 No Content`** — o endereço já é o e-mail confirmado deste mesmo usuário. Nada é enviado. **Limites do código.** Contam por sessão e por endereço — este, somando todas as sessões da organização: | Limite | Valor | Mensagem do `429` | |---|---|---| | Pedir de novo o mesmo endereço, na mesma sessão | 1 a cada 60 segundos | `"Aguarde para pedir outro código."` | | Códigos pedidos por uma sessão | 10 nas últimas 24 horas | `"Muitos códigos pedidos para este e-mail. Tente mais tarde."` | | Códigos enviados para um endereço | 5 na última hora | `"Muitos códigos pedidos para este e-mail. Tente mais tarde."` | | Tentativas de confirmação de um endereço, acertos incluídos | 10 nas últimas 24 horas | `"Muitas tentativas para este e-mail. Tente mais tarde."` | Trocar de endereço na mesma sessão não espera os 60 segundos: é a correção de um erro de digitação. **Response: `429 Too Many Requests`** — com `Retry-After`, em segundos, até o limite liberar: ```json { "error_messages": ["Aguarde para pedir outro código."] } ``` **Erros:** | Status | Quando | |---|---| | `400` | `email` ausente ou vazio (`"O e-mail é obrigatório."`), com mais de 255 caracteres (`"O e-mail não pode exceder 255 caracteres."`) ou em formato inválido (`"O formato do e-mail é inválido."`); token de alguém da equipe (`"Este recurso é apenas para usuários finais."`) | | `401` | sem sessão | | `429` | um dos limites acima | #### Confirmar E-mail ```http POST https://acme.pumahelp.com/api/v1/users/me/email/confirm Authorization: Bearer {token} Content-Type: application/json ``` **Rate limit:** balde `ip`, por endereço (ver [Rate Limiting](#-rate-limiting)), além dos limites próprios do código **Autentica com a mesma sessão que declarou o endereço.** O código só vale nela. Em outra sessão — outro navegador, outro aparelho — ele é recusado: `410` quando aquela sessão não tem código pendente, e `400` (`"Código incorreto."`) quando ela tem o seu próprio — o que gasta uma tentativa dele e uma das 10 diárias do endereço. Sem sessão, `401`. **Request Body:** ```json { "code": "123456" } ``` | Campo | Tipo | Obrigatório | Descrição | |-------|------|-------------|-----------| | `code` | string | Sim | Os 6 dígitos recebidos por e-mail. Espaços e hífens são ignorados: `"123 456"` vale | **Response: `200 OK`** ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Visitante João", "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expires_in": 3600 } ``` A sessão devolvida é a **da identidade dona daquele e-mail**. Se o endereço já pertencia a um usuário final da organização e quem confirmou é um convidado anônimo, a sessão anônima é absorvida por essa pessoa — é assim que "comecei no celular, terminei no computador" funciona: no segundo aparelho, declare o mesmo e-mail e confirme o código que chegar. Se o endereço não pertencia a ninguém, ele passa a ser do próprio usuário, marcado como verificado, e o usuário deixa de ser convidado (`guest: false`). **Substitua as credenciais guardadas pelas devolvidas.** Quando há absorção, o `id` é outro e a sessão anterior deixa de valer. **Erros.** A coluna "Conta tentativa" diz se o pedido gastou uma das 5 tentativas do código e uma das 10 diárias do endereço: | Status | Mensagem | Quando | Conta tentativa | |---|---|---|---| | `400` | `"Informe o código de 6 dígitos."` | o valor não tem 6 dígitos | Não | | `400` | `"Este recurso é apenas para usuários finais."` | token de alguém da equipe | Não | | `400` | `"Código incorreto."` | o código não confere | Sim | | `400` | `"Não foi possível vincular a sessão."` | a absorção pela pessoa dona do endereço falhou; peça um novo código | Sim — o código foi usado | | `401` | — | sem sessão | Não | | `409` | `"Este e-mail já está em uso."` | o código confere, mas o endereço não pode ser deste usuário (veja abaixo) | Sim — o código foi usado | | `410` | `"O código expirou ou já foi usado. Peça um novo código."` | não há código pendente nesta sessão, ou ele venceu, já foi usado, foi substituído por um mais novo ou esgotou as 5 tentativas | Não | | `429` | `"Muitas tentativas para este e-mail. Tente mais tarde."` | o endereço chegou a 10 tentativas nas últimas 24 horas; vem com `Retry-After` | Não | :::note A absorção só acontece na direção anônimo → identificado. Duas identidades duradouras nunca se fundem em silêncio: quem já confirmou um e-mail, ou foi identificado pelo seu site, recebe `409` ao confirmar um endereço que é de outra pessoa. O mesmo acontece quando o endereço é o login de alguém da equipe. Um código errado responde `400` em qualquer caso: a API só revela que um endereço tem dono depois que o código prova a posse da caixa. ::: --- ### 👥 Grupos #### Criar Grupo ```http POST https://acme.pumahelp.com/api/v1/groups Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `group:create` **Request Body:** ```json { "name": "Suporte Técnico", "description": "Equipe responsável por suporte técnico" } ``` | Campo | Tipo | Obrigatório | Descrição | |-------|------|-------------|-----------| | `name` | string | Sim | Nome do grupo | | `description` | string | Não | Descrição do grupo | **Response: `201 Created`** ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Suporte Técnico", "description": "Equipe responsável por suporte técnico", "default": false } ``` --- #### Listar Grupos ```http GET https://acme.pumahelp.com/api/v1/groups?page=1&page_size=50 Authorization: Bearer {token} ``` **Scope:** `group:read` **Query Parameters:** | Parâmetro | Tipo | Descrição | Obrigatório | |-----------|------|-----------|-------------| | `page` | integer | Número da página | Sim | | `page_size` | integer | Itens por página — **entre 10 e 100** | Sim | | `query` | string | Busca por **nome do grupo** | Não | | `user_id` | uuid | Filtrar grupos que contêm um usuário específico | Não | **Response: `200 OK`** ```json { "count": 3, "groups": [ { "id": "uuid", "name": "Suporte Técnico", "description": "Equipe responsável por suporte técnico", "default": false }, { "id": "uuid", "name": "Vendas", "description": "Equipe de vendas", "default": true } ] } ``` --- #### Listar Grupos com Usuários ```http GET https://acme.pumahelp.com/api/v1/groups/users?page=1&page_size=50 Authorization: Bearer {token} ``` **Scope:** `group:read` **Query Parameters:** | Parâmetro | Tipo | Descrição | Obrigatório | |-----------|------|-----------|-------------| | `page` | integer | Número da página | Sim | | `page_size` | integer | Itens por página — **entre 10 e 100** | Sim | | `query` | string | Busca por nome do grupo **ou por nome e e-mail dos membros** | Não | | `user_id` | uuid | Filtrar grupos que contêm um usuário específico | Não | **Response: `200 OK`** ```json { "count": 3, "groups": [ { "id": "uuid", "name": "Suporte Técnico", "description": "Equipe responsável por suporte técnico", "default": false, "users": [ { "id": "uuid", "name": "Maria Santos", "email": "maria@example.com", "role": "agent", "external_id": null, "verified": true }, { "id": "uuid", "name": "João Silva", "email": "joao@example.com", "role": "agent", "external_id": null, "verified": true } ] } ] } ``` :::tip Retorna uma **prévia de até 3 membros por grupo**, sem indicação de truncamento; use `query` para localizar os demais por nome e e-mail. Não use este endpoint para listar todo mundo de um grupo — um grupo de 20 agentes aparece aqui com 3. ::: --- #### Obter Grupo por ID ```http GET https://acme.pumahelp.com/api/v1/groups/{group_id} Authorization: Bearer {token} ``` **Scope:** `group:read` **Response: `200 OK`** ```json { "id": "uuid", "name": "Suporte Técnico", "description": "Equipe responsável por suporte técnico", "default": false } ``` --- #### Atualizar Grupo ```http PUT https://acme.pumahelp.com/api/v1/groups/{group_id} Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `group:update` **Request Body:** ```json { "name": "Suporte Técnico Nível 2", "description": "Equipe de suporte avançado" } ``` | Campo | Tipo | Descrição | |-------|------|-----------| | `name` | string | Novo nome do grupo | | `description` | string | Nova descrição | :::note Todos os campos são opcionais. Apenas os campos enviados serão atualizados. ::: **Response: `204 No Content`** --- #### Deletar Grupo ```http DELETE https://acme.pumahelp.com/api/v1/groups/{group_id} Authorization: Bearer {token} ``` **Scope:** `group:delete` **Response: `204 No Content`** :::note Para adicionar ou remover usuários de um grupo, use o endpoint de atualização de usuário (`PUT /v1/users/{user_id}`) enviando o campo `group_ids`. Apenas os grupos padrão não podem ser removidos. ::: --- ### 🤖 Macros #### Criar Macro ```http POST https://acme.pumahelp.com/api/v1/macros Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `macro:create` **Request Body:** ```json { "title": "Resposta Padrão - Problema de Login", "description": "Resposta automática para problemas de login", "type": "group", "group_ids": ["uuid-grupo-1", "uuid-grupo-2"], "actions": [ { "field": "status", "value": "pending" }, { "field": "priority", "value": "high" }, { "field": "comment_value", "value": "Por favor, tente limpar o cache do navegador e fazer login novamente." } ] } ``` | Campo | Tipo | Obrigatório | Descrição | |-------|------|-------------|-----------| | `title` | string | Sim | Título da macro | | `description` | string | Não | Descrição da macro | | `type` | string | Sim | Tipo: `user` (usuário) ou `group` (grupo) | | `group_ids` | array[uuid] | Não | IDs dos grupos que podem usar esta macro. Omitir numa macro de grupo usa o **grupo padrão** da organização | | `actions` | array[object] | Sim | Lista de ações a executar | | `actions[].field` | string | Sim | Campo a modificar: `comment_value`, `status`, `type`, `priority` | | `actions[].value` | any | Sim | Valor a aplicar (tipo depende do field) | **Response: `201 Created`** ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "title": "Resposta Padrão - Problema de Login", "description": "Resposta automática para problemas de login", "type": "group", "group_ids": ["uuid-grupo-1"], "actions": [ { "field": "status", "value": "pending" }, { "field": "comment_value", "value": "Por favor, tente limpar o cache..." } ], "created_at": "2025-01-01T10:00:00Z" } ``` --- #### Listar Macros ```http GET https://acme.pumahelp.com/api/v1/macros?page=1&page_size=50 Authorization: Bearer {token} ``` **Scope:** `macro:read` **Query Parameters:** | Parâmetro | Tipo | Descrição | Obrigatório | |-----------|------|-----------|-------------| | `page` | integer | Número da página | Sim | | `page_size` | integer | Itens por página — **entre 10 e 100** | Sim | **Response: `200 OK`** ```json { "count": 5, "macros": [ { "id": "uuid", "title": "Resposta Padrão - Problema de Login", "description": "Resposta automática para problemas de login", "type": "user", "group_ids": [], "actions": [], "created_at": "2025-01-01T10:00:00Z" } ] } ``` --- #### Obter Macro por ID ```http GET https://acme.pumahelp.com/api/v1/macros/{macro_id} Authorization: Bearer {token} ``` **Scope:** `macro:read` **Response: `200 OK`** ```json { "id": "uuid", "title": "Resposta Padrão - Problema de Login", "description": "Resposta automática para problemas de login", "type": "user", "group_ids": ["uuid-grupo-1"], "actions": [ { "field": "status", "value": "pending" }, { "field": "priority", "value": "high" } ], "created_at": "2025-01-01T10:00:00Z" } ``` --- #### Atualizar Macro ```http PUT https://acme.pumahelp.com/api/v1/macros/{macro_id} Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `macro:update` **Request Body:** ```json { "title": "Resposta Padrão - Login Atualizada", "description": "Nova descrição", "type": "group", "group_ids": ["uuid-grupo-3"], "actions": [ { "field": "status", "value": "solved" } ] } ``` :::note Todos os campos são opcionais. Apenas os campos enviados serão atualizados. ::: **Response: `200 OK`** ```json { "id": "uuid", "title": "Resposta Padrão - Login Atualizada", "description": "Nova descrição", "type": "group", "group_ids": ["uuid-grupo-3"], "actions": [ { "field": "status", "value": "solved" } ], "created_at": "2025-01-01T10:00:00Z" } ``` --- #### Deletar Macro ```http DELETE https://acme.pumahelp.com/api/v1/macros/{macro_id} Authorization: Bearer {token} ``` **Scope:** `macro:delete` **Response: `204 No Content`** --- ### 👁️ Visualizações Visualizações são filtros de tickets salvos com nome. Cada uma guarda uma `query` na mesma sintaxe do filtro de listagem (`status:open assignee:me …`) e, opcionalmente, uma ordenação. Existem dois escopos: | Escopo | Quem vê | Quem cria/edita/apaga | Limite | |--------|---------|-----------------------|--------| | `organization` | Todos os agentes da organização | Owner e Admin | 5 por organização | | `personal` | Apenas quem criou | O próprio usuário (Owner, Admin ou Agente) | 5 por usuário | Toda organização nasce com 5 visualizações padrão no escopo `organization` ("Seus tickets sem resolução", "Tickets não atribuídos", "Todos os tickets sem resolução", "Tickets resolvidos recentemente" e "Tickets pendentes"). Elas são comuns — podem ser renomeadas, alteradas ou apagadas por Owner/Admin. Macros como `assignee:me` são resolvidas para quem está chamando, então uma visualização da organização mostra (e conta) tickets diferentes para cada agente. #### Listar Visualizações ```http GET https://acme.pumahelp.com/api/v1/views Authorization: Bearer {token} ``` **Scope:** `view:read` Retorna as visualizações da organização mais as pessoais do usuário autenticado, ordenadas por `position`. **Response: `200 OK`** ```json { "count": 2, "views": [ { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Seus tickets sem resolução", "query": "assignee:me status:new status:open status:pending", "sort_by": null, "sort_order": null, "scope": "organization", "position": 1, "created_at": "2025-01-01T10:00:00Z", "updated_at": null }, { "id": "660e8400-e29b-41d4-a716-446655440001", "name": "Urgentes do meu grupo", "query": "priority:urgent group:suporte", "sort_by": "created_at", "sort_order": "desc", "scope": "personal", "position": 1, "created_at": "2025-01-02T10:00:00Z", "updated_at": null } ] } ``` --- #### Contagem por Visualização ```http GET https://acme.pumahelp.com/api/v1/views/counts Authorization: Bearer {token} ``` **Scope:** `view:read` Retorna quantos tickets cada visualização listaria **para o usuário autenticado** — a mesma visibilidade da listagem de tickets (agentes veem apenas os tickets dos seus grupos). **Response: `200 OK`** ```json { "counts": [ { "view_id": "550e8400-e29b-41d4-a716-446655440000", "count": 12 }, { "view_id": "660e8400-e29b-41d4-a716-446655440001", "count": 3 } ] } ``` --- #### Criar Visualização ```http POST https://acme.pumahelp.com/api/v1/views Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `view:create` **Request Body:** ```json { "name": "Urgentes do meu grupo", "query": "priority:urgent group:suporte", "sort_by": "created_at", "sort_order": "desc", "scope": "personal" } ``` | Campo | Tipo | Obrigatório | Descrição | |-------|------|-------------|-----------| | `name` | string | Sim | Nome exibido (máx. 80). Único dentro do escopo | | `query` | string | Não | Filtro na sintaxe de `GET /v1/tickets?query=` (máx. 500). Vazio = todos os tickets visíveis | | `sort_by` | string | Não | `status`, `priority`, `created_at`, `updated_at` ou `sla` (mesmos campos e mesma regra da listagem de tickets) | | `sort_order` | string | Não | `asc` ou `desc` | | `scope` | string | Sim | `organization` (somente Owner/Admin) ou `personal` | **Response: `201 Created`** — mesmo formato de um item da listagem. **Erros:** - `400` — limite de 5 atingido no escopo, nome já usado no escopo, ou campo inválido - `401` — agente tentando criar uma visualização `organization` --- #### Atualizar Visualização ```http PUT https://acme.pumahelp.com/api/v1/views/{view_id} Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `view:update` Todos os campos são opcionais; só os enviados são alterados. Envie `sort_by: ""` para remover a ordenação. ```json { "name": "Urgentes", "query": "priority:urgent status:open" } ``` **Response: `200 OK`** — a visualização atualizada. **Erros:** - `401` — agente tentando alterar uma visualização `organization` - `404` — visualização inexistente ou pessoal de outro usuário --- #### Deletar Visualização ```http DELETE https://acme.pumahelp.com/api/v1/views/{view_id} Authorization: Bearer {token} ``` **Scope:** `view:delete` Mesmas regras de posse do `PUT`. **Response: `204 No Content`** --- ### 🔗 Webhooks #### Criar Webhook ```http POST https://acme.pumahelp.com/api/v1/webhooks Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `webhook:create` **Request Body:** ```json { "name": "Notificação Slack", "url": "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXX", "events": ["ticket.created", "ticket.updated", "user.created"] } ``` | Campo | Tipo | Obrigatório | Descrição | |-------|------|-------------|-----------| | `name` | string | Sim | Nome do webhook | | `url` | string | Sim | URL que receberá as notificações | | `events` | array[string] | Sim | Eventos a monitorar: `ticket.created`, `ticket.updated`, `user.created`, `user.updated`, `user.deleted` | **Response: `201 Created`** ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Notificação Slack", "url": "https://hooks.slack.com/services/...", "events": ["ticket.created", "ticket.updated", "user.created"], "created_at": "2025-01-01T10:00:00Z", "key": "whk_live_a1b2c3d4e5_secretkeyhereXXXXXXXX" } ``` :::note Cada organização pode criar no máximo **5 webhooks**. Se você atingir esse limite, será necessário deletar um webhook existente antes de criar um novo. ::: :::important A `key` é gerada automaticamente durante a criação do webhook e é usada para assinar as requisições enviadas. Guarde-a de forma segura, pois será necessária para validar a autenticidade dos webhooks recebidos. ::: --- #### Listar Webhooks ```http GET https://acme.pumahelp.com/api/v1/webhooks?page=1&page_size=50 Authorization: Bearer {token} ``` **Scope:** `webhook:read` **Query Parameters:** | Parâmetro | Tipo | Descrição | Obrigatório | |-----------|------|-----------|-------------| | `page` | integer | Número da página | Sim | | `page_size` | integer | Itens por página — **entre 10 e 100** | Sim | **Response: `200 OK`** ```json { "count": 2, "webhooks": [ { "id": "uuid", "name": "Notificação Slack", "url": "https://hooks.slack.com/services/...", "events": ["ticket.created", "ticket.updated"], "created_at": "2025-01-01T10:00:00Z", "key": "whk_live_a1b2c3d4e5_secretkeyhereXXXXXXXX" } ] } ``` --- #### Obter Webhook ```http GET https://acme.pumahelp.com/api/v1/webhooks/{webhook_id} Authorization: Bearer {token} ``` **Scope:** `webhook:read` **Response: `200 OK`** ```json { "id": "uuid", "name": "Notificação Slack", "url": "https://hooks.slack.com/services/...", "events": ["ticket.created", "ticket.updated", "user.created"], "created_at": "2025-01-01T10:00:00Z", "key": "whk_live_a1b2c3d4e5_secretkeyhereXXXXXXXX" } ``` --- #### Atualizar Webhook ```http PUT https://acme.pumahelp.com/api/v1/webhooks/{webhook_id} Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `webhook:update` **Request Body:** ```json { "name": "Notificação Slack Atualizada", "url": "https://hooks.slack.com/services/UPDATED", "events": ["ticket.created", "user.deleted"] } ``` | Campo | Tipo | Descrição | |-------|------|-----------| | `name` | string | Novo nome do webhook | | `url` | string | Nova URL | | `events` | array[string] | Novos eventos | :::note Ao contrário dos demais endpoints de atualização, **`url`, `events` e `name` são obrigatórios** — reenvie o objeto inteiro. Um corpo parcial devolve `400` listando os três. ::: **Response: `204 No Content`** --- #### Deletar Webhook ```http DELETE https://acme.pumahelp.com/api/v1/webhooks/{webhook_id} Authorization: Bearer {token} ``` **Scope:** `webhook:delete` **Response: `204 No Content`** As entregas ainda pendentes deste webhook são descartadas junto. --- #### Rotacionar Chave do Webhook ```http PUT https://acme.pumahelp.com/api/v1/webhooks/rotate/{webhook_id} Authorization: Bearer {token} ``` **Scope:** `webhook:rotate` **Descrição:** Gera uma nova chave de assinatura para o webhook. A chave antiga é invalidada imediatamente. :::warning Após rotacionar a chave, atualize logo em seguida a aplicação que recebe os webhooks. Toda tentativa de entrega feita depois da rotação é assinada com a chave nova — inclusive a repetição de eventos anteriores. As entregas que a sua aplicação recusar nesse meio-tempo (com `401`, por exemplo) são [reenviadas](#entrega-retentativas-e-ordem) por até cerca de um dia e passam a ser aceitas assim que ela usar a chave nova. ::: **Response: `200 OK`** ```json { "key": "whk_live_x9y8z7w6v5_newsecretkeyXXXXXXXX" } ``` | Campo | Tipo | Descrição | |-------|------|-----------| | `key` | string | Nova chave gerada (com prefixo `whk_live_`) | **Exemplo de validação HMAC em Node.js:** ```javascript const crypto = require('crypto'); const express = require('express'); const app = express(); // O corpo BRUTO precisa ser guardado antes de virar objeto — é sobre ele que a assinatura é feita. app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } })); function validateWebhook(rawBody, signature, timestamp, webhookKey) { // Anti-replay: a assinatura é refeita a cada tentativa, com o horário daquela tentativa. // Uma entrega que chegue muito atrasada é uma repetição, não uma retentativa legítima. const idadeEmSegundos = Math.abs(Date.now() / 1000 - Number(timestamp)); if (!Number.isFinite(idadeEmSegundos) || idadeEmSegundos > 300) return false; const stringToSign = `${timestamp}.${rawBody}`; const expected = crypto.createHmac('sha256', webhookKey).update(stringToSign).digest('base64'); // Comparação em tempo constante: `===` vaza, pelo tempo, quantos bytes bateram. const a = Buffer.from(signature ?? '', 'utf8'); const b = Buffer.from(expected, 'utf8'); return a.length === b.length && crypto.timingSafeEqual(a, b); } app.post('/webhooks/pumahelp', (req, res) => { const ok = validateWebhook( req.rawBody, req.header('X-Pumahelp-Webhook-Signature'), req.header('X-Pumahelp-Webhook-Timestamp'), process.env.PUMAHELP_WEBHOOK_KEY, ); if (!ok) return res.sendStatus(400); // Responda rápido: a entrega é reenviada se demorar. O trabalho pesado vai para uma fila sua. res.sendStatus(200); }); ``` :::danger Valide sobre o corpo bruto, não sobre o objeto `JSON.stringify` de um corpo já convertido em objeto **não** reproduz a string que foi assinada: a ordem das chaves, os espaços e o escaping se perdem no caminho. O sintoma é falha de validação em payloads não triviais. ::: #### Formato do payload ```http POST {url do webhook} Content-Type: application/json; charset=utf-8 User-Agent: PumaHelp-Webhooks/1.0 X-Pumahelp-Webhook-Timestamp: 1757764800 X-Pumahelp-Webhook-Signature: base64(HMAC-SHA256(key, "{timestamp}.{corpo bruto}")) X-Pumahelp-Webhook-Event-Id: 0d4c5f5e-6a7b-4c8d-9e0f-1a2b3c4d5e6f X-Pumahelp-Webhook-Attempt: 1 ``` | Cabeçalho | Descrição | |---|---| | `X-Pumahelp-Webhook-Timestamp` | Horário **da tentativa**, em segundos Unix. Entra na assinatura | | `X-Pumahelp-Webhook-Signature` | Base64 do HMAC-SHA256, com a chave do webhook, sobre `{timestamp}.{corpo bruto}` | | `X-Pumahelp-Webhook-Event-Id` | O `id` do evento, igual ao do corpo e o mesmo em todas as tentativas | | `X-Pumahelp-Webhook-Attempt` | Número da tentativa: `1` na primeira, até `8` | | `User-Agent` | `PumaHelp-Webhooks/1.0` | ```json { "id": "0d4c5f5e-6a7b-4c8d-9e0f-1a2b3c4d5e6f", "type": "ticket.updated", "time": "2026-09-13T12:00:00.1234567+00:00", "event_version": "1.0", "event": { "changes": [ { "field_name": "status", "previous_value": "open", "value": "pending" } ], "comment": { "id": "5b7e…", "body": "

Pode confirmar se o erro continua?

", "public": true, "author": { "id": "3f9a…", "name": "Maria Santos", "role": "agent", "external_id": null }, "created_at": "2026-09-13T12:00:00.1234567Z", "conversation_id": "9c1d…" } }, "detail": { "id": "550e8400-e29b-41d4-a716-446655440000", "status": "pending", "public_id": 12345, "subject": "Problema com login", "updated_at": "2026-09-13T12:00:00.1234567Z", "created_at": "2026-09-12T18:30:00.1234567Z", "archived_at": null, "organization_id": "7a2c…", "requester_id": "c0d1…", "assignee_id": "3f9a…", "group_id": "a1b2…", "type": "question", "priority": "high", "tags": ["login"], "via": { "channel": "api" }, "follow_up_of": null } } ``` | Campo | Descrição | |---|---| | `id` | Identificador do **evento**. É o mesmo em todas as tentativas de entrega — use-o para descartar repetições | | `type` | Um dos cinco [eventos](#eventos-de-webhook) | | `time` | Quando o evento aconteceu, em ISO 8601 com até 7 casas de fração de segundo. **Não é** o `X-Pumahelp-Webhook-Timestamp`, que é o horário da tentativa de entrega | | `event_version` | `1.0` | | `detail` | O **recurso** — o ticket ou o usuário — como estava no instante do evento | | `event` | O que o evento trouxe, ou `null`: as mudanças em `changes` (`field_name`, `previous_value`, `value`) e, nos eventos de ticket, o comentário em `comment` | Tudo é serializado no momento do evento, em snake_case. **`detail` nos eventos de ticket** (`ticket.created` e `ticket.updated`): | Campo | Descrição | |---|---| | `id`, `public_id` | Identificador interno e número do ticket | | `status`, `type`, `priority` | Os mesmos valores do [detalhe do ticket](#obter-ticket-por-id) | | `subject`, `tags` | Assunto e nomes das tags | | `created_at`, `updated_at`, `archived_at` | Datas em UTC; `archived_at` nulo se o ticket não está arquivado | | `organization_id`, `requester_id`, `assignee_id`, `group_id` | Os ids ligados ao ticket; nulos quando não há | | `via.channel` | Canal de origem: `api`, `widget` ou `discord` | | `follow_up_of` | `{ "public_id", "subject" }` do ticket **fechado** que este continua, quando o ticket é um acompanhamento; `null` nos demais. Sempre presente | **`event.comment`** traz o comentário que veio junto com o evento: `id`, `body` (HTML), `public`, `author` e, quando há anexos, `uploads` (`id`, `file_name`, `content_type`, `size`, `content_url`). `body` vem vazio (`""`) quando o comentário tem só anexos. Nos eventos de usuário, `detail` é o usuário, e `event` traz `changes` no `user.updated` e é `null` nos demais. #### Entrega, retentativas e ordem A entrega é **pelo menos uma vez** e **sem garantia de ordem**. Um receptor correto deduplica pelo `id` e ordena pelo `time`. - **Se a mudança foi salva, a entrega existe**; se falhou, nenhuma entrega é criada. Cada webhook inscrito recebe a própria entrega, e os destinos são tratados em separado: um destino lento ou fora do ar não atrasa os demais. - **Sucesso é qualquer `2xx`.** Qualquer outra resposta, timeout (**30 s**) ou falha de rede conta como tentativa falha — inclusive `4xx`. Cada tentativa é **uma** requisição. - **Até 8 tentativas**, com estas esperas entre elas: 5 s, 5 min, 30 min, 2 h, 5 h, 10 h e 10 h — cerca de 27,6 horas no total, com uma variação de até 20% em cada espera. Depois da oitava falha a entrega não é mais repetida. - **`410 Gone` encerra a entrega na hora**, sem novas tentativas. Use-o para um evento que o seu sistema nunca vai aceitar; os próximos eventos continuam chegando. - **`Retry-After` é respeitado** nas respostas `429` e `503`, em segundos ou como data HTTP: a próxima tentativa sai no maior valor entre a espera do cronograma e o pedido, com teto de 10 horas. - **Responda `2xx` também aos eventos que você ignora.** Qualquer outra resposta faz a entrega ser repetida. - **A assinatura é refeita a cada tentativa**, com o horário daquela tentativa em `X-Pumahelp-Webhook-Timestamp`. Não compare esse cabeçalho com o `time` do corpo: numa retentativa eles diferem por minutos ou horas. Valide a assinatura pelo cabeçalho (janela de ±5 min é suficiente) e leia a data do evento pelo corpo. - **O corpo é o do instante do evento**, byte a byte o mesmo em todas as tentativas. Uma retentativa horas depois entrega o ticket como ele estava quando o evento ocorreu, não como está agora. - **Não há garantia de ordem.** Duas atualizações do mesmo ticket podem chegar invertidas — a primeira falhou e foi reenviada depois da segunda. Ordene pelo `time` do corpo e descarte o que for mais antigo do que o último estado que você já aplicou. - **Pode haver repetição.** O mesmo evento pode chegar duas vezes — por exemplo, quando o seu servidor respondeu `2xx` mas a resposta se perdeu. Deduplique pelo `id` do corpo ou pelo cabeçalho `X-Pumahelp-Webhook-Event-Id`. - **Excluir o webhook descarta as entregas pendentes** dele. - `ticket.updated` também é emitido pelo **sistema**, no fechamento e na resolução automáticos. - Não há endpoint para consultar ou reenviar entregas. Se o seu endpoint ficou fora por mais tempo que a janela de tentativas, leia o estado atual pela API (por exemplo, `GET /v1/tickets?query=updated>=2026-09-12`). --- ### 🔑 API Keys #### Listar API Keys ```http GET https://acme.pumahelp.com/api/v1/keys?page=1&page_size=50 Authorization: Bearer {token} ``` **Scope:** `apikey:read` **Query Parameters:** | Parâmetro | Tipo | Descrição | Obrigatório | |-----------|------|-----------|-------------| | `page` | integer | Número da página | Sim | | `page_size` | integer | Itens por página — **entre 10 e 100** | Sim | **Response: `200 OK`** ```json { "count": 3, "keys": [ { "id": "550e8400-e29b-41d4-a716-446655440000", "prefix": "rk_live_", "name": "Integration - CRM", "description": "API Key para sincronização com CRM", "scopes": ["ticket:create", "ticket:read", "user:upsert"], "key_lookup": "a1b2c3d4e5", "created_at": "2025-01-01T10:00:00Z" } ] } ``` | Campo | Tipo | Descrição | |-------|------|-----------| | `id` | uuid | ID da API Key | | `prefix` | string | Prefixo da chave para identificação rápida | | `name` | string | Nome da API Key | | `description` | string | Descrição | | `scopes` | array[string] | Escopos atribuídos | | `key_lookup` | string | Identificador parcial da chave, usado para localizá-la nas listagens | | `created_at` | datetime | Data de criação | --- #### Criar API Key ```http POST https://acme.pumahelp.com/api/v1/keys Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `apikey:create` **Request Body:** ```json { "name": "Integration - CRM", "description": "Para sincronização automática de tickets e usuários", "scopes": ["ticket:create", "ticket:read", "user:upsert"] } ``` | Campo | Tipo | Obrigatório | Descrição | |-------|------|-------------|-----------| | `name` | string | Sim | Nome da API Key | | `description` | string | Não | Descrição do uso da chave | | `scopes` | array[string] | Sim | Lista de escopos permitidos | **Response: `201 Created`** ```json { "key": "rk_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0" } ``` :::caution A chave completa (`key`) é exibida **apenas uma vez**! Guarde-a imediatamente em local seguro. Após esta resposta, você só verá o `prefix` e `key_lookup` ao listar as chaves. ::: --- #### Atualizar API Key ```http PUT https://acme.pumahelp.com/api/v1/keys/{key_id} Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `apikey:update` **Request Body:** ```json { "name": "Integration - CRM v2", "description": "Atualizado para nova integração", "scopes": ["ticket:create", "ticket:read", "ticket:update", "user:upsert"] } ``` | Campo | Tipo | Descrição | |-------|------|-----------| | `name` | string | Novo nome | | `description` | string | Nova descrição | | `scopes` | array[string] | Novos escopos. Seguem as regras da criação: a lista não pode ser vazia, e um scope fora do [catálogo](#catálogo-de-escopos) ou repetido devolve `400` | :::note Todos os campos são opcionais. Apenas os campos enviados serão atualizados. Enviar `description` como string vazia (`""`) limpa a descrição; omitir o campo a mantém. ::: **Response: `200 OK`** ```json { "id": "uuid", "prefix": "rk_live_", "name": "Integration - CRM v2", "description": "Atualizado para nova integração", "scopes": ["ticket:create", "ticket:read", "ticket:update", "user:upsert"], "key_lookup": "a1b2c3d4e5", "created_at": "2025-01-01T10:00:00Z" } ``` --- #### Rotacionar API Key ```http PUT https://acme.pumahelp.com/api/v1/keys/rotate/{key_id} Authorization: Bearer {token} ``` **Scope:** `apikey:rotate` **Response: `200 OK`** ```json { "key": "rk_live_x9y8z7w6v5u4t3s2r1q0p9o8n7m6l5k4j3i2h1g0" } ``` :::warning Ao rotacionar, a chave antiga torna-se inválida imediatamente. Atualize seus sistemas com a nova chave antes de rotacionar! ::: --- #### Deletar API Key ```http DELETE https://acme.pumahelp.com/api/v1/keys/{key_id} Authorization: Bearer {token} ``` **Scope:** `apikey:delete` **Response: `204 No Content`** :::caution Esta ação é irreversível. Sistemas usando esta chave perderão acesso imediatamente. ::: --- ### 🏢 Organização #### Obter Organização ```http GET https://acme.pumahelp.com/api/v1/organizations Authorization: Bearer {token} ``` **Scope:** `organization:read` **Response: `200 OK`** ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Acme Corporation", "app_name": "acme", "subscription_status": "active", "auto_close_resolved_tickets_minutes": 5760, "auto_solve_pending_tickets_minutes": null, "notify_group_on_unassigned_tickets": false, "sla_enabled": true, "created_at": "2025-01-01T10:00:00Z" } ``` | Campo | Descrição | |---|---| | `auto_close_resolved_tickets_minutes` | Minutos depois de resolvido para o ticket ser **fechado** pelo sistema. Aceita de `60` (1 hora) a `43200` (30 dias), ou `null` = desligado. Padrão: `10080` (7 dias) | | `auto_solve_pending_tickets_minutes` | Minutos aguardando o cliente (`pending`) para o ticket ser **resolvido** pelo sistema. Aceita de `60` (1 hora) a `129600` (90 dias), ou `null` = desligado (padrão) | | `notify_group_on_unassigned_tickets` | Ver [Avisar o grupo sobre tickets sem responsável](#avisar-o-grupo-sobre-tickets-sem-responsável). Padrão `false` | | `sla_enabled` | Se o SLA da organização está ligado. Ver [SLA da organização](#sla-da-organização) | --- #### Fechamento automático de tickets resolvidos ```http PUT https://acme.pumahelp.com/api/v1/organizations/settings/auto-close-time Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `organization:update` (Owner/Admin) Um ticket **resolvido** que o cliente não responde é **fechado** pelo sistema depois deste prazo, contado em minutos corridos a partir da resolução. O fechamento entra na linha do tempo com ator `system`, dispara `ticket.updated` e é propagado em tempo real aos clientes conectados. Tickets arquivados não são fechados. **Request Body:** ```json { "auto_close_resolved_tickets_minutes": 5760 } ``` `null` desliga a regra — o mesmo sinal da regra irmã, `auto_solve_pending_tickets_minutes` —, e omitir o campo tem o mesmo efeito. Os valores `-1` e `0` não são aceitos: fora de `60`–`43200` ou `null`, a resposta é `400`. A verificação ocorre a cada minuto. Ao fechar, o ticket mantém o `solved_at`. **Response: `204 No Content`** --- #### Resolução automática de tickets aguardando o cliente ```http PUT https://acme.pumahelp.com/api/v1/organizations/settings/auto-solve-pending-time Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `organization:update` (Owner/Admin) Um ticket **pendente** (aguardando o cliente) sem resposta por este prazo é marcado como **resolvido** pelo sistema — contado em minutos corridos a partir de `pending_since`. A resolução credita o responsável (aparece em `GET /v1/reports/team`), conclui os ciclos de SLA abertos, entra na linha do tempo com ator `system`, dispara `ticket.updated` e abre a janela de avaliação. O cliente ainda pode responder e reabrir; depois vale o fechamento automático. **Request Body:** ```json { "auto_solve_pending_tickets_minutes": 10080 } ``` Entre `60` (1 hora) e `129600` (90 dias); `null` desliga. **Desligado por padrão**: a regra é opt-in por organização. :::warning Ao ligar, vale também para quem já está esperando A regra conta a partir de `pending_since`, e não do momento em que foi ligada. Os tickets que já estão aguardando o cliente há mais tempo que o prazo são resolvidos logo em seguida, em até um minuto — cada um com o seu `ticket.updated`. ::: **Response: `204 No Content`** --- #### Avisar o grupo sobre tickets sem responsável ```http PUT https://acme.pumahelp.com/api/v1/organizations/settings/notify-group-on-unassigned Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `organization:update` (Owner/Admin) Decide quem é notificado quando chega um **ticket sem responsável** (ou uma resposta do cliente num ticket sem responsável). **Desligado por padrão**: nenhum agente recebe notificação pessoal — o ticket aparece na lista e nas visualizações (por exemplo, a padrão "Tickets não atribuídos"), que se atualizam em tempo real quando alguém assume. Desligado, a notificação pessoal cobre apenas o que é do agente: ticket atribuído a ele, resposta nele e alerta de SLA. Ligado, **todos os agentes do grupo** recebem a notificação; quando alguém assume o ticket, as notificações dos outros são recolhidas (marcadas como lidas) e saem da fila de e-mail. Não afeta alertas de SLA: um prazo em risco ou estourado num ticket sem responsável vai sempre ao grupo. **Request Body:** ```json { "notify_group_on_unassigned_tickets": true } ``` O campo é obrigatório: um corpo sem ele, ou com `null`, devolve `400` (`"Informe notify_group_on_unassigned_tickets: true liga o aviso ao grupo, false desliga."`). **Response: `204 No Content`** O valor atual vem em `GET /v1/organizations` como `notify_group_on_unassigned_tickets`. --- #### SLA da organização ```http PUT https://acme.pumahelp.com/api/v1/organizations/settings/sla Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `organization:update` (Owner/Admin) Liga ou desliga o SLA da organização. Desligado, nenhum prazo novo começa e ninguém recebe alertas de SLA; os prazos que já estavam correndo terminam e são medidos, sem alerta. O que muda em cada caso está em [Ligar e desligar o SLA](#ligar-e-desligar-o-sla). **Request Body:** ```json { "sla_enabled": true } ``` O campo é obrigatório: um corpo sem ele, ou com `null`, devolve `400` (`"Informe sla_enabled: true liga o SLA, false desliga."`). **Response: `204 No Content`** O valor atual vem em `GET /v1/organizations` como `sla_enabled`. --- ### 📁 Upload de Arquivos #### Upload de Arquivo ```http POST https://acme.pumahelp.com/api/v1/uploads Authorization: Bearer {token} Content-Type: multipart/form-data ``` **Scope:** `file:upload` **Limite:** 50MB por arquivo **Request Body (multipart):** ``` file: (binary data) ``` **Response: `201 Created`** ```json { "id": "uuid", "file_name": "screenshot.png", "content_type": "image/png", "size": 102400, "content_url": "https://cdn.pumahelp.com/files/abc123xyz789" } ``` **Tipos de Arquivo Suportados:** - Imagens: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.bmp`, `.tif`, `.tiff`, `.heic`, `.heif`, `.avif`, `.ico` - Documentos: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`, `.odt`, `.ods`, `.odp`, `.rtf` - Texto e dados: `.txt`, `.csv`, `.tsv`, `.log`, `.json`, `.xml`, `.yml`, `.yaml`, `.md` - Compactados: `.zip`, `.rar`, `.7z`, `.gz`, `.tar` - Mídia: `.mp4`, `.mov`, `.webm`, `.avi`, `.mkv`, `.mp3`, `.wav`, `.ogg`, `.m4a` :::note Tipos fora da lista Envie apenas os tipos acima. Um arquivo sem extensão ou com extensão fora da lista, ou cujo conteúdo não corresponda ao que a extensão promete — um executável renomeado para `.png`, por exemplo —, pode ser recusado com `400` (`"Tipo de arquivo não permitido."` ou `"O conteúdo do arquivo não corresponde à extensão informada."`). ::: :::note Cada upload é **consumível uma única vez**. Depois de referenciado em `comment.uploads`, o mesmo id não pode ser reaproveitado — reenviar um comentário com ele devolve `400`. Numa retentativa de envio, reutilize o mesmo `client_id` do comentário — não gere um novo upload. ::: **Exemplo:** ```bash curl -X POST https://acme.pumahelp.com/api/v1/uploads \ -H "Authorization: Bearer SEU_TOKEN" \ -F "file=@/caminho/para/arquivo.png" ``` **Exemplo com JavaScript:** ```javascript const formData = new FormData(); formData.append('file', fileInput.files[0]); fetch('https://acme.pumahelp.com/api/v1/uploads', { method: 'POST', headers: { 'Authorization': 'Bearer SEU_TOKEN' }, body: formData }) .then(res => res.json()) .then(data => { console.log('Arquivo enviado:', data.content_url); }); ``` :::note Após o upload, use o `id` retornado para anexar o arquivo a um comentário ao criar ou atualizar um ticket. ::: --- ### 🔍 Escopos Disponíveis #### Listar Escopos ```http GET https://acme.pumahelp.com/api/v1/scopes?category=ticket Authorization: Bearer {token} ``` **Scope:** Público (qualquer usuário autenticado) **Query Parameters:** | Parâmetro | Tipo | Descrição | Obrigatório | |-----------|------|-----------|-------------| | `category` | string | Filtrar por categoria | Não | `count` reflete o **conjunto filtrado**; `categories_count` conta sempre o catálogo **completo**. **Response: `200 OK`** ```json { "count": 5, "categories_count": { "comment": 3, "file": 1, "group": 4, "key": 5, "macro": 4, "organization": 2, "profile": 2, "report": 1, "satisfaction": 2, "session": 2, "ticket": 5, "user": 8, "view": 4, "webhook": 5 }, "scopes": [ { "scope": "ticket:create", "name": "Criar Tickets", "description": "Permite criar novos tickets no sistema", "category": "ticket" }, { "scope": "ticket:delete", "name": "Excluir Tickets", "description": "Permite arquivar tickets", "category": "ticket" }, { "scope": "ticket:events:read", "name": "Ler Linha do Tempo", "description": "Permite ler a linha do tempo (eventos) dos tickets — visão de equipe, nunca do cliente", "category": "ticket" }, { "scope": "ticket:read", "name": "Ler Tickets", "description": "Permite visualizar tickets (restrição de ownership aplicada por lógica de negócio)", "category": "ticket" }, { "scope": "ticket:update", "name": "Atualizar Tickets", "description": "Permite modificar tickets existentes (restrição de ownership aplicada por lógica de negócio)", "category": "ticket" } ] } ``` Sem `category`, `count` é **48**. A lista comentada, com quem recebe cada escopo, está no [Catálogo de Escopos](#catálogo-de-escopos). --- ### ⚙️ Configurações de Conta #### Alterar Email ```http PUT https://acme.pumahelp.com/api/v1/accounts/email Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `account:update:own` **Request Body:** ```json { "new_email": "novoemail@example.com", "password": "senha-atual" } ``` **Response: `204 No Content`** **Response: `409 Conflict`** — `"Este e-mail já está em uso."`: o endereço já é o login de outra conta, ou o e-mail de contato de um cliente em alguma organização de que você participa. --- #### Solicitar Redefinição de Senha ```http POST https://acme.pumahelp.com/api/v1/accounts/forgot/password Content-Type: application/json ``` **Rate limit:** balde `ip`, por endereço (ver [Rate Limiting](#-rate-limiting)) :::note Endpoint público. Envia email com token de redefinição. ::: **Request Body:** ```json { "email": "usuario@example.com" } ``` **Response: `204 No Content`** --- #### Redefinir Senha ```http POST https://acme.pumahelp.com/api/v1/accounts/reset/password Content-Type: application/json ``` **Rate limit:** balde `ip`, por endereço (ver [Rate Limiting](#-rate-limiting)) **Request Body:** ```json { "reset_password_token": "token-recebido-por-email", "new_password": "nova-senha-segura" } ``` **Response: `204 No Content`** --- #### Alterar Senha ```http PUT https://acme.pumahelp.com/api/v1/accounts/password Authorization: Bearer {token} Content-Type: application/json ``` **Scope:** `account:update:own` **Request Body:** ```json { "current_password": "senha-atual", "new_password": "nova-senha-segura" } ``` A senha nova precisa ter **no mínimo 6 caracteres**, conter **letras e números**, e ser **diferente da atual**. **Response: `204 No Content`** --- ### 📦 Exportação de Dados Um arquivo JSON com tudo o que a organização tem no PumaHelp — organização, usuários ativos, grupos, macros e **todos os tickets com a conversa completa e os links dos anexos**. É o caminho para levar os dados embora (portabilidade) e continua liberado com a assinatura inativa. **Só o Owner** pede, lista e baixa. Admins recebem `403`, e uma API Key também — não existe escopo que habilite a exportação: ela expõe os dados de todos os clientes da organização. No painel, fica em **Configurações → Exportar dados**. O fluxo é assíncrono: você pede, o processamento começa em até 1 minuto, os owners recebem um e-mail quando ficar pronto, e o download vale por **7 dias**. #### Solicitar Exportação ```http POST https://acme.pumahelp.com/api/v1/exports Authorization: Bearer {token} ``` Sem corpo. **Response: `202 Accepted`** ```json { "id": "3f9a1c2e-5b6d-4e7f-8a9b-0c1d2e3f4a5b", "status": "pending", "file_size_bytes": null, "created_at": "2026-09-13T12:00:00Z", "completed_at": null, "expires_at": null } ``` **Response: `400 Bad Request`** — uma exportação por vez, e no máximo uma a cada **24 horas**: - `"Já existe uma exportação em andamento. Aguarde ela concluir."` — a última está `pending` ou `processing`. - `"Aguarde antes de gerar outra exportação (limite de 24h entre exportações)."` — a última foi criada há menos de 24 h. A janela conta a partir da **última solicitação, qualquer que seja o status** — inclusive `failed`. #### Listar Exportações ```http GET https://acme.pumahelp.com/api/v1/exports Authorization: Bearer {token} ``` Todas as exportações da organização, mais recentes primeiro, sem paginação. A resposta é um **array na raiz**, sem envelope. **Response: `200 OK`** ```json [ { "id": "3f9a1c2e-5b6d-4e7f-8a9b-0c1d2e3f4a5b", "status": "ready", "file_size_bytes": 4823110, "created_at": "2026-09-13T12:00:00Z", "completed_at": "2026-09-13T12:01:07Z", "expires_at": "2026-09-20T12:01:07Z" }, { "id": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e", "status": "failed", "file_size_bytes": null, "created_at": "2026-08-30T09:12:00Z", "completed_at": null, "expires_at": null } ] ``` | Campo | Descrição | |---|---| | `status` | `pending` (na fila), `processing` (gerando), `ready` (disponível), `failed` (erro ao gerar — peça outra) | | `file_size_bytes` | Tamanho do arquivo; `null` até ficar pronto | | `completed_at` | Quando ficou pronta | | `expires_at` | `completed_at` + 7 dias. **Compare com o relógio**: uma exportação vencida continua listada como `ready` — só o download responde `410` | #### Baixar Exportação ```http GET https://acme.pumahelp.com/api/v1/exports/{id}/download Authorization: Bearer {token} ``` **Response: `200 OK`** — o próprio arquivo, `Content-Type: application/json`, como anexo (`pumahelp-export-2026-09-13.json`, data da solicitação). Não é um redirecionamento: o corpo é o JSON. **Erros:** `404` se a exportação não existe, é de outra organização ou ainda não está `ready` ("A exportação ainda não está disponível para download."); `410` depois de `expires_at` ("Esta exportação expirou. Gere uma nova."). #### Formato do arquivo ```json { "exported_at": "2026-09-13T12:01:07Z", "organization": { "id": "...", "name": "Acme", "app_name": "acme", "created_at": "..." }, "users": [ { "id": "...", "name": "Maria Santos", "email": "maria@acme.com", "role": "agent", "external_id": null, "active": true, "created_at": "..." } ], "groups": [ { "id": "...", "name": "Suporte", "description": null, "default": true, "created_at": "..." } ], "macros": [ { "title": "Resposta padrão", "description": null, "active": true, "type": "group", "created_at": "...", "actions": [ { "type": "status", "value": "pending" } ], "groups": ["Suporte"] } ], "tickets": [ { "public_id": 42, "subject": "Erro no pagamento", "status": "closed", "priority": "high", "type": "incident", "channel": "widget", "created_at": "...", "solved_at": "...", "archived_at": null, "group": "Suporte", "requester": { "id": "...", "name": "João Silva", "email": "joao@example.com" }, "assignee": { "id": "...", "name": "Maria Santos", "email": "maria@acme.com" }, "satisfaction": { "score": "good", "comment": "Resolveram rápido.", "rated_at": "...", "first_rated_at": "..." }, "tags": ["pagamento"], "comments": [ { "created_at": "...", "public": true, "author": "João Silva", "author_email": "joao@example.com", "body": "

Não consigo pagar o boleto.

", "attachments": [ { "file_name": "print.png", "content_type": "image/png", "size": 102400, "url": "https://cdn.pumahelp.com/..." } ] } ] } ] } ``` - `users` traz só os ativos; `tickets` traz todos, arquivados inclusive, com **todos** os comentários (públicos e internos) e o `body` como foi gravado. - `satisfaction` é a avaliação do cliente — `score`, o comentário livre, `rated_at` (última) e `first_rated_at` (primeira) — ou `null` se o ticket não foi avaliado. - Anexos **suprimidos** não entram. Uma exportação gerada **antes** da supressão continua servindo o anexo até expirar. - A linha do tempo dos tickets e os ciclos de SLA não fazem parte do arquivo. --- ## ❌ Códigos de Erro A API retorna códigos HTTP padrão: | Código | Significado | Descrição | |--------|-------------|-----------| | `200` | OK | Requisição bem-sucedida | | `201` | Created | Recurso criado com sucesso | | `202` | Accepted | Pedido aceito: processado em segundo plano ([exportação de dados](#-exportação-de-dados)), ou código de e-mail enviado ([Declarar E-mail](#declarar-e-mail)) | | `204` | No Content | Recurso excluído com sucesso | | `400` | Bad Request | Dados inválidos na requisição | | `401` | Unauthorized | Token inválido, ausente, ou de um usuário excluído ou desativado — **e também** negações de regra de negócio, como o usuário final no ticket de outra pessoa | | `402` | Payment Required | Assinatura inativa. A organização fica **somente leitura** para a equipe; ver abaixo | | `403` | Forbidden | A política de autorização reprovou: papel ou escopo insuficiente para o endpoint | | `404` | Not Found | Recurso não encontrado — ou que quem chama não pode ver, como o ticket de um grupo de que o agente não faz parte. A resposta não confirma que o recurso existe | | `409` | Conflict | Conflito: um valor que precisa ser único já está em uso (e-mail, `external_id`), o recurso já existe, ou foi alterado por outra pessoa entre a leitura e a gravação | | `410` | Gone | Exportação expirada ([Baixar Exportação](#baixar-exportação)), ou código de e-mail do usuário final vencido, usado ou esgotado ([Confirmar E-mail](#confirmar-e-mail)) | | `429` | Too Many Requests | Rate limit excedido ([Rate Limiting](#-rate-limiting)), ou um limite do código de [E-mail do Usuário Final](#-e-mail-do-usuário-final) | | `500` | Internal Server Error | Erro no servidor | Falha de validação devolve **`400`**, não `422`. Valor único já em uso devolve **`409`**, não `400`. Os `409` têm mensagem fixa — o servidor nunca devolve o valor que colidiu nem quem o usa: | Mensagem | Quando | O que fazer | |---|---|---| | `"Este e-mail já está em uso."` | o endereço já é de outra pessoa da organização | use outro, ou ache quem o usa pela busca de [Listar Usuários](#listar-usuários) | | `"O ExternalId informado já pertence a outro usuário."` | o `external_id` já é de outro usuário | use outro valor, ou atualize quem já o tem | | `"Este recurso já existe."` | duas requisições gravando o mesmo valor ao mesmo tempo | releia e reenvie | | `"Este recurso foi alterado por outra pessoa. Recarregue e tente novamente."` | a linha mudou ou sumiu entre a leitura e a gravação | releia e reenvie | Reenviar não resolve os dois primeiros. **Formato de Erro:** ```json { "error_messages": [ "O campo 'email' é obrigatório", "O campo 'password' deve ter no mínimo 6 caracteres" ] } ``` :::warning O campo é `error_messages` Não `errors`. Um cliente que leia `errors` recebe `undefined` e acaba mostrando uma mensagem genérica no lugar do motivo real. ::: Alguns erros trazem também um **`error_code`**, estável, para a sua integração decidir o que fazer sem depender do texto — que é para pessoas e pode mudar de redação. O campo só aparece quando existe: ```json { "error_messages": ["Ticket fechado não pode ser reaberto. Uma resposta do cliente abre um ticket de acompanhamento."], "error_code": "ticket_closed" } ``` | `error_code` | Quando | |---|---| | `ticket_closed` | O ticket está fechado e a requisição tenta mudá-lo: sair de `closed`, comentar como equipe ou mandar a resposta do cliente com outras alterações. Ver [Atualizar Ticket](#atualizar-ticket) | ### `402`: assinatura inativa Com a assinatura inadimplente ou cancelada, a organização entra em **somente leitura**: toda mutação feita por Owner, Admin ou Agente devolve `402`. Seguem liberados: - toda leitura (`GET`, `HEAD`, `OPTIONS`); - **tudo que chega do usuário final** — abrir chamado e responder pelo widget nunca é bloqueado, para a fila não parar; - `/v1/billing` e `/v1/exports`, para dar como regularizar e como levar os dados embora; - login, logout e refresh; - `DELETE /v1/users/{id}`, para a organização poder reduzir agentes e caber num plano menor. --- ## ⚡ Quick Start ```bash # 1. Login (substitua 'acme' pelo seu subdomínio) curl -X POST https://acme.pumahelp.com/api/v1/users/login \ -H "Content-Type: application/json" \ -d '{ "email": "user@example.com", "password": "senha" }' # Response: # { # "access_token": "eyJhbGc...", # "refresh_token": "eyJhbGc..." # } # 2. Criar API Key (opcional, para integrações) curl -X POST https://acme.pumahelp.com/api/v1/keys \ -H "Authorization: Bearer SEU_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Minha Integração", "scopes": ["ticket:create", "ticket:read"] }' # 3. Criar Ticket (com JWT) curl -X POST https://acme.pumahelp.com/api/v1/tickets \ -H "Authorization: Bearer SEU_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "subject": "Meu primeiro ticket via API", "priority": "normal", "type": "question", "comment": { "body": "Testando a integração com a API", "public": true } }' # OU com API Key curl -X POST https://acme.pumahelp.com/api/v1/tickets \ -H "X-API-Key: SEU_API_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{...}' # 4. Listar Tickets curl -X GET "https://acme.pumahelp.com/api/v1/tickets?page=1&page_size=10" \ -H "Authorization: Bearer SEU_ACCESS_TOKEN" ``` :::tip Substitua `acme` em todos os exemplos pelo **subdomínio** da sua organização, disponível em **Configurações → Organização**. ::: --- ## 💡 Melhores Práticas ### Segurança - ✅ Use HTTPS sempre - ✅ Nunca exponha secrets no código versionado - ✅ Rotacione API Keys regularmente - ✅ Use princípio do menor privilégio (scopes mínimos necessários) - ✅ Valide assinaturas HMAC dos webhooks ### Performance - ✅ Use paginação adequada (`page_size` adequado) - ✅ Implemente retry logic com backoff exponencial - ✅ Cache dados que mudam pouco - ✅ Monitore headers `X-RateLimit-*` - ✅ Use webhooks em vez de polling ### Integração - ✅ Valide dados antes de enviar - ✅ Trate todos os códigos de erro - ✅ Implemente logs para auditoria - ✅ Teste em ambiente de desenvolvimento primeiro --- ## 🔧 Troubleshooting ### Erro 401: Unauthorized **Sintoma:** Todas as requisições retornam `401 Unauthorized` **Causas Comuns:** 1. Token JWT expirado 2. API Key inválida ou revogada 3. Header de autorização mal formatado 4. Token de um usuário excluído ou desativado **Soluções:** ```bash # Verifique o formato do header # ✅ Correto: Authorization: Bearer eyJhbGci... # ❌ Incorreto: Authorization: eyJhbGci... Authorization: bearer eyJhbGci... # Para API Key: # ✅ Correto: X-API-Key: rk_live_abc123... # Renovar token expirado: curl -X POST https://acme.pumahelp.com/api/v1/users/refresh \ -H "Content-Type: application/json" \ -d '{"refresh_token": "SEU_REFRESH_TOKEN"}' ``` --- ### Erro 403: Forbidden **Sintoma:** Request autenticado mas retorna `403 Forbidden` **Causas Comuns:** 1. API Key sem o scope necessário 2. Usuário sem permissão para a ação **Soluções:** ```bash # Verificar scopes da sua API Key: curl -X GET https://acme.pumahelp.com/api/v1/keys \ -H "Authorization: Bearer SEU_TOKEN" # Verificar scopes disponíveis: curl -X GET https://acme.pumahelp.com/api/v1/scopes \ -H "Authorization: Bearer SEU_TOKEN" # Atualizar API Key com scopes necessários ou criar nova ``` --- ### Erro 400: Bad Request **Sintoma:** Request retorna `400` com mensagem de validação **Causas Comuns:** 1. Campos obrigatórios faltando 2. Formato de dados inválido 3. Valor fora do range permitido **Exemplo de Response:** ```json { "error_messages": [ "O campo 'subject' é obrigatório", "O campo 'priority' deve ser: low, normal, high ou urgent" ] } ``` **Soluções:** - Revise a documentação do endpoint para campos obrigatórios - Verifique tipos de dados esperados - Valide valores enum (status, priority, type) --- ### Erro 429: Too Many Requests **Sintoma:** Requests bloqueados com `429 Too Many Requests` **Causa:** Rate limit excedido — ou, nas rotas de [E-mail do Usuário Final](#-e-mail-do-usuário-final), um limite do código, que vem com o motivo em `error_messages` **Solução:** ```javascript // Implementar retry com backoff exponencial async function requestWithRetry(url, options, maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { const response = await fetch(url, options); if (response.status === 429) { const retryAfter = response.headers.get('Retry-After') || 10; const delay = Math.pow(2, i) * 1000; // Backoff exponencial await new Promise(resolve => setTimeout(resolve, Math.max(delay, retryAfter * 1000))); continue; } return response; } throw new Error('Max retries exceeded'); } ``` --- ### Webhook Não Está Sendo Chamado **Sintomas:** - Webhook criado mas não recebe eventos - Eventos não aparecem nos logs **Checklist:** 1. ✅ URL está acessível publicamente (não localhost)? 2. ✅ URL usa HTTPS? (recomendado) 3. ✅ A URL é a final, sem redirecionamento? Um redirecionamento pode fazer a chamada chegar sem o corpo. 4. ✅ Servidor responde com `2xx` em menos de 30 segundos? 5. ✅ Eventos selecionados estão corretos? 6. ✅ Respondeu `2xx`? Qualquer outro status conta como falha, e depois de 8 tentativas (~27,6 h) a entrega não é mais repetida; `410` encerra na hora. Ver [Entrega, retentativas e ordem](#entrega-retentativas-e-ordem) 7. ✅ A assinatura foi validada com o `X-Pumahelp-Webhook-Timestamp` **da tentativa**, não com o `time` do corpo, e sobre o corpo bruto? **Testar Webhook:** ```bash # Verificar status do webhook: curl -X GET https://acme.pumahelp.com/api/v1/webhooks/{webhook_id} \ -H "Authorization: Bearer SEU_TOKEN" # Criar evento de teste (criar ticket): curl -X POST https://acme.pumahelp.com/api/v1/tickets \ -H "X-API-Key: SEU_API_KEY" \ -H "Content-Type: application/json" \ -d '{"subject": "Teste Webhook", "priority": "normal", "type": "question", "comment": {"body": "Teste", "public": true}}' ``` --- ### FAQ **P: Posso usar a mesma API Key em múltiplos ambientes?** R: Não é recomendado. Crie API Keys separadas para desenvolvimento, staging e produção. **P: Quantos webhooks posso criar?** R: Máximo de 5 webhooks por organização. Recomendamos consolidar eventos relacionados em um único endpoint quando possível. **P: Como sei qual scope usar?** R: Consulte `GET /v1/scopes` para lista completa. Use o princípio do menor privilégio: apenas os scopes necessários. **P: Posso deletar permanentemente um ticket?** R: Não. DELETE arquiva o ticket (soft delete). Isso preserva histórico e auditoria. --- ### Status de Ticket **new** Ticket recém criado, aguardando primeira atribuição ou triagem. **open** Ticket em andamento, sendo trabalhado por um agente. **pending** Aguardando resposta do cliente ou informação externa. A resposta do cliente devolve o ticket a `open` (ver [Ciclo de vida do ticket](#ciclo-de-vida-do-ticket)). Com a resolução automática ligada na organização, um ticket pendente sem resposta por N minutos é marcado `solved` pelo sistema. **solved** Ticket resolvido. A resposta do cliente reabre (`open`; a reabertura conta no `reopened` dos relatórios). Com o fechamento automático ligado, vira `closed` depois de N minutos sem resposta. **closed** Ticket fechado — **definitivo**. Nenhum status sai de `closed` e nenhum agente comenta nele (`400`, `error_code: "ticket_closed"`); a resposta do cliente abre um **ticket de acompanhamento** ligado a este. `solved_at` continua com a data da resolução. --- ### Ciclo de vida do ticket Cinco status, duas automações por tempo e uma regra terminal: resolvido pode reabrir; fechado é definitivo e gera um ticket de acompanhamento. ``` new ──▶ open ◀──▶ pending ──(sem resposta por N min, opcional)──▶ solved ──(N min)──▶ closed ▲ │ │ └──────────── resposta do cliente reabre ──────────────────┘ resposta do cliente abre acompanhamento ``` | Movimento | Quem faz | O que fica registrado | |---|---|---| | `new`/`open`/`pending`/`solved`/`closed` | agente ou API (`PUT /v1/tickets/{public_id}`) | `status_changed` na linha do tempo, com o ator | | resposta do cliente em `pending`/`solved` → `open` | cliente com token de end-user (o widget); uma integração que responde em nome dele reabre enviando `"status": "open"` junto do comentário | `status_changed`; a partir de `solved` conta como reabertura nos relatórios; `first_solved_at` não muda | | `pending` → `solved` por inatividade | sistema, se `auto_solve_pending_tickets_minutes` estiver definido | `status_changed` com ator `system`; credita o responsável no relatório de equipe (`GET /v1/reports/team`); ciclos de SLA concluídos | | `solved` → `closed` por tempo | sistema, se `auto_close_resolved_tickets_minutes` estiver definido | `status_changed` com ator `system`; `solved_at` é mantido | | resposta do cliente em `closed` | cliente com token de end-user, ou integração que identifica o solicitante como autor (ver [Atualizar Ticket](#atualizar-ticket)) | ticket **novo** com `follow_up_of`; o original ganha `follow_up_created` | Campos do ticket que expõem isso: `pending_since` (desde quando aguarda o cliente; nulo fora de `pending`), `follow_up_of` (`{ "public_id", "subject" }` do ticket fechado que este continua; nulo nos demais), `solved_at` (quando foi resolvido; mantido ao fechar, nulo ao reabrir) e `rateable_until`. **Linha do tempo.** Cada movimento acima vira uma linha em [`GET /v1/tickets/{public_id}/events`](#linha-do-tempo-do-ticket-eventos) — com ator `system` nas automações. Só a equipe lê. **Efeito nos relatórios e no SLA.** A resolução automática conta como resolução para quem estava atribuído, no mês em que aconteceu, e a primeira resolução nunca é reescrita; o acompanhamento é um ticket novo (entra em "criados", não em "reabertos"). Os ciclos de SLA de um ticket resolvido pelo sistema são concluídos junto com a resolução, e a avaliação (cumprido/violado) é registrada uma única vez. A resolução automática de pendentes é opt-in por organização. --- ### Prioridades **low** - Baixa prioridade **normal** - Prioridade normal (padrão) **high** - Alta prioridade **urgent** - Urgente --- ### Tipos de Ticket **question** - Pergunta/dúvida **incident** - Problema/erro **problem** - Problema complexo que afeta múltiplos usuários **task** - Tarefa a ser realizada --- ### Roles de Usuário **end-user** Usuário final, cliente. Pode criar tickets e visualizar apenas seus próprios tickets. Escopos no JWT: tickets e comentários próprios, `satisfaction:create` (avaliar o próprio ticket), `file:upload`, `session:merge`. **agent** Agente de suporte. Pode visualizar, atualizar e resolver tickets. **Não tem acesso aos relatórios** (`reports:read`): eles são da organização inteira, sem recorte por grupo. Recebe ainda `ticket:events:read` (linha do tempo), macros, visualizações, `user:read`, `comment:update` e `comment:redact`. **admin** Administrador. Pode gerenciar usuários, grupos, configurações e tem acesso completo — incluindo relatórios e a configuração de SLA. Escopos: `reports:read` e `satisfaction:read`, gestão de usuários, grupos, API Keys, webhooks e `organization:update`. **owner** Proprietário da organização. Acesso total incluindo configurações de billing e organização. Mesmos escopos do admin; a diferença está no papel: só o Owner faz a [exportação de dados](#-exportação-de-dados) e gerencia billing. A tabela completa está no [Catálogo de Escopos](#catálogo-de-escopos). --- ### Eventos de Webhook **ticket.created** - Novo ticket criado — inclusive o acompanhamento de um ticket fechado, com `detail.follow_up_of` apontando para o original, que não gera evento **ticket.updated** - Ticket atualizado (status, prioridade, assignee, etc) — inclusive pelo sistema, no fechamento e na resolução automáticos **user.created** - Novo usuário criado **user.updated** - Usuário atualizado **user.deleted** - Usuário deletado Entrega, assinatura e retentativas: [Entrega, retentativas e ordem](#entrega-retentativas-e-ordem). --- ### Endpoints legados Continuam funcionando para integrações existentes e não recebem funcionalidades novas. Não use em integrações novas. | Endpoint | Use no lugar | |---|---| | `POST /v1/oauth/tokens` (client credentials) | [API Keys](#-api-keys) com `X-API-Key` | | `POST /v1/guests` (header `X-Public-Key`) | [`POST /v3/guests`](#criar-sessão-de-convidado-v3) com API Key e `guest:create` | | `GET`/`PUT /v1/organizations/key/{secret,public,webhook}` | [API Keys](#-api-keys) e a chave por webhook em [Rotacionar Chave do Webhook](#rotacionar-chave-do-webhook) | | `GET /v1/tickets/stats-over-time` | [`GET /v1/reports/volume`](#volume) | | `GET /v1/tickets/agent-ranking` | [`GET /v1/reports/team`](#equipe) | --- **Versionamento:** a API é versionada por rota — cada endpoint carrega seu prefixo (`/v1/...`, `/v2/...`). Não existe um número de versão global da API; o prefixo de cada endpoint está no bloco de exemplo dele. **Última atualização:** Setembro de 2026. --- ## Widget O widget de suporte do PumaHelp em uma tag de script. O visitante abre conversas, acompanha as respostas em tempo real, anexa arquivos e avalia o atendimento — sem sair do seu site. ```html ``` Não há segunda tag, não há chamada de função, não há espera. O widget se inicializa sozinho lendo os `data-*` da própria tag. :::important O `type="module"` é obrigatório. O widget carrega o painel sob demanda, e é o modo módulo que faz esse carregamento resolver contra o CDN. Sem ele, o navegador nem chega a executar o script. Módulo já carrega adiado por definição — não é preciso `defer`. ::: --- ## 🚀 Instalação ### O básico Troque `acme` pelo subdomínio da sua organização e cole a chave restrita: ```html ``` O botão flutuante aparece no canto inferior direito. É só isso. ### Embutido num container Sem botão flutuante, ocupando todo o espaço de um elemento seu — para uma página de ajuda ou uma aba de suporte dentro do seu produto: ```html
``` Passar `container` implica `embedded: true`. ### A fila de comandos Repare que o `pumahelp(...)` do exemplo acima vem **antes** da tag do script. Isso é intencional e funciona: aquela primeira linha instala uma fila que guarda tudo o que você chamar, e o widget a consome assim que carrega. ```html ``` Você nunca precisa esperar o widget carregar, nem testar se `window.pumahelp` já existe. --- ## 🔐 Autenticação Há dois modos. Eles resolvem problemas diferentes e podem conviver. | Modo | Como se ativa | Quando usar | |---|---|---| | Chave restrita | `data-api-key` | Site sem backend, ou visitante anônimo | | Provedor de token | `pumahelp('auth', fn)` | Você tem backend e não quer segredo nenhum no HTML | ### Chave restrita A chave vai no HTML e **é pública por definição** — qualquer visitante a lê no código-fonte. Por isso ela precisa ser restrita: crie uma chave de API com o escopo de criação de convidado e **nada além disso**. :::warning Nunca use no widget uma chave com escopo de leitura de tickets, de usuários ou de relatórios. Uma chave no navegador é uma chave publicada — trate o escopo como o único limite real. ::: ### Provedor de token Se você tem backend, existe caminho melhor: o widget pede o token ao **seu** servidor, e nenhum segredo chega ao navegador. ```html ``` Repare que não há `data-api-key`. O widget chama essa função na inicialização e outra vez sempre que a credencial expirar — ela pode ser `async`, e devolver `null` se não houver usuário. :::tip Este é o modo durável Porque a função é chamada de novo a cada expiração e a cada carregamento de página, o provedor é o único jeito de a identidade **sobreviver sozinha**. Se o seu site tem login, prefira-o ao `identify()`. ::: Se preferir registrar o provedor junto da configuração, `init()` aceita o mesmo: ```js pumahelp('init', { appName: 'acme', auth: async () => (await fetch('/api/suporte/token')).json().then((r) => r.token), }); ``` No seu servidor, emita o token com a chave que **fica no servidor**: ```js // POST /api/suporte/token — no SEU backend const response = await fetch('https://acme.pumahelp.com/api/v1/users/impersonate', { method: 'POST', headers: { 'X-API-Key': process.env.PUMAHELP_SECRET_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: usuario.nome, email: usuario.email, external_id: usuario.id, role: 'end-user', }), }); const { access_token } = await response.json(); ``` Assim a conversa já nasce vinculada ao usuário certo, e o histórico o acompanha entre dispositivos e navegadores. A chave usada aqui precisa do escopo `user:impersonate` e **nunca** deve ir para o navegador. Veja a [referência da API](/docs/api) para o contrato completo do endpoint. ### Identificar depois Se a pessoa faz login já com o widget na tela, não é preciso recarregar nada: ```js pumahelp('identify', { token: '' }); ``` E ao sair: ```js pumahelp('logout'); ``` :::warning `identify()` vale por carregamento de página O token do `impersonate` não tem *refresh*, e o widget **não o guarda** — gravá-lo deixaria uma credencial de longa duração no armazenamento local do seu site. Na prática: **chame `identify()` a cada carregamento**, ou registre um provedor de token e não se preocupe mais com isso. Sem nenhum dos dois, a pessoa volta como visitante anônima depois de um F5 ou quando o token expirar — e nesse momento o widget emite [`pumahelp:session-lost`](#-eventos), que é o seu gancho para reidentificar: ```js pumahelp('on', 'pumahelp:session-lost', async () => { const { token } = await fetch('/api/suporte/token').then((r) => r.json()); pumahelp('identify', { token }); }); ``` ::: O `logout` descarta a sessão local. As conversas continuam existindo na sua conta do PumaHelp — quem entrar de novo com o mesmo usuário volta a vê-las. --- ## ⚙️ Configuração Todo campo existe como atributo `data-*` na tag do script e como propriedade em `init()`. | Atributo | Propriedade | Padrão | O que faz | |---|---|---|---| | `data-app` | `appName` | — | **Obrigatório.** Subdomínio da organização. Aceita letras, números e hífen | | `data-api-key` | `apiKey` | — | Chave restrita. Dispensável quando você registra um provedor de token | | `data-theme` | `theme` | `auto` | `light`, `dark` ou `auto` (segue o sistema do visitante) | | `data-language` | `language` | `pt-BR` | Idioma dos textos e das datas | | `data-color` | `color` | `#f97116` | Cor de destaque, a mesma nos dois temas. Aceita qualquer cor CSS. O texto que fica em cima dela é escolhido pelo contraste — veja [Aparência](#-aparência) | | `data-icon` | `icon` | `puma` | Ícone do botão flutuante: `puma`, `chat`, `question`, `bell`, `smile`, `lines` ou `send` — ou a URL de uma imagem sua. Veja [Ícone do botão](#ícone-do-botão) | | `data-embedded` | `embedded` | `false` | Embute no lugar de flutuar | | `data-z-index` | `zIndex` | `2147483000` | Camada do widget | | `data-sound` | `soundEnabled` | `true` | Geral: `false` silencia os dois sons abaixo | | `data-send-sound` | `sendSoundEnabled` | `true` | Som ao enviar uma mensagem ou abrir uma conversa | | `data-receive-sound` | `receiveSoundEnabled` | `true` | Som ao receber uma resposta | | `data-persist-session` | `persistSession` | `true` | `false` mantém a sessão só em memória | | `data-branding` | `branding` | `true` | Link "Criado com PumaHelp" no pé do painel. `false` esconde | | `data-collect-email` | `collectEmail` | `false` | Convida o visitante anônimo a acompanhar a conversa por e-mail. Veja [E-mail](#-e-mail-e-continuidade-entre-dispositivos) | | `data-collect-rating` | `collectRating` | `true` | Pergunta a satisfação quando a conversa é resolvida ou encerrada. `false` não pergunta. Veja [Avaliação](#avaliação-do-atendimento) | | `data-rating-comment` | `ratingComment` | `true` | Oferece um campo de comentário depois da nota. `false` recolhe o cartão assim que a nota é dada | | `data-auto-focus` | `autoFocus` | `true` | Leva o foco ao campo de mensagem ao abrir o painel e ao entrar numa conversa. `false` deixa o foco no título. Veja [Acessibilidade](#acessibilidade) | | `data-launcher-label` | `launcherLabel` | — | Texto ao lado do ícone do botão flutuante; o botão vira uma pílula. Veja [Texto e tamanho do botão](#texto-e-tamanho-do-botão) | | `data-translations` | `translations` | — | JSON com textos sobrescritos. JSON inválido não impede o widget de abrir: ele cai nos textos padrão e emite `pumahelp:error` com `field: "translations"` | | `data-tags` | `tags` | — | Etiquetas aplicadas a todo ticket aberto por esta instalação. No atributo, separadas por vírgula (`site,checkout`); em `init()`, uma lista de textos. Para etiquetar por ticket, veja [Etiquetar tickets](#etiquetar-tickets) | | — | `container` | — | Elemento onde embutir. Só em `init()`. Implica `embedded: true` | | — | `auth` | — | Provedor de token. Em `init()` ou via `pumahelp('auth', fn)` | ### Booleanos Valem como falso: `false`, `0`, `no`, `off` e string vazia. Qualquer outro valor é verdadeiro, e o atributo ausente cai no padrão. ```html ``` ### Erros de configuração Configuração inválida não derruba a página: o widget emite `pumahelp:error` com o campo culpado e não monta. ```js pumahelp('on', 'pumahelp:error', ({ field, message }) => { console.warn('Widget não iniciou:', field, message); }); ``` --- ## 📋 API programática `window.pumahelp` recebe um comando e seus argumentos. ```js pumahelp('open'); // abre o painel pumahelp('close'); // fecha pumahelp('toggle'); // alterna pumahelp('logout'); // descarta a sessão local pumahelp('update', { collectEmail: true }); // troca opções com o widget no ar pumahelp('destroy'); // remove o widget da página pumahelp('identify', { token: '' }); pumahelp('auth', async () => (await fetch('/api/suporte/token')).json().then(r => r.token)); pumahelp('init', { appName: 'acme', apiKey: 'rk_live_...' }); ``` | Comando | Argumentos | Devolve | |---|---|---| | `init` | `Config` | A instância criada | | `auth` | `() => string \| null \| Promise` | — | | `open` / `close` / `toggle` | — | — | | `identify` | `{ token }` | `Promise` — ou nada, quando enfileirado | | `logout` | — | — | | `update` | objeto com opções | — | | `destroy` | — | — | | `on` | `evento, ouvinte` | Função que cancela a inscrição | Todos funcionam antes de o script carregar, graças à fila — inclusive `on`, cuja função de cancelar já vale mesmo tendo sido pedida antes de o widget existir. ### Trocar opções com o widget no ar `update` muda a configuração sem recarregar nem recriar o widget. Aceita `icon`, `collectEmail`, `collectRating`, `ratingComment`, `autoFocus`, `launcherLabel`, `branding`, `tags`, `soundEnabled`, `sendSoundEnabled`, `receiveSoundEnabled`, `theme`, `color`, `zIndex`, `language` e `translations`. O que define a instância — `appName`, `apiKey`, `auth`, `embedded`, `container`, `persistSession` — não muda por aqui: a chave é recusada com um `pumahelp:error` (`field` diz qual), e nada mais é alterado. Chamado antes de o widget montar, vale como `init()`. ```js pumahelp('update', { theme: 'dark', color: '#f26522' }); ``` O que a fila não consegue devolver é um valor que ainda não existe: um `identify` chamado antes de o widget montar devolve `undefined` em vez da promessa, e um `init` enfileirado devolve a instância só quando ela é criada. Se você precisa da referência na hora, chame depois do carregamento. ### Ouvir e parar de ouvir ```js const parar = pumahelp('on', 'pumahelp:unread-changed', ({ count }) => { document.title = count > 0 ? `(${count}) Minha loja` : 'Minha loja'; }); // mais tarde parar?.(); ``` O retorno é opcional (`parar?.()`) porque, se você chamar `on` antes de o widget montar, a inscrição entra pela fila e o cancelamento chega depois. --- ## 🎧 Eventos Todo evento é um `CustomEvent` que atravessa o Shadow DOM e sobe até o `document`. Você pode ouvir pelos dois caminhos — `pumahelp('on', ...)` ou `document.addEventListener(...)`. Todos são prefixados com `pumahelp:`, e o detalhe vai sempre em `event.detail`. | Evento | `event.detail` | |---|---| | `pumahelp:ready` | `{ instanceId: string }` | | `pumahelp:error` | `{ field?: string, message: string, status?: number }` | | `pumahelp:open` | — | | `pumahelp:close` | — | | `pumahelp:destroy` | — | | `pumahelp:before-ticket-create` | `{ subject: string, body: string, tags: string[] }` — mutável; veja [Etiquetar tickets](#etiquetar-tickets) | | `pumahelp:ticket-created` | `{ ticketId: number, subject: string }` | | `pumahelp:ticket-updated` | `{ ticketId: number, status: string }` | | `pumahelp:message-sent` | `{ ticketId: number }` | | `pumahelp:rated` | `{ ticketId: number, score: string }` | | `pumahelp:unread-changed` | `{ count: number }` | | `pumahelp:identified` | `{ userId: string \| null }` | | `pumahelp:session-lost` | `{ reason: 'expired' }` | | `pumahelp:realtime` | `{ state: 'connected' \| 'reconnecting' \| 'disconnected' }` | O widget não emite nenhum evento além destes. **`pumahelp:error`** traz `status` quando a falha veio da API: `0` para rede fora, `5xx` para erro do servidor, `401` para credencial que morreu. Sem `status`, é erro de configuração — e aí `field` diz qual campo. Erros de validação e limite de taxa não geram evento: o primeiro o painel já mostra no lugar certo, e o segundo o widget resolve sozinho respeitando o `Retry-After`. **`pumahelp:session-lost`** avisa que uma identidade declarada por `identify()` expirou e a conversa seguirá anônima. Quem registra um provedor de token não recebe este evento — lá a renovação é automática. ### Analytics ```js document.addEventListener('pumahelp:ticket-created', (event) => { gtag('event', 'suporte_conversa_aberta', { ticket_id: event.detail.ticketId, subject: event.detail.subject, }); }); document.addEventListener('pumahelp:rated', (event) => { gtag('event', 'suporte_avaliado', { score: event.detail.score }); }); ``` ### Indicador de não lidas no seu próprio layout ```js pumahelp('on', 'pumahelp:unread-changed', ({ count }) => { const badge = document.querySelector('#meu-badge-suporte'); badge.hidden = count === 0; badge.textContent = String(count); }); ``` ### Avisar quando a conexão cair ```js pumahelp('on', 'pumahelp:realtime', ({ state }) => { if (state === 'disconnected') mostrarAviso('Suporte offline, reconectando…'); if (state === 'connected') esconderAviso(); }); ``` ### Etiquetar tickets Há dois jeitos de um ticket aberto pelo widget já chegar etiquetado ao painel. **Etiquetas fixas** valem para toda a instalação — úteis para saber de onde o ticket veio: ```html ``` Em `init()`, é `tags: ['site', 'checkout']`. **Etiquetas por ticket** vêm do evento `pumahelp:before-ticket-create`, disparado logo antes do envio. O `event.detail` é o rascunho do ticket — `{ subject, body, tags }` — e ele é **mutável**: o que você alterar ali é o que vai para a API. As etiquetas fixas já estão na lista quando o evento dispara. ```js pumahelp('on', 'pumahelp:before-ticket-create', (rascunho) => { rascunho.tags.push(`plano-${usuario.plano}`); if (usuario.vip) rascunho.tags.push('vip'); }); ``` Pelo `document` funciona igual: `event.detail.tags.push('vip')`. Faça as alterações de forma síncrona — o widget lê o rascunho assim que os ouvintes retornam. Antes de enviar, o widget apara os espaços, remove repetidas e descarta o que a API não aceita (etiqueta vazia ou com mais de 50 caracteres) — cada descarte gera um `pumahelp:error` com `field: "tags"`, e o ticket é enviado normalmente com as demais. :::note Só na abertura O evento dispara apenas quando o visitante abre uma conversa nova. A conversa de acompanhamento — aberta ao responder uma conversa encerrada — herda as etiquetas da original, e as respostas dentro de uma conversa não carregam etiquetas. ::: --- ## 🎨 Aparência O widget vive num Shadow DOM: o CSS do seu site não entra e o do widget não vaza. A customização acontece por dois caminhos combináveis — variáveis CSS e `::part()`. ### Cor de destaque O caminho mais curto, e o que resolve a maioria dos casos: ```html ``` Sem `data-color`, o destaque é o laranja da PumaHelp, `#f97116` — o mesmo do painel, e o mesmo nos dois temas. O destaque pinta o que é pequeno e quer atenção: o botão flutuante, o botão de enviar, a nota escolhida na avaliação. O balão do visitante usa um **tom** dele — `--pw-bubble-user` —, porque uma conversa inteira em cor cheia cansa de ler. #### O texto sobre a cor de destaque `--pw-accent-contrast` é a cor de tudo o que o widget desenha **em cima** do destaque: o ícone do botão flutuante, o rótulo dos botões e o texto do balão do visitante. Você não precisa se preocupar com ela. Ao informar `data-color`, o widget mede a cor e usa quase-preto ou branco, o que for mais legível — um destaque amarelo recebe texto preto, um azul escuro recebe branco. Para discordar dessa escolha, uma linha no CSS do seu site vence: ```css pumahelp-widget::part(root) { --pw-accent-contrast: #ffffff; } ``` ### Ícone do botão O botão flutuante traz a marca da PumaHelp. Para trocar por um símbolo neutro — útil quando a sua página já usa um balão de conversa para outra coisa: ```html ``` Os valores são `puma` (o padrão), `chat`, `question`, `bell`, `smile`, `lines` e `send`. Um nome fora dessa lista não impede o widget de abrir: ele mantém o padrão e emite `pumahelp:error` com `field: "icon"`. Também dá para trocar com o widget no ar: ```js pumahelp('update', { icon: 'bell' }); ``` #### A sua logo `data-icon` também aceita a URL de uma imagem — a logo da sua empresa no lugar do ícone: ```html ``` Uma imagem quadrada, com fundo transparente, em SVG ou PNG. Ela aparece com metade do tamanho do botão (28px num botão de 56px), centrada, e o X de fechar toma o lugar dela quando o painel abre. Para outro tamanho ou arredondamento, a parte `launcher-image`: ```css pumahelp-widget::part(launcher-image) { width: 34px; height: 34px; } ``` Se a imagem não carregar, o botão mostra o ícone padrão e o widget emite `pumahelp:error` com `field: "icon"`. Endereços que não são de imagem (`javascript:`, por exemplo) são recusados como um nome fora da lista. `pumahelp('update', { icon: 'https://…' })` troca no ar, e `'bell'` volta ao ícone embutido. Em modo embutido não há botão flutuante, e o ícone não tem efeito. ### Texto e tamanho do botão Um texto ao lado do ícone transforma o botão numa pílula — "Fale conosco", "Assistente": ```html ``` Duas ou três palavras: o botão cresce com o texto, com uma transição suave. O texto é também o nome do botão para leitores de tela. Troca-se com o widget no ar, e vazio volta ao botão redondo: ```js pumahelp('update', { launcherLabel: 'Fale conosco' }); pumahelp('update', { launcherLabel: '' }); ``` Com o painel aberto o texto se recolhe e o botão é só o de fechar; ao fechar, a pílula volta. O texto aparece em qualquer tamanho de tela. Para deixar só o ícone no celular, esconda a parte `launcher-label`: ```css @media (max-width: 480px) { pumahelp-widget::part(launcher-label) { display: none; } } ``` O tamanho do botão é a variável `--pw-launcher-size` (56px por padrão); o ícone e o contador de não lidas acompanham: ```css pumahelp-widget::part(root) { --pw-launcher-size: 48px; } ``` Em modo embutido não há botão flutuante: texto e tamanho não têm efeito. ### Paleta completa As variáveis vivem na parte `root`, que é a raiz do widget: ```css pumahelp-widget::part(root) { --pw-accent: #0ea5e9; --pw-accent-contrast: #ffffff; --pw-radius: 8px; } ``` :::caution `--pw-font` é a exceção Ela é a única variável lida **fora** da parte `root`, no próprio elemento. Declará-la em `::part(root)` não tem efeito nenhum: o valor chega tarde demais na árvore. Use o seletor do elemento: ```css pumahelp-widget { --pw-font: 'Inter', system-ui, sans-serif; } ``` ::: | Variável | Padrão (claro) | Padrão (escuro) | O que pinta | |---|---|---|---| | `--pw-accent` | `#f97116` | — | Cor de destaque: botão flutuante, botão de enviar, nota escolhida | | `--pw-accent-contrast` | `#140801` | — | Texto e ícones **sobre** a cor de destaque | | `--pw-accent-strong` | destaque 78% com preto | destaque 85% com branco | Anel de foco, borda do botão flutuante, seta de enviar acesa | | `--pw-accent-tint` | destaque a 14% sobre branco | destaque a 22% sobre o painel | Áreas grandes: balão do visitante e nota escolhida | | `--pw-bg` | `#ffffff` | `#1c1a19` | Fundo do painel | | `--pw-bg-subtle` | `#faf8f6` | `#262321` | Fundos secundários: cabeçalho, campos, cartões | | `--pw-fg` | `#1c1917` | `#ece8e4` | Texto principal | | `--pw-fg-muted` | `#78716c` | `#a8a29e` | Texto secundário: datas, legendas | | `--pw-border` | `#eae5e1` | `#3a3532` | Bordas e divisórias | | `--pw-bubble-agent` | `#f4f1ee` | `#2b2725` | Balão de quem atende | | `--pw-bubble-user` | `--pw-accent-tint` | — | Balão do visitante | | `--pw-bubble-user-fg` | `--pw-fg` | — | Texto do balão do visitante | | `--pw-danger` | `#dc2626` | `#f87171` | Erros e ações destrutivas | | `--pw-success` | `#16a34a` | `#4ade80` | Confirmações | | `--pw-radius` | `16px` | — | Arredondamento | | `--pw-shadow` | sombra suave | mais densa | Sombra do painel e do botão | | `--pw-badge` | `#ef4444` | — | Contador de não lidas sobre o botão | | `--pw-launcher-size` | `56px` | — | Tamanho do botão flutuante; ícone e contador acompanham | | `--pw-font` | pilha do sistema | — | Família tipográfica — **só no seletor do elemento**, não em `::part(root)` | | `--pw-z` | `2147483000` | — | Camada. Prefira `data-z-index` | ### Partes Para ajustes que variáveis não alcançam: ```css pumahelp-widget::part(launcher) { inset-inline-end: 32px; inset-block-end: 32px; } pumahelp-widget::part(header) { background: #0f172a; } pumahelp-widget::part(send) { border-radius: 999px; } pumahelp-widget::part(brand) { color: #94a3b8; } pumahelp-widget::part(rating) { border-radius: 4px; } ``` | Parte | O que é | |---|---| | `root` | Raiz do widget. É onde ficam as variáveis de tema | | `launcher` | Botão flutuante | | `badge` | Contador de não lidas sobre o botão | | `launcher-label` | Texto ao lado do ícone do botão flutuante. Vazio sem `data-launcher-label` | | `launcher-image` | A sua logo no botão flutuante, quando `data-icon` é uma URL | | `surface` | Painel da conversa | | `header` | Cabeçalho do painel | | `body` | Área rolável das mensagens | | `footer` | Rodapé do painel | | `composer` | O formulário de escrever — a área toda, não o campo de texto, que não tem parte própria | | `send` | Botão de enviar | | `brand` | Link "Criado com PumaHelp" no pé do painel | | `rating` | Cartão de avaliação. Vale tanto para a pergunta quanto para o "Você avaliou: …" | | `rating-option` | Cada uma das três opções de nota | | `rating-option-selected` | A opção escolhida. Vem **junto** de `rating-option`, nunca sozinha | | `rating-comment` | Campo de comentário da avaliação | | `rating-submit` | Botão de enviar a avaliação | :::note `::part()` alcança o elemento inteiro, mas não os filhos dele — não existe `::part(header) .titulo`. Se o que você precisa não está na lista, o caminho é uma variável CSS. Também não existe seletor de atributo depois de `::part()`: `::part(rating-option)[aria-pressed]` não é CSS válido. É por isso que a opção escolhida carrega um segundo nome — estilize o estado com `::part(rating-option-selected)`. ::: ### Claro e escuro Com `data-theme="auto"` (o padrão) o widget segue o sistema do visitante e troca junto quando ele troca. Para fixar, use `light` ou `dark`. Para dar valores diferentes em cada tema, use a media query normal — o widget não exige nada especial: ```css pumahelp-widget::part(root) { --pw-accent: #0ea5e9; } @media (prefers-color-scheme: dark) { pumahelp-widget::part(root) { --pw-accent: #38bdf8; } } ``` --- ## 🌍 Textos e idiomas Vêm prontos **pt-BR** (padrão), **en** e **es**: ```html ``` Qualquer texto pode ser sobrescrito, em qualquer idioma. O que você não informar cai no idioma escolhido, e o que faltar nele cai no pt-BR — nunca fica em branco. ```js pumahelp('init', { appName: 'acme', apiKey: 'rk_live_...', translations: { title: 'Fale com a gente', welcomeHint: 'Qual é a sua dúvida? Escreva aqui e a gente responde.', ratingQuestion: 'A gente resolveu o seu problema?', }, }); ``` Em HTML, o mesmo objeto vai como JSON no atributo: ```html ``` Chaves disponíveis: | Grupo | Chaves | |---|---| | Geral | `title`, `close`, `back`, `retry`, `offline` | | Lista | `listError`, `listLoadMore`, `listClosed`, `newConversation`, `previewYou` | | Nova conversa | `welcome`, `welcomeHint`, `newPlaceholder`, `newError` | | Conversa | `chatError`, `chatPlaceholder`, `chatPlaceholderOffline`, `chatSend`, `chatTyping`, `chatReceived` | | Mensagem | `messageSending`, `messageFailed`, `discard`, `unreadOne`, `unreadMany` | | Anexos | `attach`, `attachments`, `attachmentsPending`, `attachmentUploading`, `attachmentRemove`, `attachmentsTooMany`, `attachmentError` | | Avaliação | `ratingQuestion`, `ratingGood`, `ratingNeutral`, `ratingBad`, `ratingCommentPlaceholder`, `ratingSubmit`, `ratingThanks`, `ratingGiven`, `ratingChange`, `ratingCancel`, `ratingError` | | E-mail | `emailInvite`, `emailInviteHint`, `emailPlaceholder`, `emailSubmit`, `emailDismiss`, `emailSent`, `emailCodeLabel`, `emailCodeSubmit`, `emailResend`, `emailResendIn`, `emailCodeInvalid`, `emailCodeExpired`, `emailTooMany`, `emailUnavailable`, `emailChange`, `emailError`, `emailConfirmed` | | Situação | `statusSolved`, `statusClosed` | | Notificação do navegador | `notificationTitle`, `notificationBody` | | Botão flutuante | `launcherOpen`, `launcherClose` — o nome do botão para leitores de tela quando não há `data-launcher-label`; `unreadOne` e `unreadMany` também entram na contagem anunciada | Três textos têm um marcador que o widget preenche: `emailSent` recebe o endereço em `{email}`, `emailResendIn` os segundos que faltam em `{seconds}` e `attachmentsTooMany` o limite de arquivos em `{max}`. Ao sobrescrevê-los, mantenha o marcador — sem ele, o texto aparece sem essa informação. --- ## 💬 O que o widget faz ### Tempo real As respostas de quem atende chegam sozinhas, sem recarregar nem consultar em intervalos. O estado da conexão é observável pelo evento `pumahelp:realtime`, e a reconexão é automática. Conversas longas carregam o histórico aos poucos, a partir do topo, e os dias ficam separados ("Hoje", "Ontem", data). Com a aba em segundo plano, a resposta também vira uma **notificação do navegador** — quando o site tem permissão de notificações, que o widget só pede no momento em que o visitante escolhe acompanhar a conversa por e-mail, nunca por conta própria. Os textos são as chaves `notificationTitle` e `notificationBody` em `data-translations`. :::note A conexão existe enquanto o painel existir O widget conecta na primeira vez que o painel é aberto e mantém a conexão depois, mesmo com o painel fechado — é o que faz o contador do botão subir quando a equipe responde. Antes do primeiro clique não há conexão: quem volta com sessão vê o contador pela contagem do servidor, sem tempo real. ::: ### Anexos O visitante anexa **pelo clipe ou colando** com `Ctrl+V` — não há arrastar e soltar. Até **20 arquivos por mensagem**, de até **50 MB cada**. Os dois limites são recusados na hora, com aviso: nada sobe para falhar depois. Um arquivo pode ir **sozinho, sem texto** — uma foto do problema é uma mensagem completa, e na lista de conversas ela aparece como `📎 nome-do-arquivo`. ### Envio otimista e fila offline A resposta aparece na conversa assim que a pessoa envia, marcada como "enviando". Se a rede cair, ela fica marcada como não enviada e **sai sozinha quando a conexão voltar** — nada do que foi escrito se perde. Reenviar não duplica: cada mensagem carrega um identificador próprio, e o servidor reconhece a repetição. Ao abrir uma conversa, a linha **"Recebemos sua mensagem. Respondemos por aqui."** fica abaixo da primeira mensagem até a equipe responder — é o que diz ao visitante que chegou, sem aviso que some. O texto é a chave `chatReceived` em `data-translations`. :::note A fila vale para responder, não para abrir **Abrir uma conversa nova** exige rede: o botão de enviar fica desabilitado enquanto o navegador se declara offline. A fila cobre as respostas dentro de uma conversa que já existe. ::: ### Avaliação do atendimento Quando a conversa é marcada como resolvida ou encerrada, o visitante recebe a pergunta de satisfação com três opções. **A nota é registrada no clique** — não há botão de confirmar, e fechar o painel em seguida não perde nada. É nesse momento que `pumahelp:rated` dispara e que a nota passa a aparecer nos relatórios de satisfação do painel. Logo depois, o cartão agradece e oferece um campo de comentário. Escrever é opcional: quem não quiser, já terminou. Quem já avaliou não é perguntado de novo — a avaliação existente volta junto da conversa e o widget mostra a nota dada, com a opção **"Trocar avaliação"** para corrigir um clique errado (vale sempre a última nota; nos relatórios ela continua contada na data da primeira avaliação). A avaliação fica disponível por **30 dias** após a última resolução; a API informa o prazo em `rateable_until` e o widget esconde os botões depois dele. Uma nota dada em outro dispositivo aparece assim que a conversa é recarregada. #### Não perguntar Se o seu produto já mede satisfação por outro caminho — ou se este widget atende um público para quem a pergunta não faz sentido —, desligue: ```html ``` E no ar, quando a decisão depender de quem está do outro lado: ```js pumahelp('update', { collectRating: false }); ``` Desligar esconde o cartão, nada mais: uma avaliação já dada continua valendo e continua nos relatórios do painel. #### Não pedir comentário Por padrão o widget oferece o campo de comentário depois da nota. Com `data-rating-comment="false"` ele não aparece: a nota é gravada no clique e o cartão recolhe na hora. Use quando o número lhe basta e você não quer prender a atenção do visitante. Em qualquer um dos dois casos, quem se enganar tem a opção "Trocar avaliação" enquanto o prazo estiver aberto. #### Trocar a aparência e os textos O cartão é estilizável de fora pelas partes `rating`, `rating-option`, `rating-option-selected`, `rating-comment` e `rating-submit` — veja [Partes](#partes): ```css pumahelp-widget::part(rating) { border-radius: 4px; border-color: #cbd5e1; } pumahelp-widget::part(rating-option) { font-size: 22px; padding: 12px 6px; } pumahelp-widget::part(rating-option-selected) { background: #0f172a; color: #fff; } pumahelp-widget::part(rating-submit) { border-radius: 999px; } ``` A pergunta e os rótulos são textos como quaisquer outros — inclusive emoji, que é o que combina com as opções largas do exemplo acima: ```js pumahelp('init', { appName: 'acme', apiKey: 'rk_live_...', translations: { ratingQuestion: 'A gente resolveu o seu problema?', ratingGood: '😃', ratingNeutral: '😐', ratingBad: '😞', ratingCommentPlaceholder: 'Conta pra gente o que aconteceu', ratingSubmit: 'Enviar', }, }); ``` A lista completa das chaves está em [Textos e idiomas](#-textos-e-idiomas). ### Conversa encerrada Uma conversa **encerrada** é definitiva: o widget mostra "Esta conversa foi encerrada." no lugar do campo de mensagem e o botão **"Continuar em uma nova conversa"**. O que o visitante escrever a partir daí abre uma conversa nova, ligada à encerrada (a API responde `201` com ela, e o painel mostra "Acompanhamento de #N"); o widget navega para a conversa nova e anuncia "Nova conversa criada". Uma conversa **resolvida** funciona de outro modo: a resposta do visitante a reabre. Na lista, "Encerrada" aparece em cinza e "Resolvida" em verde. ### Aviso sonoro Dois sons curtos: um marca a mensagem enviada, outro a resposta recebida — este só toca quando a conversa não está na frente da pessoa. Cada um tem o seu interruptor. Só o de envio desligado: ```html ``` Só o de recebimento desligado: ```html ``` Silêncio total: ```html ``` Em `init()`, são `soundEnabled`, `sendSoundEnabled` e `receiveSoundEnabled`. ### Marca No pé do painel há um link discreto **"Criado com PumaHelp"**, traduzido junto com o resto dos textos (em inglês, "Powered by PumaHelp"). Para escondê-lo: ```html ``` Em `init()`, é `branding: false`. O texto pode ser trocado por `data-translations` com a chave `poweredBy`, e o estilo ajustado por `::part(brand)` — veja [Partes](#partes). ### Acessibilidade O painel é um diálogo de verdade: `Esc` fecha, o foco fica preso enquanto está aberto e volta para o botão ao sair. Mensagens novas e erros são anunciados por região viva para leitores de tela. Ao abrir o painel ou entrar numa conversa, o foco vai para o campo de mensagem — no celular ele fica no título, para o teclado não cobrir a conversa. Se preferir que o foco nunca vá a um campo por conta própria, `data-auto-focus="false"` (ou `autoFocus: false` no `init()` e no `update`) o mantém no título. --- ## 📧 E-mail e continuidade entre dispositivos Depois que a conversa já existe — nunca antes — o widget pode oferecer ao visitante acompanhar por e-mail: um cartão discreto dentro da conversa, logo abaixo da primeira mensagem dele. Quem o dispensa com **Agora não** só volta a vê-lo depois de uma semana. Quem informa o endereço passa à etapa do código, que continua na tela mesmo se a página for recarregada, pelos 10 minutos em que o código vale — quando a sessão fica guardada no navegador, que é o padrão. O convite vem **desligado**. Ligue com `data-collect-email="true"` na tag, `collectEmail: true` no `init()` — ou só quando fizer sentido, com `update`. O caso típico: seu site identifica quem está logado pelo provedor de token, e só oferece o e-mail a quem ficou anônimo: ```js pumahelp('auth', async () => { const token = await meuBackend.tokenDoSuporte(); // null quando ninguém está logado if (!token) pumahelp('update', { collectEmail: true }); return token; }); // E se a identidade expirar no meio da conversa: pumahelp('on', 'pumahelp:session-lost', () => pumahelp('update', { collectEmail: true })); ``` Informado o endereço, o PumaHelp envia um **código de 6 dígitos**, digitado ali mesmo, no widget em que o e-mail foi informado. **Nada é gravado antes disso.** O e-mail não traz link: abri-lo no celular não muda nada no computador. - o código vale por **10 minutos**, e uma vez só; - **Reenviar código** manda outro a cada 60 segundos, e o novo cancela o anterior; - **Usar outro e-mail** volta ao endereço, já preenchido, para corrigir um erro de digitação; - confirmar numa aba vale para as outras abas do mesmo site, no mesmo navegador — quando a sessão fica guardada no navegador, que é o padrão. Confirmado, duas coisas passam a valer: - a equipe vê o e-mail no ticket, marcado como **verificado**; - a pessoa pode **continuar a conversa em outro dispositivo**: no convite de lá, ela informa o mesmo e-mail e digita o código que chegar, e as conversas dos dois aparelhos se juntam. ### Segurança do código - **Só vale onde foi pedido.** O código confirma o endereço no widget em que ele foi informado, no mesmo navegador. Digitado em outro navegador ou aparelho, é recusado. É por isso que ler o e-mail no celular e digitar o código no computador funciona. - **Tentativas limitadas.** Cada código aceita 5 tentativas; depois disso, é preciso pedir outro. Cada endereço recebe no máximo 5 códigos por hora e aceita 10 tentativas por dia, somando todos os visitantes, e cada visitante pede até 10 códigos por dia. Passado um limite, o widget pede para tentar mais tarde. - **O e-mail avisa.** A mensagem pede para não compartilhar o código e diz que a equipe de atendimento nunca vai pedi-lo — garanta que a sua equipe, de fato, nunca peça. O código não vai no assunto, que aparece nas notificações da tela bloqueada. - **O dono de um endereço não é revelado.** Pedir o código tem a mesma resposta para qualquer endereço, e informar o e-mail de outra pessoa não dá acesso a nada: o código vai para a caixa dela, não para a tela de quem digitou. Os limites de um endereço valem para todos os visitantes juntos, então pedidos em excesso para ele podem atrasar em até um dia a confirmação da própria dona. --- ## 📘 TypeScript Os tipos são publicados ao lado do bundle. Aponte para eles uma vez: ```ts /// ``` Ou baixe o `pumahelp-widget.d.ts` e inclua no `include` do seu `tsconfig.json`. **Em React, inclua também o `pumahelp-widget-react.d.ts`**, publicado ao lado — é ele que declara a tag para o JSX. A partir daí, `window.pumahelp` é tipado — inclusive o detalhe de cada evento: ```ts pumahelp('on', 'pumahelp:ticket-created', ({ ticketId, subject }) => { // ^ number ^ string console.log(ticketId, subject); }); ``` Com o arquivo de React incluído, usar a tag direto num componente não acusa erro de elemento desconhecido: ```tsx ``` `init()` também é tipado: ele devolve o elemento, com `openPanel`, `closePanel`, `toggle`, `isOpen`, `getUnread`, `identify`, `logout`, `destroy` e `instanceId`. É o que permite dirigir uma segunda instância sem `as`. :::important Não copie definições de tipo de documentação para dentro do seu projeto — uma cópia envelhece em silêncio. Referencie o arquivo publicado, que acompanha a versão do widget que você está usando. ::: --- ## 🌐 Integração com frameworks Na maioria dos casos você não precisa de nada disso: a tag de script no `index.html` resolve, e o widget é indiferente a qual framework desenha o resto da página. Os exemplos abaixo servem para quando o widget precisa reagir ao ciclo de vida da aplicação. ### React ```tsx import { useEffect } from 'react'; export function Suporte({ usuario }: { usuario?: { token: string } }) { useEffect(() => { if (!usuario) return; // `identify` vale por carregamento de página: este efeito roda de novo a cada montagem, que é // exatamente o que se quer. Para não depender disso, registre um provedor de token. window.pumahelp('identify', { token: usuario.token }); }, [usuario]); useEffect(() => { const parar = window.pumahelp('on', 'pumahelp:session-lost', async () => { const { token } = await fetch('/api/suporte/token').then((r) => r.json()); window.pumahelp('identify', { token }); }); return () => parar?.(); }, []); useEffect(() => { // `on` devolve a função que cancela a inscrição — é ela que vai no cleanup. const parar = window.pumahelp('on', 'pumahelp:ticket-created', ({ ticketId }) => { console.log('conversa aberta', ticketId); }); return () => parar?.(); }, []); return null; } ``` ### Vue 3 ```html ``` ### Angular ```ts import { Component, OnDestroy, OnInit } from '@angular/core'; @Component({ selector: 'app-suporte', template: ``, }) export class SuporteComponent implements OnInit, OnDestroy { private parar?: () => void; ngOnInit(): void { this.parar = window.pumahelp('on', 'pumahelp:ticket-created', ({ ticketId }) => { console.log('conversa aberta', ticketId); }); } ngOnDestroy(): void { this.parar?.(); } abrir(): void { window.pumahelp('open'); } } ``` :::note Se preferir colocar a tag `` direto num template Angular, o componente precisa de `schemas: [CUSTOM_ELEMENTS_SCHEMA]` — sem isso o Angular acusa `NG0304`. Usar só a tag de script, ou o `init()`, evita o assunto. ::: ### Aplicações de página única O widget não é remontado a cada troca de rota e não precisa ser. Se você realmente quiser removê-lo de uma área do site: ```js pumahelp('destroy'); ``` --- ## 🔁 Migrando da v2 Cada versão é uma release própria no CDN: a v2.1.1 continua publicada em `https://cdn.pumahelp.com/widget/v2.1.1/widget.js` e continua atendida pela API, e a v3 está em `https://cdn.pumahelp.com/widget/v3/widget.js`. Nada muda no seu site enquanto você não trocar a URL do script — a migração é exatamente essa troca, mais os ajustes da tabela abaixo. | v2 | v3 | |---|---| | `https://cdn.pumahelp.com/widget/v2.1.1/widget.js` | `https://cdn.pumahelp.com/widget/v3/widget.js` | | `` | `` — ou nenhuma tag, só o script | | `window.PumaHelp.init({...})` | `pumahelp('init', {...})`, e funciona antes de carregar | | `app-name` / `api-key` | `data-app` / `data-api-key` | | `PumaHelp.identify({ accessToken })` | `pumahelp('identify', { token })` — `accessToken` ainda é aceito | | Identidade sobrevivia ao F5 | Vale por carregamento; use um provedor de token para durar | | Evento `auth-error` na queda da sessão | `pumahelp:session-lost` | | `identify({ name })` alimentava o nome do convidado | O nome vem do servidor; `name` não é mais usado | | `widget.on('ready', fn)` | `pumahelp('on', 'pumahelp:ready', fn)` | | Evento `error`, sem prefixo | `pumahelp:error` | | `event.detail.payload` | Detalhe tipado por evento — veja a tabela de eventos | | `debug` | Não existe. Erros saem por `pumahelp:error` | | `theme`, `language`, `color` | `data-theme`, `data-language`, `data-color` | | Sem tipos publicados | `.d.ts` no CDN | | Chave de API obrigatória | `pumahelp('auth', fn)` dispensa a chave | | Sem avaliação | Avaliação ao resolver a conversa | Três diferenças que costumam pegar quem migra: 1. **Booleano é booleano.** Na v3, `"false"`, `"0"`, `"no"`, `"off"` e vazio desligam; qualquer outro valor liga. Confira os atributos booleanos ao migrar. 2. **`embedded` embute de verdade.** Na v3, embutido é embutido: sem botão flutuante, ocupando o container. Se você usava `embedded` na v2, confira o resultado na tela. 3. **Eventos têm prefixo e detalhe tipado.** Se o seu código lia `event.detail.ticketId` na v2, ele lia `undefined`: o detalhe vinha em `event.detail.payload`. Na v3, `ticketId` está onde a tabela de eventos diz que está. 4. **A identidade não sobrevive mais ao recarregamento sozinha.** A v3 não guarda o token de acesso, então quem usa só `identify()` precisa chamá-lo a cada carregamento. O provedor de token resolve isso de vez, e é o caminho recomendado. --- ## ❓ Perguntas frequentes **A chave de API no HTML não é um risco?** Ela é pública, e é por isso que precisa ser restrita ao escopo de criar convidado. Com esse escopo, o que um visitante mal-intencionado consegue fazer com ela é abrir uma conversa — que é exatamente o que o widget faz. Se você tem backend e prefere não expor nada, use o provedor de token. **O widget conflita com o CSS ou o JavaScript do meu site?** Não. Ele roda dentro de um Shadow DOM: o seu CSS não entra, o dele não vaza. Os eventos são todos prefixados justamente para não colidir com os seus. **Preciso avisar sobre cookies?** O widget não usa cookies. No armazenamento local do navegador ele guarda a credencial de sessão e, se o visitante dispensar o convite de e-mail, a data em que o fez. Com o [convite de e-mail](#-e-mail-e-continuidade-entre-dispositivos) ligado, guarda também o **endereço informado** enquanto o visitante está na etapa do código: ele sai ao confirmar, ao trocar de endereço ou quando o código vence — e, se a página for fechada antes, na próxima vez que o widget carregar no seu site. Com `data-persist-session="false"`, a credencial e o endereço ficam só em memória e somem ao fechar a aba; só a data da dispensa continua guardada. **O visitante perde o histórico se limpar o navegador?** Perde, se for anônimo — é o que "anônimo" significa. Com `identify()` ou com o provedor de token, o histórico está atrelado à identidade do seu site e volta em qualquer dispositivo. Com e-mail confirmado, basta informar o mesmo e-mail no widget e digitar o código: o histórico volta. **Dá para abrir o widget a partir de um botão meu?** Sim: `pumahelp('open')`. Funciona mesmo antes de o widget carregar. **Dá para ter mais de um widget na mesma página?** Sim, mas os comandos de `window.pumahelp` agem sempre sobre a **primeira** instância. Para dirigir a segunda, guarde o que `init()` devolve e chame os métodos do próprio elemento: ```js const suporte = pumahelp('init', { appName: 'acme', apiKey: 'rk_live_...' }); const vendas = pumahelp('init', { appName: 'acme', apiKey: 'rk_live_...', container: caixa }); vendas.openPanel(); // pumahelp('open') abriria o primeiro ``` Em TypeScript funciona direto: `init()` é tipado e devolve o elemento com todos esses métodos. **O widget funciona sem JavaScript ou em navegador antigo?** Não. Ele é um módulo ES e depende de Custom Elements e Shadow DOM — o que qualquer navegador com atualização automática tem desde 2018. --- ## Bot do Discord Conecte seu servidor do Discord ao PumaHelp: membros abrem tickets sem sair do Discord, cada ticket vira uma thread privada, e as respostas da sua equipe — enviadas pelo dashboard — chegam direto na conversa. ## 🚀 Antes de começar Você vai precisar de: - **Administrador** no servidor do Discord onde o bot será instalado; - acesso de **Owner ou Admin** na sua organização no PumaHelp (para criar a API Key). A configuração completa leva cerca de 10 minutos. ## 1. Convide o bot para o servidor Use o link oficial de convite: **[discord.com/oauth2/authorize?client_id=1407072685565153350](https://discord.com/oauth2/authorize?client_id=1407072685565153350)** O Discord vai pedir sua confirmação das permissões que o bot usa: ver o canal escolhido e enviar mensagens nele, criar a thread privada de cada ticket e escrever dentro dela, publicar as mensagens formatadas (o card de abertura e as respostas da sua equipe), mencionar o cargo de suporte para trazê-lo à thread, arquivar e trancar a thread quando o ticket é fechado, e apagar mensagens com menções indevidas dentro dos tickets. ## 2. Crie a API Key no Dashboard A API Key é como o bot se autentica na sua organização — e criar a do bot leva menos de um minuto: 1. No dashboard, acesse **Configurações → API Keys** e clique em **Criar Chave**. 2. Em *"Para que esta chave será usada?"*, clique em **Bot do Discord** — as permissões necessárias são selecionadas automaticamente e o nome é preenchido. 3. Clique em **Gerar Chave**. 4. **Copie a chave agora.** Por segurança, ela é exibida uma única vez. Se perder, basta gerar outra. ## 3. Configure o bot com `/config` No seu servidor do Discord, digite **`/config`** (exige permissão de Administrador). Abre um painel — visível só para você — mostrando o estado de cada parte e o que ainda falta: | Seção | O que configurar | |---|---| | **Discord** | O canal onde as threads de ticket serão criadas e, opcionalmente, o cargo de suporte | | **PumaHelp** | O subdomínio da sua organização (ex.: `acme`) e a **API Key** do passo 2 | | **Categorias** | Os tipos de ticket que aparecem no menu de abertura | | **Painel** | Onde publicar a mensagem de abertura de tickets | | **Avaliação** | Liga e desliga a pergunta de satisfação na thread — um clique, já vem ligada | :::tip Seguro por padrão Tudo é preenchido em janelas próprias (modais) — nenhuma credencial é digitada no chat nem fica visível para outros membros. Ao salvar, o bot valida a API Key na hora: se algo estiver errado, você fica sabendo imediatamente. E ao editar depois, campos de credencial deixados em branco mantêm o valor já salvo. ::: :::note E o webhook? Ao salvar a API Key, o bot **configura sozinho o webhook de retorno** na sua organização — é por ele que as respostas dos agentes chegam ao Discord. Você verá a confirmação na própria resposta do `/config` ("webhook criado" ou "reaproveitado"). Se a sua Key não tiver as permissões de webhook, veja a [configuração manual](#configurar-o-webhook-manualmente) no fim da página. ::: ### Sobre o cargo de suporte - **Com cargo**: ele é mencionado na abertura de cada ticket — é essa menção que adiciona a equipe à thread privada e a notifica. - **Sem cargo**: o ticket abre sem menção; apenas o solicitante (e quem tem a permissão *Gerenciar Threads*) enxerga a thread no Discord. A equipe acompanha e responde pelo dashboard normalmente. ## 4. Crie as categorias e publique o painel 1. Em `/config` → **Categorias**, crie ao menos uma (ex.: "Suporte Técnico", "Financeiro"). Cada categoria define o assunto e as tags do ticket criado no PumaHelp, e pode ser associada a um dos seus grupos. 2. De volta ao painel do `/config`, clique em **Publicar painel** e escolha o canal. A mensagem com o menu de abertura aparece lá, pronta para os membros usarem. ## 💬 No dia a dia - **Abrir ticket**: o membro escolhe a categoria no painel, descreve o problema e pode anexar até 10 arquivos (50 MB cada). Uma thread privada é criada na hora, e o ticket aparece no dashboard. - **Responder**: pelo botão **Responder** na thread — com texto, anexos ou ambos. - **Anexo que não chega**: se um arquivo não puder ser enviado ao suporte, o bot avisa quem mandou e cita o nome dele. Quando a mensagem era só de anexos e nenhum chegou, nada é enviado ao ticket; quando parte chegou, o resto da mensagem é enviado e o aviso diz quais arquivos faltaram. - **Respostas da equipe**: enviadas pelo dashboard, chegam automaticamente à thread do Discord. - **Resolução**: quando o ticket é resolvido (pelo agente ou pelo sistema, se a organização resolve automaticamente tickets aguardando o cliente), o membro pode responder para reabri-lo; sem resposta, a thread é arquivada automaticamente pelo bot (7 dias) e o ticket é fechado pelo PumaHelp no prazo configurado na organização — dois relógios independentes. - **Ticket fechado**: é definitivo. A thread é arquivada e trancada, com o aviso para abrir um novo ticket. Se a thread ainda estiver aberta quando o ticket já foi fechado, a próxima mensagem de quem abriu o ticket abre um **ticket de acompanhamento** — o bot avisa *"Este ticket já estava encerrado. Sua mensagem abriu o ticket #N, e a conversa continua aqui."*, e as respostas da equipe no novo ticket chegam à mesma thread. A mensagem de outra pessoa nessa situação não é enviada, e a thread é encerrada. - **Avaliação**: junto da mensagem de "Ticket Resolvido" aparecem três botões — 🙂 Bom, 😐 Neutro, 🙁 Ruim. ## ⭐ Avaliação de atendimento Quando um ticket é marcado como resolvido, o bot pergunta na própria thread como foi o atendimento. A resposta entra no **Relatório de Satisfação** do dashboard, junto com as avaliações que chegam pelo widget. - **Quando aparece**: no momento em que o ticket passa a *resolvido*. É a única janela em que a thread ainda está aberta e o ticket já pode ser avaliado. Um ticket fechado direto, sem passar por resolvido, não é avaliado. - **Quem pode responder**: só quem abriu o ticket. A equipe de suporte não avalia o próprio atendimento. - **Mudar de ideia**: se o ticket for reaberto e resolvido de novo, a pergunta reaparece e a nota nova substitui a anterior. ### Desligar Em **`/config`**, o último botão do painel mostra o estado atual — **Avaliação: ligada** ou **desligada**. Um clique inverte. A configuração vale por servidor do Discord: se a sua organização tem mais de um servidor, cada um decide o seu. ## 🔑 Permissões da API Key (referência) O preset **Bot do Discord** seleciona exatamente estas: | Permissão | Para quê | |---|---| | `ticket:create` | abrir tickets a partir do Discord | | `ticket:update` | enviar as respostas e mudar o status do ticket | | `file:upload` | enviar os anexos postados no Discord | | `group:read` | listar seus grupos ao configurar categorias | | `organization:read` | validar a Key e identificar sua organização | | `webhook:read` | encontrar o webhook de retorno, se já existir | | `webhook:create` | criar o webhook de retorno automaticamente | | `satisfaction:create` | registrar a avaliação que o cliente dá na thread | ## ❓ Problemas comuns **O comando `/config` não aparece.** Recarregue o Discord (`Ctrl+R` no desktop, `Cmd+R` no macOS). E lembre: apenas administradores do servidor enxergam o comando. **"Não consegui validar a API Key" ao salvar.** Confira o subdomínio (só o nome, sem `.pumahelp.com`) e se a Key foi criada com o preset **Bot do Discord**. Se você montou a Key manualmente, ela precisa incluir a permissão `organization:read`. **As respostas dos agentes não chegam ao Discord.** Abra o `/config` → **PumaHelp** e salve novamente — o bot re-verifica e reconfigura o webhook de retorno. Se a mensagem avisar que a Key não tem as permissões de webhook, crie uma nova Key com o preset **Bot do Discord** (que já as inclui) ou siga a [configuração manual](#configurar-o-webhook-manualmente). **Eu usava `/configticket` e `/ticketoptions`.** Foram substituídos — ao usá-los, o bot responde com um botão que abre o `/config`, onde toda a configuração é feita agora. ## Configurar o webhook manualmente Só é necessário se a sua API Key **não** tiver as permissões `webhook:read` e `webhook:create` (Keys criadas antes do preset atual, por exemplo) e você preferir não criar outra: 1. No dashboard, acesse **Configurações → Webhooks** e crie um novo webhook. 2. **URL**: `https://bot.pumahelp.com/webhook` 3. **Evento**: marque `ticket.updated` — é o único que o bot usa. 4. Salve, **copie a Webhook Key** e informe-a no campo *Webhook Secret* do `/config` → **PumaHelp**. O valor digitado manualmente sempre tem precedência sobre o automático.