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
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:
# 1. Fazer login
curl -X POST https://acme.pumahelp.com/api/v1/users/login \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"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:
# 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
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 para receber notificações em tempo real
- ✅ Configure scopes adequados para sua API Key
- ✅ Implemente tratamento de erros robusto
📝 Convenções de Nomenclatura
A API utiliza snake_case para todos os campos de request e response.
Exemplo:
{
"public_id": 12345,
"created_at": "2025-01-01T10:00:00Z",
"assignee_id": "uuid",
"public": true
}
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:
POST https://acme.pumahelp.com/api/v1/users/login
Content-Type: application/json
{
"email": "[email protected]",
"password": "sua-senha"
}
Resposta:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"verified": true
}
Como usar:
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:
- Faça login na plataforma PumaHelp
- Navegue até Configurações → API Keys
- Clique em "Criar Nova API Key"
- Selecione os scopes necessários
- Copie o secret (visível apenas uma vez!)
Como usar:
GET https://acme.pumahelp.com/api/v1/tickets
X-API-Key: seu-api-key-secret-aqui
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
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.
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 |
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 — 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: 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.
📊 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 — 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.
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:
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 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
POST https://acme.pumahelp.com/api/v1/tickets
Authorization: Bearer {token}
Content-Type: application/json
Scope: ticket:create
Request Body:
{
"subject": "Problema com login",
"priority": "high",
"type": "question",
"group_id": "uuid-do-grupo",
"requester": {
"name": "Cliente Nome",
"email": "[email protected]"
},
"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 |
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 |
Response: 201 Created
{
"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.
Listar Tickets
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:pendingtraz tickets abertos ou pendentes. Vale para todas as chaves —assignee:joao assignee:mariatraz 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:hightraz apenas os que são abertos e de prioridade alta. - Não existem operadores
AND/ORescritos. Se você escreverstatus:open AND priority:high, a palavraANDé tratada como texto de busca. Use apenas espaços. - Palavras sem
chave:são busca textual no assunto do ticket:boleto atrasadoprocura 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, created<today (antes de hoje). As datas são interpretadas em UTC.
me significa quem está fazendo a requisição: o usuário do token JWT ou, no caso de uma API Key, o usuário dono da chave.
Exemplos:
# Meus tickets sem resolução
assignee:me status:new status:open status:pending
# Tickets não atribuídos, urgentes
assignee:none priority:urgent
# Resolvidos nas últimas 24 horas
status:solved updated:last_24_hours
# Busca textual combinada com filtro
boleto status:open
# Frase exata no assunto
"nota fiscal" priority:high
Um valor que não existe para a chave (status:aberto, archived:sim, created:xpto) devolve 400 Bad Request com a mensagem indicando os valores aceitos. Uma chave desconhecida (foo:bar) não é erro — é tratada como texto de busca.
requester:, assignee: e group: casam por trecho do nome: requester:ana traz também "Mariana" e "Juliana". A correspondência é por trecho do nome — portanto filtrar por nome não isola um cliente.
Se você usa a API para mostrar a cada cliente final apenas os tickets dele, filtre por id, external_id ou group_id, que são comparações exatas:
# Correto — exato
requester:'3f2a9c10-4b8e-4c1a-9f2e-8a7b6c5d4e3f'
requester:'crm-4211'
group_id:'a1b2c3d4-...'
# Perigoso — traz homônimos
requester:'Ana'
Response: 200 OK
{
"count": 150,
"tickets": [
{
"id": "uuid",
"public_id": 12345,
"subject": "Problema com login",
"status": "open",
"priority": "high",
"type": "question",
"created_at": "2025-01-01T10:00:00Z",
"updated_at": "2025-01-01T12:00:00Z",
"solved_at": null,
"archived_at": null,
"group_id": "uuid",
"conversation_id": "uuid",
"tags": ["login", "urgent"],
"assignee": {
"id": "uuid",
"name": "Maria Santos",
"role": "agent",
"email": "[email protected]",
"verified": true
},
"requester": {
"id": "uuid",
"name": "João Silva",
"role": "end-user",
"email": "[email protected]",
"verified": true
},
"last_comment": {
"id": "uuid",
"body": "Último comentário",
"public": true,
"created_at": "2025-01-01T12:00:00Z",
"updated_at": null,
"author": {
"id": "uuid",
"name": "Maria Santos",
"role": "agent",
"email": "[email protected]",
"verified": true
},
"attachments": []
},
"via": {
"channel": "widget"
}
}
]
}
Obter Ticket por ID
GET https://acme.pumahelp.com/api/v1/tickets/{public_id}
Authorization: Bearer {token}
Scope: ticket:read
Response: 200 OK
{
"id": "uuid",
"public_id": 12345,
"subject": "Problema com login",
"status": "open",
"priority": "high",
"type": "question",
"created_at": "2025-01-01T10:00:00Z",
"updated_at": "2025-01-01T12:00:00Z",
"solved_at": null,
"archived_at": null,
"group_id": "uuid",
"conversation_id": "uuid",
"tags": ["login", "urgent"],
"assignee": {
"id": "uuid",
"name": "Maria Santos",
"role": "owner",
"email": "[email protected]",
"verified": true
},
"requester": {
"id": "uuid",
"name": "João Silva",
"role": "end-user",
"email": "[email protected]",
"verified": true
},
"pending_since": null,
"follow_up_of": null,
"rateable_until": null,
"unread_count": null,
"sla_cycles": [],
"satisfaction": null,
"via": {
"channel": "api"
}
}
Campos do ciclo de vida (ver Ciclo de vida do ticket):
| Campo | Descrição |
|---|---|
solved_at | Quando o ticket foi resolvido. Continua preenchido depois que o ticket é fechado; volta a nulo quando o ticket é reaberto |
pending_since | Desde quando o ticket aguarda o cliente. Nulo fora do status pending |
follow_up_of | { "public_id", "subject" } do ticket fechado que este continua. Nulo nos tickets comuns |
rateable_until | Até quando o cliente pode avaliar (última resolução + 30 dias) |
Linha do tempo do Ticket (eventos)
GET https://acme.pumahelp.com/api/v1/tickets/{public_id}/events?page=1&page_size=100&sort_order=desc
Authorization: Bearer {token}
Scope: ticket:events:read — só equipe (Owner, Admin, Agent). O usuário final recebe 403,
sempre, antes de qualquer consulta; uma chave de API precisa deste escopo (o ticket:read das chaves
voltadas ao cliente não basta). Agente fora do grupo do ticket e ticket de outra organização recebem
404, como no restante da API.
Tudo o que aconteceu com o ticket, do mais novo para o mais antigo: mudanças de status, responsável, prioridade, grupo, assunto, tipo, etiquetas, solicitante e arquivamento, supressões, avaliação do cliente, acompanhamento aberto e SLA estourado — inclusive o que o sistema fez sozinho (fechamento e resolução automáticos). A linha do tempo é visível apenas para a equipe; usuários finais nunca a recebem.
| Parâmetro | Tipo | Descrição |
|---|---|---|
page | int | Página (a partir de 1) |
page_size | int | 10 a 100 |
sort_order | string | desc (padrão) ou asc |
Response: 200 OK
{
"count": 3,
"events": [
{
"id": "8b1c…",
"occurred_at": "2025-01-09T14:32:10Z",
"type": "assigned",
"from_value": null,
"to_value": "7d2e…",
"from_label": null,
"to_label": "Maria Souza",
"actor": { "id": "3f9a…", "name": "João Lima", "type": "agent" }
},
{
"id": "8b1b…",
"occurred_at": "2025-01-09T14:32:10Z",
"type": "status_changed",
"from_value": "new",
"to_value": "open",
"from_label": null,
"to_label": null,
"actor": { "id": "3f9a…", "name": "João Lima", "type": "agent" }
},
{
"id": "8b1a…",
"occurred_at": "2025-01-09T14:30:00Z",
"type": "created",
"from_value": null,
"to_value": "new",
"from_label": null,
"to_label": null,
"actor": { "id": "c0d1…", "name": "Cliente Nome", "type": "end_user" }
}
]
}
| Campo | Descrição |
|---|---|
type | Tipo do evento (catálogo abaixo) |
from_value / to_value | O que foi gravado: descrição do enum (open, high, task…), id, texto. Nulo quando não se aplica |
from_label / to_label | Nome resolvido só nos tipos que guardam ids (assigned, requester_changed → usuário; group_changed → grupo). Nulo nos demais, e nulo quando o id não existe mais |
actor.type | end_user, agent, bot (token de organização / integração) ou system (ações automáticas da plataforma). É o carimbo do momento, nunca o papel atual do usuário |
actor.name | Nulo quando o ator não é um usuário (system) ou não existe mais (id continua) |
count | Total de eventos do ticket, não o tamanho da página |
Os eventos de uma mesma atualização (um PUT que muda status e prioridade) compartilham o
occurred_at e o ator: agrupe por esses dois campos para mostrar "mudou status e prioridade" numa
entrada só. A lista é append-only: nada é editado nem apagado.
Catálogo de eventos
type | from_value → to_value | Significado |
|---|---|---|
created | nulo → status inicial | Ticket aberto |
status_changed | status anterior → novo | Mudança de status (pelo agente, pelo cliente ao responder, ou pelo sistema — actor.type = system — no fechamento e na resolução automáticos) |
assigned | id do responsável anterior → do novo (nulo = sem responsável) | Atribuição, transferência ou remoção do responsável |
priority_changed | prioridade anterior → nova | |
group_changed | id do grupo anterior → do novo | |
subject_changed | assunto anterior → novo | |
type_changed | tipo anterior → novo | |
tags_changed | etiquetas anteriores → novas (nomes ordenados, separados por vírgula; nulo = nenhuma) | |
requester_changed | id do solicitante anterior → do novo | |
archived_changed | false → true ao arquivar, true → false ao desarquivar | Também pelo DELETE |
comment_added | nulo → public ou internal | Um comentário |
comment_made_private | nulo → id do comentário | Comentário público tornado interno |
comment_redacted | nulo → id do comentário | Supressão de conteúdo — nunca guarda o que foi removido |
attachment_redacted | nulo → id do anexo | Supressão de anexo |
satisfaction_rated | nota anterior (nulo na primeira) → nova | O cliente avaliou |
follow_up_created | nulo → public_id do acompanhamento | Gravado no ticket fechado quando a resposta do cliente abre um acompanhamento |
sla_breached | métrica (first_reply, next_reply, resolution) → prazo (ISO) | Um ciclo de SLA estourou; gravado uma única vez, com actor.type = system |
Alterações anteriores ao início do registro de cada tipo não constam da linha do tempo e não são reconstruíveis.
Atualizar Ticket
PUT https://acme.pumahelp.com/api/v1/tickets/{public_id}
Authorization: Bearer {token}
Content-Type: application/json
Scope: ticket:update
Request Body:
{
"subject": "Problema com login - RESOLVIDO",
"status": "solved",
"priority": "normal",
"type": "incident",
"requester_id": "uuid-novo-requester",
"assignee_id": "uuid-agente",
"group_id": "uuid-grupo",
"archived": false,
"tags": ["login", "resolved"],
"comment": {
"body": "Problema resolvido!",
"public": true,
"author_external_id": "discord-123456789",
"uploads": ["uuid-arquivo"],
"client_id": "9f1c2b7a-4e5d-4c3b-8a1f-2d6e7f8a9b0c"
}
}
| Campo | Tipo | Descrição |
|---|---|---|
subject | string | Novo assunto |
status | string | Novo status: new, open, pending, solved, closed. A partir de closed nenhum status é aceito (400): fechado é definitivo |
priority | string | Nova prioridade |
type | string | Novo tipo |
requester_id | uuid | Transferir para outro requester |
assignee_id | uuid | Atribuir a agente (use null para desatribuir) |
group_id | uuid | Atribuir a grupo |
archived | boolean | Arquivar ticket |
tags | array[string] | Substituir tags |
comment | object | Adicionar comentário com a atualização — texto em body, anexos em uploads, ou só os anexos. Em ticket fechado: a resposta do cliente abre um ticket de acompanhamento e a resposta é 201; qualquer outro comentário → 400 (abaixo) |
comment.author_id | uuid | Autor do comentário (se diferente do usuário autenticado) |
comment.author_external_id | string | Autor por external_id — para integrações que autenticam como staff mas postam em nome do cliente (ex.: bot do Discord). author_id tem precedência; ignorado para tokens de end-user; external_id desconhecido cai no usuário autenticado |
comment.client_id | string | Identificador que você gera para tornar a retentativa segura. Reenviar o mesmo valor na mesma conversa não cria um segundo comentário |
Todos os campos são opcionais. Para remover assignee, envie null explicitamente.
Reenvio seguro com client_id
O mesmo campo vale na criação de ticket: reenviar comment.client_id numa nova chamada a
POST /v1/tickets para o mesmo solicitante devolve o ticket que já foi criado, com o mesmo
public_id, em vez de abrir uma segunda conversa. É o que torna seguro retentar a abertura depois de
uma falha de rede.
O identificador vale por solicitante. Um client_id que já pertence a um ticket de outro
solicitante não tem efeito: o ticket novo é criado normalmente, sem ele. Por isso, ao informar
requester, identifique-o por id, email ou external_id: um requester só com name cria uma
pessoa nova a cada chamada, e a retentativa abre outro ticket.
Numa corrida entre dois envios simultâneos com o mesmo identificador, o perdedor recebe 409 — nunca
uma segunda conversa.
Uma falha de rede depois de o servidor já ter gravado é indistinguível, do lado do cliente, de uma
falha antes. Sem um identificador, retentar duplica a mensagem — ou devolve 400, quando havia
anexo, porque um upload é consumível uma única vez.
Gere um valor único por mensagem (um UUID serve), guarde-o enquanto a mensagem estiver pendente e
repita-o em cada tentativa. O segundo envio com o mesmo client_id é reconhecido como repetição e
não cria nada.
O valor volta em client_id na leitura dos comentários, o que permite casar a mensagem otimista
exibida na interface com a mensagem real quando ela chega.
Response: 204 No Content
Response: 201 Created — só quando o ticket está fechado e o comentário é a resposta do
cliente. Fechado é definitivo: a resposta não reabre o ticket, ela abre um ticket de
acompanhamento com o assunto, grupo, tipo, prioridade, tags e canal do original e o mesmo
comentário. O cliente é o solicitante e o autor do acompanhamento. O corpo é o ticket novo, no mesmo
formato da criação:
{
"id": "7c3a…",
"public_id": 12346,
"subject": "Problema com login",
"status": "new",
"created_at": "2025-01-09T10:00:00Z",
"conversation_id": "uuid-conversa-nova",
"follow_up_of": { "public_id": 12345, "subject": "Problema com login" }
}
A resposta é do cliente em dois casos:
- Token de end-user do solicitante (o widget, por exemplo).
- Integração (API Key, ou o token de organização
client_credentials, obsoleto) que responde em nome do cliente. Valem as quatro condições: o comentário é público e tem texto ou anexo; o autor vem explícito emcomment.author_idoucomment.author_external_id; esse autor é umend-user; e é o solicitante do ticket. A requisição pode trazer junto sóstatusopenouclosed, que não mudam nada; qualquer outro campo devolve400.
Um token de agente que envia o author_id do cliente continua sendo o agente escrevendo: 400.
O comment.client_id continua valendo: repetir a chamada com o mesmo identificador devolve o mesmo
acompanhamento, nunca um segundo. O ticket original não muda — só ganha o evento follow_up_created
apontando para o novo. Trate 201 para seguir a conversa no ticket de acompanhamento: numa integração
que repassa as mensagens do cliente (um bot de chat, por exemplo), as próximas mensagens vão para o
public_id da resposta. Clientes que esperavam 204 continuam recebendo uma resposta 2xx.
Response: 400 Bad Request — em ticket fechado, com error_code: "ticket_closed":
- um
statusdiferente declosed, fora da resposta do cliente descrita acima ("Ticket fechado não pode ser reaberto…"); - comentário da equipe, nota interna, ou comentário de integração que não identifica o solicitante ("Ticket fechado não aceita comentários…");
- a resposta do cliente acompanhada de outras alterações ("Ticket fechado: a resposta do cliente abre um ticket de acompanhamento e não pode vir com outras alterações. Envie só o comentário.").
{
"error_messages": ["Ticket fechado não aceita comentários. Uma resposta do cliente abre um ticket de acompanhamento."],
"error_code": "ticket_closed"
}
Compare o error_code, não o texto. Metadados (prioridade, tags, responsável, arquivar) continuam
editáveis num ticket fechado.
Arquivar Ticket
DELETE https://acme.pumahelp.com/api/v1/tickets/{public_id}
Authorization: Bearer {token}
Scope: ticket:delete
Response: 204 No Content
Tickets são arquivados (soft delete), não excluídos permanentemente.
Marcar Ticket como Lido
POST https://acme.pumahelp.com/api/v1/tickets/{public_id}/read
Authorization: Bearer {token}
Registra que quem está pedindo leu a conversa até agora. É o que zera o unread_count devolvido por
include=unread.
A leitura fica no servidor, e não no navegador: é o que faz o contador sobreviver à troca de dispositivo — quem leu no celular não reencontra as mesmas mensagens marcadas como novas no computador.
Response: 204 No Content
Avaliar Satisfação do Ticket (CSAT)
POST https://acme.pumahelp.com/api/v1/tickets/{public_id}/satisfaction
Authorization: Bearer {token}
Content-Type: application/json
Scope: satisfaction:create
Registra a avaliação de satisfação do solicitante — escala de 3 pontos. É um upsert: uma avaliação por ticket; enviar de novo atualiza a existente.
{
"score": "good",
"comment": "Resolveram rápido, obrigado!",
"author_external_id": "crm-123456",
"requested_at": "2026-08-27T12:00:00Z"
}
| Campo | Tipo | Descrição |
|---|---|---|
score | string | Obrigatório. good, neutral ou bad |
comment | string | Texto livre do cliente (máx. 1000 caracteres) |
author_id | uuid | Identifica o autor quando quem chama não é o end-user (API key / staff) |
author_external_id | string | Idem, por external_id — author_id tem precedência |
requested_at | datetime | Quando o convite de avaliação foi feito ao cliente (opcional, não pode ser futuro) |
Regras:
- Só tickets com status
solvedouclosedpodem ser avaliados — reabrir o ticket fecha a janela — e só até 30 dias após a última resolução (rateable_untilno ticket). Fora do prazo →400. - Quem não pode ver o ticket recebe
404, antes de qualquer outra checagem: um end-user em ticket alheio, um agente fora do grupo. A resposta não confirma que o ticket existe. - Com JWT de usuário final, só o próprio solicitante avalia. Staff não registra a nota de ninguém — nem o responsável, nem um colega — →
401. O token de end-user avalia o próprio ticket e os camposauthor_*são ignorados. - Integrações (API Key, ou o token de organização
client_credentials, obsoleto) identificam o cliente porauthor_id/author_external_id, obrigatórios: sem eles →400; identificação que não resolve →404; autor diferente do solicitante →401. - A escala é fixa em 3 pontos e o neutro conta no denominador: CSAT% =
good / (good + neutral + bad). - Cada gravação escreve um evento
satisfaction_ratedna linha do tempo do ticket, com a nota anterior e a nova. No relatório, a nota conta pela data da primeira resposta do cliente; reavaliar troca a nota, não o mês. requested_até aceito e armazenado; nenhum relatório o consome (campo reservado).
Response: 200 OK
{
"ticket_public_id": 42,
"score": "good",
"comment": "Resolveram rápido, obrigado!",
"rated_at": "2026-08-27T14:30:00Z",
"requested_at": "2026-08-27T12:00:00Z",
"created_at": "2026-08-27T14:30:00Z",
"updated_at": null
}
Relatório de Satisfação (CSAT)
GET https://acme.pumahelp.com/api/v1/reports/satisfaction?from=2026-08-01T00:00:00Z&to=2026-08-27T00:00:00Z&tz=America/Sao_Paulo
Authorization: Bearer {token}
Scope: satisfaction:read (ou reports:read)
Query Parameters:
| Parâmetro | Descrição |
|---|---|
from, to | Obrigatórios. Janela em ISO 8601 (UTC) |
tz | Fuso IANA para os buckets da série (default UTC) |
channel, type, priority, group_id, assignee_id, tag | Filtros de segmento, opcionais |
A série é indexada pela data da primeira resposta do cliente (não da resolução, e não da última reavaliação: reavaliar troca a nota, não o mês). solved_in_period/solved_with_rating medem a cobertura da coleta: CSAT de 95% com 4% de taxa de resposta é outra métrica.
Response: 200 OK
{
"granularity": "day",
"totals": {
"rated": 128,
"good": 104,
"neutral": 12,
"bad": 12,
"solved_in_period": 240,
"solved_with_rating": 121
},
"series": [
{ "bucket_start": "2026-08-01T00:00:00", "rated": 6, "good": 5 }
],
"recent_comments": [
{
"public_id": 42,
"subject": "Erro no pagamento",
"score": "bad",
"comment": "Precisei explicar duas vezes.",
"rated_at": "2026-08-26T18:12:00Z"
}
]
}
📊 Relatórios
Sete endpoints sob /v1/reports. Todos compartilham o mesmo filtro, o mesmo tratamento de fuso e as mesmas regras de leitura descritas abaixo.
Relatórios são da organização inteira, sem recorte por grupo. A leitura é de Owner e
Admin, ou de uma API Key com o scope reports:read selecionado. Um agente recebe 403.
Parâmetros Comuns dos Relatórios
Todos os endpoints de /v1/reports aceitam o mesmo conjunto de query parameters:
| Parâmetro | Tipo | Descrição |
|---|---|---|
from | datetime | Obrigatório. Início da janela, ISO 8601 em UTC. Valor sem indicador de fuso é lido como UTC |
to | datetime | Obrigatório. Fim da janela, ISO 8601 em UTC. Deve ser posterior a from e no máximo 366 dias depois |
tz | string | Fuso IANA usado para montar os buckets (ex.: America/Sao_Paulo). Default UTC |
channel | string | Filtro de segmento: api, discord ou widget |
type | string | Filtro de segmento: question, incident, problem ou task |
priority | string | Filtro de segmento: urgent, high, normal ou low |
group_id | uuid | Filtro de segmento: grupo responsável |
assignee_id | uuid | Filtro de segmento: agente atribuído |
tag | string | Filtro de segmento: uma tag (máx. 50 caracteres) |
Os filtros de segmento são opcionais e combinados por AND.
Regras de Leitura
Valem para os sete relatórios:
- Janela
[from, to)— início inclusivo, fim exclusivo. Dois períodos consecutivos nunca contam o mesmo ticket duas vezes. - Mediana e p90, nunca média. Tempos de atendimento têm cauda longa e a média não descreve a experiência de ninguém. Todo campo de percentil vem
nullquando a amostra é zero. - O denominador é sempre visível —
sample,assessed,rated,solved_in_period. Percentual sem denominador ao lado não é conclusão: 91% de aderência sobre 12 casos é ruído. - Buckets no fuso pedido, com zero-fill. A série cobre a janela inteira e bucket sem dado vem com zero em vez de sumir do gráfico.
bucket_starté horário local do fuso detz, serializado sem offset. - Granularidade automática, derivada do tamanho da janela e devolvida em
granularity: ≤ 48h →hour; ≤ 31 dias →day; ≤ 184 dias →week; acima disso →month. - Faixas de duração em escala logarítmica — 5, 15, 60, 240 e 1440 minutos, mais uma faixa aberta.
upper_bound_minutes: nullmarca a última. - Contagem de resolução ancora em
first_solved_at, imutável: reabrir um ticket em agosto não o tira do relatório de julho. Duração de resolução, ao contrário, é medida atélast_solved_at("full resolution") — a única métrica deliberadamente móvel: um ticket reaberto e resolvido de novo alonga e troca de mês. - O passado não muda. Além da contagem de resolução: a aderência de SLA conta cada ciclo uma vez, no primeiro instante em que viola ou conclui, e reabrir não move nem apaga essa avaliação; a satisfação conta pela primeira resposta do cliente, e reavaliar não move a nota de mês; o relatório de equipe (
/v1/reports/team) credita quem resolveu, e reatribuir não reescreve;reopenedsó conta reaberturas ocorridas dentro da janela. previousé a janela imediatamente anterior do mesmo tamanho, por subtração do intervalo — para março personalizado, o "anterior" é 29/jan–01/mar, não fevereiro.- Cache no servidor, por organização e filtro. Um relatório pode estar até este tempo atrasado:
| Relatório | Cache |
|---|---|
overview, team, sla | 1 minuto (misturam tendência com estado do instante) |
attention | 1 minuto |
volume, times, satisfaction | 5 minutos |
Visão Geral (Overview)
GET https://acme.pumahelp.com/api/v1/reports/overview?from=2026-08-01T00:00:00Z&to=2026-08-28T00:00:00Z&tz=America/Sao_Paulo
Authorization: Bearer {token}
Scope: reports:read (Owner/Admin)
Indicadores agregados do período, com comparação contra a janela anterior. current e previous têm exatamente a mesma forma para o cliente calcular deltas — previous é a janela imediatamente anterior, do mesmo tamanho. backlog é estado agora e por isso não tem período anterior.
| Campo | Descrição |
|---|---|
created | Tickets criados na janela |
resolved | Resoluções na janela, por first_solved_at |
reopened | Dos resolvidos na janela, quantos voltaram de solved para new/open/pending dentro da janela — uma reabertura em agosto não aumenta o reopened de julho |
first_reply_median_minutes / first_reply_sample | Mediana da 1ª resposta e o tamanho da amostra |
resolution_median_minutes / resolution_sample | Mediana da resolução e o tamanho da amostra |
backlog.unreplied | Abertos sem nenhuma resposta pública de agente |
backlog.age_buckets | Idade dos abertos em faixas de 1h, 4h, 24h, 72h, 168h e aberta |
Response: 200 OK
{
"from": "2026-08-01T00:00:00Z",
"to": "2026-08-28T00:00:00Z",
"previous_from": "2026-07-05T00:00:00Z",
"previous_to": "2026-08-01T00:00:00Z",
"current": {
"created": 1284,
"resolved": 1197,
"reopened": 63,
"first_reply_median_minutes": 14.5,
"first_reply_sample": 1102,
"resolution_median_minutes": 386.2,
"resolution_sample": 1197
},
"previous": {
"created": 1190,
"resolved": 1141,
"reopened": 71,
"first_reply_median_minutes": 18.0,
"first_reply_sample": 1035,
"resolution_median_minutes": 431.7,
"resolution_sample": 1141
},
"backlog": {
"open": 213,
"unreplied": 27,
"unreplied_over24h": 9,
"age_buckets": [
{ "upper_bound_minutes": 60, "count": 18 },
{ "upper_bound_minutes": 240, "count": 34 },
{ "upper_bound_minutes": 1440, "count": 61 },
{ "upper_bound_minutes": 4320, "count": 52 },
{ "upper_bound_minutes": 10080, "count": 27 },
{ "upper_bound_minutes": null, "count": 21 }
]
}
}
Volume
GET https://acme.pumahelp.com/api/v1/reports/volume?from=2026-08-01T00:00:00Z&to=2026-08-28T00:00:00Z&tz=America/Sao_Paulo
Authorization: Bearer {token}
Scope: reports:read (Owner/Admin)
Série de criados × resolvidos, quebras por canal/tipo/prioridade, mapa de calor de criação e a evolução histórica do backlog.
heatmapconta criações por (dia da semana, hora) no fuso pedido —day_of_week0= domingo.backlogvem das fotografias diárias do backlog (uma por dia UTC). Vem vazio quando há qualquer filtro de segmento ativo — as fotografias são da organização inteira e exibi-las ao lado de uma série filtrada seria enganoso — e também enquanto não houver fotografias acumuladas no período.
Response: 200 OK (arrays cortados)
{
"granularity": "day",
"series": [
{ "bucket_start": "2026-08-01T00:00:00", "created": 41, "resolved": 38 },
{ "bucket_start": "2026-08-02T00:00:00", "created": 12, "resolved": 9 },
{ "bucket_start": "2026-08-03T00:00:00", "created": 0, "resolved": 4 }
],
"by_channel": [
{ "key": "widget", "count": 812 },
{ "key": "api", "count": 318 },
{ "key": "discord", "count": 154 }
],
"by_type": [
{ "key": "question", "count": 690 },
{ "key": "incident", "count": 402 }
],
"by_priority": [
{ "key": "normal", "count": 903 },
{ "key": "high", "count": 244 },
{ "key": "urgent", "count": 88 },
{ "key": "low", "count": 49 }
],
"heatmap": [
{ "day_of_week": 1, "hour": 9, "count": 34 },
{ "day_of_week": 1, "hour": 10, "count": 41 }
],
"backlog": [
{ "date": "2026-08-01", "new_count": 12, "open_count": 148, "pending_count": 39 },
{ "date": "2026-08-02", "new_count": 15, "open_count": 151, "pending_count": 41 }
]
}
Tempos de Atendimento
GET https://acme.pumahelp.com/api/v1/reports/times?from=2026-08-01T00:00:00Z&to=2026-08-28T00:00:00Z&tz=America/Sao_Paulo
Authorization: Bearer {token}
Scope: reports:read (Owner/Admin)
As três métricas de tempo — first_reply, next_reply e resolution — cada uma com mediana, p90, amostra, histograma e série temporal. Cada duração é ancorada no evento que para o relógio: a resposta saiu dentro do período, o ticket foi resolvido dentro do período.
Response: 200 OK (apenas first_reply expandido; next_reply e resolution têm a mesma forma)
{
"granularity": "day",
"first_reply": {
"p50_minutes": 14.5,
"p90_minutes": 187.3,
"sample": 1102,
"buckets": [
{ "upper_bound_minutes": 5, "count": 210 },
{ "upper_bound_minutes": 15, "count": 356 },
{ "upper_bound_minutes": 60, "count": 288 },
{ "upper_bound_minutes": 240, "count": 152 },
{ "upper_bound_minutes": 1440, "count": 71 },
{ "upper_bound_minutes": null, "count": 25 }
],
"series": [
{ "bucket_start": "2026-08-01T00:00:00", "p50_minutes": 12.0, "p90_minutes": 143.5, "sample": 39 },
{ "bucket_start": "2026-08-02T00:00:00", "p50_minutes": null, "p90_minutes": null, "sample": 0 }
]
},
"next_reply": { "p50_minutes": 31.0, "p90_minutes": 402.5, "sample": 2874, "buckets": [], "series": [] },
"resolution": { "p50_minutes": 386.2, "p90_minutes": 2913.0, "sample": 1197, "buckets": [], "series": [] }
}
Fila de Atenção
GET https://acme.pumahelp.com/api/v1/reports/attention?from=2026-08-01T00:00:00Z&to=2026-08-28T00:00:00Z
Authorization: Bearer {token}
Scope: reports:read (Owner/Admin)
O que precisa de ação antes de virar problema. É estado agora: from/to continuam obrigatórios (o filtro é o mesmo dos demais), mas as duas listas descrevem a fila atual, não a janela. Os filtros de segmento valem normalmente. Cada lista traz no máximo 10 tickets.
unreplied— abertos sem nenhuma resposta pública de agente, mais antigos primeiro.oldest_open— abertos mais antigos, respondidos ou não.age_minutesé a idade em minutos no instante da requisição.
Response: 200 OK
{
"unreplied": [
{
"public_id": 3187,
"subject": "Cobrança duplicada no cartão",
"created_at": "2026-08-26T11:02:00Z",
"age_minutes": 2874.5,
"channel": "widget",
"priority": "urgent",
"status": "new"
}
],
"oldest_open": [
{
"public_id": 2904,
"subject": "Integração com o ERP parou de sincronizar",
"created_at": "2026-08-11T09:40:00Z",
"age_minutes": 24012.0,
"channel": "api",
"priority": "high",
"status": "pending"
}
]
}
Aderência de SLA
GET https://acme.pumahelp.com/api/v1/reports/sla?from=2026-08-01T00:00:00Z&to=2026-08-28T00:00:00Z&tz=America/Sao_Paulo
Authorization: Bearer {token}
Scope: reports:read (Owner/Admin)
Aderência sempre por métrica × prioridade — 91% global esconde 78% em urgent, que é o segmento crítico. O denominador (assessed) são os ciclos avaliados na janela: cada ciclo é avaliado uma única vez, no primeiro instante em que viola ou conclui, e essa avaliação nunca muda de mês nem de resultado. Um ciclo cumprido em julho e reaberto em agosto continua contando em julho como cumprido; a segunda resolução não entra de novo (é o mesmo ciclo — o tempo em solved conta como pausa). Ciclos ativos que já violaram contam no mês da violação.
| Campo | Descrição |
|---|---|
overall | achieved e assessed do período inteiro |
attainment | Uma linha por (metric, priority) com o par achieved/assessed |
overage | Magnitude da violação por métrica — p50/p90 dos minutos excedidos. Uma taxa não distingue "violou por 2 min" de "violou por 3 dias" |
at_risk | Até 10 ciclos ativos (estado agora, não a janela), os de prazo mais próximo primeiro; pausados por último |
recent_breaches | Até 10 violações da janela, mais recentes primeiro |
series | Aderência por bucket, com zero-fill |
Três armadilhas de leitura:
overage.sampleNÃO éassessed − achieved. A aderência conta a violação no instante em que ela acontece, inclusive quando o prazo continua correndo; o "por quanto estourou" só nasce quando o ciclo é encerrado. A diferença entre os dois números é a quantidade de prazos estourados e ainda em aberto — os mesmos que aparecem emrecent_breachescomcompleted: false.overageé retroativamente mutável, ao contrário doassessed. Uma violação estampada em julho e encerrada em agosto entra nosamplede julho e desloca os percentis de julho. E como as que faltam são justamente as mais atrasadas, o p50/p90 de um período recente é otimista.- Combinações sem alvo configurado somem de
attainment— nunca aparece uma linha com denominador zero. "Não há linha para urgente" pode significar "não houve caso" ou "não há alvo para essa célula da matriz", e a resposta não distingue as duas.
risk_at é 75% do prazo na base do relógio do ciclo — é o limiar do "vai estourar". paused: true significa que o due_at está congelado (só reprojeta ao retomar): não vale rodar contagem regressiva sobre ele.
Response: 200 OK (arrays cortados)
{
"granularity": "day",
"overall": { "achieved": 2847, "assessed": 3129 },
"attainment": [
{ "metric": "first_reply", "priority": "urgent", "achieved": 118, "assessed": 151 },
{ "metric": "first_reply", "priority": "normal", "achieved": 1204, "assessed": 1288 },
{ "metric": "resolution", "priority": "urgent", "achieved": 97, "assessed": 140 }
],
"overage": [
{ "metric": "first_reply", "p50_minutes": 23.0, "p90_minutes": 311.5, "sample": 168 },
{ "metric": "resolution", "p50_minutes": 240.0, "p90_minutes": 1980.0, "sample": 114 }
],
"at_risk": [
{
"public_id": 3187,
"subject": "Cobrança duplicada no cartão",
"metric": "first_reply",
"priority": "urgent",
"due_at": "2026-08-28T14:30:00Z",
"risk_at": "2026-08-28T14:07:30Z",
"breached": false,
"paused": false
}
],
"recent_breaches": [
{
"public_id": 3102,
"subject": "Erro 500 ao anexar arquivo",
"metric": "resolution",
"priority": "high",
"breached_at": "2026-08-27T22:15:00Z",
"overage_minutes": 143.0,
"completed": true
}
],
"series": [
{ "bucket_start": "2026-08-01T00:00:00", "achieved": 94, "assessed": 101 },
{ "bucket_start": "2026-08-02T00:00:00", "achieved": 0, "assessed": 0 }
]
}
Satisfação (CSAT)
GET https://acme.pumahelp.com/api/v1/reports/satisfaction?from=...&to=...&tz=America/Sao_Paulo
Scope: satisfaction:read ou reports:read — uma API Key pode ler só a satisfação sem enxergar o resto dos relatórios.
Documentado em detalhe em Relatório de Satisfação (CSAT), junto do endpoint que registra a avaliação.
Equipe
GET https://acme.pumahelp.com/api/v1/reports/team?from=2026-08-01T00:00:00Z&to=2026-08-28T00:00:00Z
Authorization: Bearer {token}
Scope: reports:read (Owner/Admin)
A tabela da equipe vem em ordem alfabética, sem ranking; ordene pela coluna que preferir. Carga, volume, tempo e recontato saem juntos na mesma linha. Agentes com carga aberta e zero resolvidos no período também aparecem.
| Campo | Descrição |
|---|---|
open_now | Abertos atribuídos agora — carga atual |
resolved | Resolvidos na janela, pela atribuição atual do ticket |
resolution_median_minutes | Mediana da resolução dos tickets desse agente. null com amostra zero |
recontact_rate | Fração (0–1, 4 casas) dos resolvidos cujo solicitante abriu outro ticket em até 24h. null com amostra zero |
recontact_sample | Denominador do recontato — os resolvidos do agente na janela |
Response: 200 OK
{
"agents": [
{
"user_id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"name": "Ana Ribeiro",
"open_now": 14,
"resolved": 138,
"resolution_median_minutes": 312.5,
"recontact_rate": 0.0942,
"recontact_sample": 138
},
{
"user_id": "9c858901-8a57-4791-81fe-4c455b099bc9",
"name": "Bruno Tavares",
"open_now": 9,
"resolved": 0,
"resolution_median_minutes": null,
"recontact_rate": null,
"recontact_sample": 0
}
]
}
Estatísticas ao Longo do Tempo
Este endpoint foi substituído por GET /v1/reports/volume, que devolve a mesma série com janela livre (from/to em vez de period fixo), buckets no fuso da organização, zero-fill e as quebras por canal/tipo/prioridade. Mantido apenas para integrações existentes.
GET https://acme.pumahelp.com/api/v1/tickets/stats-over-time?period=30d
Authorization: Bearer {token}
Scope: reports:read (Owner/Admin)
Query Parameters:
| Parâmetro | Valores Aceitos |
|---|---|
period | 24h, 7d, 30d |
Response: 200 OK
{
"data": [
{
"date": "2025-01-01",
"created": 45,
"resolved": 38
},
{
"date": "2025-01-02",
"created": 52,
"resolved": 41
}
]
}
Ranking de Agentes
Este endpoint foi substituído por GET /v1/reports/team, que traz carga atual, mediana de resolução e taxa de recontato ao lado do volume resolvido. Mantido apenas para integrações existentes.
GET https://acme.pumahelp.com/api/v1/tickets/agent-ranking?period=30d
Authorization: Bearer {token}
Scope: reports:read (Owner/Admin)
Query Parameters:
| Parâmetro | Valores Aceitos |
|---|---|
period | 24h, 7d, 30d |
Response: 200 OK
{
"data": [
{
"agent": "Maria Santos",
"resolved": 127
},
{
"agent": "João Silva",
"resolved": 98
}
]
}
⏱️ SLA
Uma organização define políticas de SLA (quem casa com quais alvos) e um calendário comercial (o que conta como hora útil). Toda organização tem uma política padrão e um horário comercial padrão — segunda a sexta, das 9h às 18h, no fuso America/Sao_Paulo —, e decide se o SLA está ligado.
Ligar e desligar o SLA
O SLA da organização tem um interruptor: sla_enabled em GET /v1/organizations,
alterado por PUT /v1/organizations/settings/sla. Organizações novas nascem
com ele ligado.
Desligado:
- nenhum prazo novo começa — nem na criação do ticket, nem na resposta do cliente, nem na reabertura;
- ninguém recebe alerta de SLA;
- os prazos que já estavam correndo continuam sendo medidos até a resposta ou a resolução, sem alerta, e entram nos relatórios como qualquer outro;
- políticas e horário comercial continuam editáveis, e o histórico dos relatórios é mantido.
Ao ligar, o SLA vale dali em diante, também nos tickets que já existiam:
- os tickets criados a partir daí recebem os prazos de primeira resposta e de resolução; um ticket que ainda não tinha esses prazos não os recebe depois;
- a próxima resposta do cliente abre o prazo de próxima resposta, se a política tiver esse alvo;
- reabrir um ticket que já tinha prazo de resolução faz esse prazo voltar a correr;
- nenhum alerta represado é enviado.
Como as Políticas São Avaliadas
- As políticas são avaliadas por posição, em ordem crescente, e a primeira cujas condições todas casam vence. Não há acumulação entre políticas.
positionnão é única: duas políticas na mesma posição desempatam pela mais antiga (created_at). - Herança de célula: se a política vencedora não define o alvo para o par (métrica, prioridade) do ticket, esse alvo vem da política padrão. Uma política só de primeira resposta não deixa o ticket sem prazo de resolução.
- Uma política tem quatro condições —
channel_condition,type_condition,group_id_conditionetag_condition— combinadas por AND. Condição nula = qualquer: uma política sem nenhuma condição casa com todo ticket. - A política padrão (
is_default: true) é o catch-all do fim da fila. Ela não pode ser excluída, desativada nem receber condições — só nome e alvos são editáveis. Sem ela, tickets ficariam sem alvo de SLA. Para parar de contar prazos, desligue o SLA da organização. - Limite de 20 políticas por organização.
tag_conditioné uma tag só por política, normalizada como as tags dos tickets (minúsculas, espaços viram hífen), e casa o ticket que tiver essa tag — ele pode ter outras.- Reabertura: o ciclo de
resolutionvolta a correr no mesmo registro (o tempo emsolvedvira pausa, o prazo é reprojetado); o defirst_replynão reabre (já aconteceu); umnext_replysó nasce se a política tiver esse alvo. Um ciclo que já violou antes da reabertura não alerta de novo.
Alvos
- Um alvo é a combinação métrica × prioridade:
metric∈first_reply,next_reply,resolution;priority∈urgent,high,normal,low. Não pode haver dois alvos para o mesmo par. duration_minutesé sempre em minutos (1 a 131.400 — cerca de 3 meses). A duração é expressa apenas em minutos.clockescolhe o relógio:business(horas úteis do calendário da organização) oucalendar(24×7 corrido).- Sem horário comercial configurado, os alvos
businesscorrem 24×7 até alguém configurar o calendário. first_replyeresolutionabrem ciclo na criação do ticket.next_replyé opt-in: só passa a valer depois que você adiciona o alvo à matriz da política. A partir daí, cada resposta pública do cliente após a primeira resposta do agente abre um novo ciclo, que fecha na próxima resposta pública da equipe. Mensagens seguidas do cliente contam como um ciclo (o relógio corre desde a primeira), nota interna não fecha nada, e o relógio de resposta nunca pausa empending— resolver ou fechar o ticket encerra o ciclo aberto.
Ciclos em andamento mantêm o alvo com que começaram. Editar uma política — ou até excluí-la — não repactua o que já está correndo: o ciclo carrega um snapshot da métrica, do relógio e da duração. A mudança vale para os ciclos abertos depois dela. O mesmo vale para o calendário comercial: alterá-lo não reprojeta prazos já materializados.
O objeto sla_cycles no ticket
Os ciclos abertos de um ticket aparecem no próprio ticket, no campo sla_cycles:
- No detalhe (
GET /v1/tickets/{public_id}): vem sempre, para quem é da equipe. - Na listagem (
GET /v1/tickets): só quando você pedirinclude=sla. Sem oinclude, o campo énull. - Para um usuário final (
end_user), é semprenull— SLA é métrica operacional da equipe, e isso vale tanto na listagem quanto no detalhe. - Ciclos já concluídos não aparecem aqui. A lista pode vir vazia (ticket sem nada correndo).
"sla_cycles": [
{
"metric": "first_reply",
"cycle_number": 1,
"priority": "high",
"due_at": "2026-01-15T13:30:00Z",
"risk_at": "2026-01-15T12:45:00Z",
"paused": false,
"breached": false
}
]
| Campo | Tipo | Descrição |
|---|---|---|
metric | string | first_reply, next_reply ou resolution |
cycle_number | int | Ordinal do ciclo dentro da métrica (sempre 1 em first_reply/resolution; cresce em next_reply). Distingue dois ciclos da mesma métrica |
priority | string | Prioridade com que o ciclo foi pactuado — a do ticket na época, não necessariamente a atual |
due_at | datetime (UTC) | Quando o prazo estoura. Já convertido para tempo de relógio — para um alvo business, as horas fora do expediente e os feriados já foram somados |
risk_at | datetime (UTC) | Quando o ciclo entra em risco: 75% do prazo consumido |
paused | boolean | Relógio parado. Acontece com resolution enquanto o ticket está pending (esperando o cliente). due_at de um ciclo pausado está defasado — ele é reprojetado quando o relógio volta a correr |
breached | boolean | O prazo já estourou. Fica true de forma definitiva: um ciclo violado não volta a ficar são, nem se a política mudar depois |
Para exibir um countdown, use o menor due_at entre os ciclos com paused: false. Se todos estiverem
pausados (ou a lista vier vazia), não há relógio correndo e não há o que mostrar. É a mesma regra do
sort_by=sla na listagem.
O objeto satisfaction no ticket
O detalhe do ticket (GET /v1/tickets/{public_id}) traz a avaliação já registrada, ou null se
ainda não houver nenhuma.
"satisfaction": {
"score": "good",
"comment": "Resolveram rápido, obrigado!",
"rated_at": "2026-01-15T14:02:00Z"
}
| Campo | Tipo | Descrição |
|---|---|---|
score | string | good, neutral ou bad |
comment | string | null | Comentário livre, opcional |
rated_at | datetime (UTC) | Quando foi avaliado |
O objeto só existe depois da nota: use-o para saber se o solicitante já avaliou. Avaliar de novo sobrescreve a nota anterior.
O prazo para avaliar fica no próprio ticket, em rateable_until (datetime UTC, null se o ticket
nunca foi resolvido): última resolução + 30 dias. Depois disso a API responde 400. Quem já avaliou
continua vendo a própria nota.
Listar Políticas de SLA
GET https://acme.pumahelp.com/api/v1/sla/policies
Authorization: Bearer {token}
Scope: reports:read (Owner/Admin)
Response: 200 OK (alvos cortados)
{
"policies": [
{
"id": "c0a80101-0000-4000-8000-000000000001",
"name": "Clientes Enterprise",
"position": 0,
"is_default": false,
"is_active": true,
"channel_condition": null,
"type_condition": null,
"group_id_condition": "7d1f5a2c-3b44-4e19-9d0a-1c2b3d4e5f60",
"tag_condition": "enterprise",
"targets": [
{ "metric": "first_reply", "priority": "urgent", "duration_minutes": 15, "clock": "calendar" },
{ "metric": "resolution", "priority": "urgent", "duration_minutes": 240, "clock": "calendar" }
]
},
{
"id": "c0a80101-0000-4000-8000-0000000000ff",
"name": "Política padrão",
"position": 2147483647,
"is_default": true,
"is_active": true,
"channel_condition": null,
"type_condition": null,
"group_id_condition": null,
"tag_condition": null,
"targets": [
{ "metric": "first_reply", "priority": "urgent", "duration_minutes": 30, "clock": "calendar" },
{ "metric": "first_reply", "priority": "normal", "duration_minutes": 240, "clock": "business" },
{ "metric": "resolution", "priority": "normal", "duration_minutes": 2400, "clock": "business" }
]
}
]
}
Criar Política de SLA
POST https://acme.pumahelp.com/api/v1/sla/policies
Authorization: Bearer {token}
Content-Type: application/json
Scope: organization:update (Owner/Admin)
{
"name": "Incidentes urgentes do widget",
"position": 0,
"is_active": true,
"channel_condition": "widget",
"type_condition": "incident",
"group_id_condition": null,
"tag_condition": "prioritario",
"targets": [
{ "metric": "first_reply", "priority": "urgent", "duration_minutes": 15, "clock": "calendar" },
{ "metric": "first_reply", "priority": "high", "duration_minutes": 60, "clock": "business" },
{ "metric": "resolution", "priority": "urgent", "duration_minutes": 240, "clock": "calendar" }
]
}
| Campo | Tipo | Descrição |
|---|---|---|
name | string | Obrigatório. Máx. 100 caracteres |
position | int | Ordem de avaliação (menor = primeiro), ≥ 0. Ausente → entra no fim da fila, antes da padrão |
is_active | bool | Ausente → ativa |
channel_condition | string | api, discord ou widget. Nula = qualquer |
type_condition | string | question, incident, problem ou task. Nula = qualquer |
group_id_condition | uuid | Precisa existir na organização. Nula = qualquer |
tag_condition | string | Máx. 50 caracteres, normalizada. Nula = qualquer |
targets | array | Obrigatório, não vazio. Política sem alvo desligaria o SLA dos tickets que ela casar |
targets[].metric | string | first_reply, next_reply ou resolution |
targets[].priority | string | urgent, high, normal ou low |
targets[].duration_minutes | int | 1 a 131.400 |
targets[].clock | string | calendar ou business |
Response: 201 Created — devolve a política criada, no mesmo formato da listagem.
Erros: 400 quando o limite de 20 políticas foi atingido, o grupo da condição não existe na organização, a matriz tem dois alvos para o mesmo par métrica/prioridade ou algum enum é inválido.
Atualizar Política de SLA
PUT https://acme.pumahelp.com/api/v1/sla/policies/{policy_id}
Authorization: Bearer {token}
Content-Type: application/json
Scope: organization:update (Owner/Admin)
Mesmo corpo do create. Duas regras que mudam o resultado de um PUT parcial:
targetssubstitui a matriz inteira. Par presente é atualizado, par ausente é removido, par novo é inserido. Enviar uma matriz incompleta apaga o resto.- Condição nula limpa a condição;
positioneis_activenulos mantêm o valor atual (para não reativar em silêncio uma política desligada).
Na política padrão, apenas name e targets são aceitos: enviar is_active: false, position ou qualquer condição resulta em 400.
Response: 204 No Content
Excluir Política de SLA
DELETE https://acme.pumahelp.com/api/v1/sla/policies/{policy_id}
Authorization: Bearer {token}
Scope: organization:update (Owner/Admin)
Response: 204 No Content
A política padrão não pode ser excluída (400). Ciclos já abertos sob a política excluída continuam válidos com o alvo que tinham no início.
Obter Horário Comercial
GET https://acme.pumahelp.com/api/v1/sla/business-hours
Authorization: Bearer {token}
Scope: reports:read (Owner/Admin)
configured: false significa que o calendário nunca foi configurado — e que todo alvo business está correndo 24×7.
Response: 200 OK
{
"configured": true,
"time_zone": "America/Sao_Paulo",
"days": [
{ "day_of_week": 1, "start_minute": 540, "end_minute": 1080 },
{ "day_of_week": 2, "start_minute": 540, "end_minute": 1080 },
{ "day_of_week": 3, "start_minute": 540, "end_minute": 1080 },
{ "day_of_week": 4, "start_minute": 540, "end_minute": 1080 },
{ "day_of_week": 5, "start_minute": 540, "end_minute": 1080 }
],
"holidays": [
{ "date": "2026-09-07", "name": "Independência", "recurring": true },
{ "date": "2026-12-25", "name": "Natal", "recurring": true }
]
}
Quando nunca configurado:
{
"configured": false,
"time_zone": null,
"days": [],
"holidays": []
}
Atualizar Horário Comercial
PUT https://acme.pumahelp.com/api/v1/sla/business-hours
Authorization: Bearer {token}
Content-Type: application/json
Scope: organization:update (Owner/Admin)
Substitui o calendário inteiro.
{
"time_zone": "America/Sao_Paulo",
"days": [
{ "day_of_week": 1, "start_minute": 540, "end_minute": 1080 },
{ "day_of_week": 5, "start_minute": 540, "end_minute": 960 }
],
"holidays": [
{ "date": "2026-12-25", "name": "Natal", "recurring": true },
{ "date": "2026-11-20", "name": "Recesso da equipe", "recurring": false }
]
}
| Campo | Tipo | Descrição |
|---|---|---|
time_zone | string | Obrigatório. Identificador IANA (ex.: America/Sao_Paulo) — sem default silencioso |
days[].day_of_week | int | 0 = domingo … 6 = sábado. Cada dia só pode aparecer uma vez; dia ausente = sem expediente |
days[].start_minute / end_minute | int | Minutos desde a meia-noite local, entre 0 e 1440, com início antes do fim. É uma janela contígua por dia |
holidays[].date | date | Obrigatório. YYYY-MM-DD |
holidays[].name | string | Obrigatório. Máx. 100 caracteres |
holidays[].recurring | bool | Repete todo ano no mesmo dia/mês |
Response: 204 No Content
Alertas de SLA
Quando um ciclo entra em risco (75% do prazo) ou estoura, o sistema gera uma notificação para o
responsável pelo ticket — ou, se não houver responsável, para a equipe do grupo do ticket. Os tipos são
sla_at_risk e sla_breached, e chegam no painel pelos mesmos canais das demais notificações
(GET /v1/notifications e o evento NotificationCreated no hub de tempo real).
Cada ciclo alerta uma vez por tipo: não há repetição enquanto o prazo segue estourado, e um ciclo
que já violou antes de uma reabertura não alerta de novo. Exceção: quando o ticket muda para uma
prioridade com prazo maior e o instante de risco volta para o futuro, o alerta de risco é
rearmado. Cada pessoa controla os próprios avisos (sino e e-mail, em
/v1/notifications/preference); a liberação do e-mail por organização é feita pelo PumaHelp.
Com o SLA desligado, nenhum alerta é enviado — nem dos prazos que já
estavam correndo. O estouro continua registrado na linha do tempo do ticket (sla_breached).
🔔 Notificações
Avisos de ticket para a equipe (Owner, Admin, Agent): ticket criado, resposta do cliente, SLA em
risco, SLA estourado. Cada evento vira uma linha por destinatário: o responsável pelo ticket, se estiver com o sino
ligado — nunca o autor do evento. Sem responsável elegível, o grupo inteiro só é avisado nos alertas
de SLA ou, para os demais tipos, quando a organização ligou
avisar o grupo sobre tickets sem responsável
(desligado por padrão). A linha alimenta
o sino do painel e o evento NotificationCreated em tempo real; o que não for visto no painel é
enviado por e-mail, agrupado. Usuários finais não recebem notificação nenhuma por enquanto, e as
rotas respondem 401 a um token de end-user.
Dois interruptores por pessoa:
| Preferência | Controla | Padrão |
|---|---|---|
in_app_enabled | o sino: as notificações no aplicativo (GET /v1/notifications e o evento em tempo real) | ligado para staff |
email_enabled | o e-mail com o que não foi visto no painel | desligado |
O e-mail depende ainda de a organização estar liberada para e-mail — uma liberação feita pelo
PumaHelp, organização a organização, exposta como organization_enabled (somente leitura). O sino
funciona em toda organização. email_enabled só tem efeito com in_app_enabled ativo: o e-mail
reúne as notificações do sino que ficaram sem leitura.
Listar notificações
GET https://acme.pumahelp.com/api/v1/notifications?page=1&page_size=20
Authorization: Bearer {token}
page_size vai até 100. Ordem: mais recentes primeiro. count é o total e unread_count as não lidas.
{
"count": 42,
"unread_count": 3,
"notifications": [
{
"id": "6f1c…",
"type": "client_reply",
"ticket_public_id": 42,
"subject": "Não consigo entrar",
"preview": "Ainda não funcionou…",
"author_name": "Maria",
"created_at": "2026-08-27T14:30:00Z",
"read_at": null
}
]
}
type é um de ticket_created, client_reply, sla_at_risk, sla_breached. Existe ainda
agent_reply — resposta da equipe endereçada ao cliente, o único tipo cujo destinatário é um
usuário final — reservado: hoje nenhuma notificação é gerada para usuários finais. Lidas são apagadas
depois de 90 dias; não lidas, depois de 180.
Marcar como lida
POST https://acme.pumahelp.com/api/v1/notifications/{id}/read
POST https://acme.pumahelp.com/api/v1/notifications/read-all
POST https://acme.pumahelp.com/api/v1/notifications/read-by-ticket/{public_id}
Todas respondem 204. Marcar uma notificação de outra pessoa não faz nada. read-by-ticket marca
todas as não lidas daquele ticket para quem chama — use ao abrir um ticket, para limpar as
notificações pendentes dele. Ticket inexistente afeta zero linhas.
Preferências
GET https://acme.pumahelp.com/api/v1/notifications/preference
{
"organization_enabled": false,
"in_app_enabled": true,
"email_enabled": false,
"user_enabled": true
}
user_enabled repete in_app_enabled (nome anterior, mantido para compatibilidade).
PUT https://acme.pumahelp.com/api/v1/notifications/preference
Content-Type: application/json
{ "email_enabled": true }
Todos os campos são opcionais — omitido = não muda; enviar o corpo vazio é 400. enabled é o
nome anterior de in_app_enabled e controla só o sino; se os dois vierem, vale in_app_enabled. A
resposta é o mesmo objeto do GET.
💬 Comentários
Listar Comentários de um Ticket
GET https://acme.pumahelp.com/api/v1/tickets/{public_id}/comments?page=1&page_size=50
Authorization: Bearer {token}
Scope: comment:read
Query Parameters:
| Parâmetro | Tipo | Descrição | Padrão | Obrigatório |
|---|---|---|---|---|
page | integer | Número da página | - | Sim |
page_size | integer | Itens por página — entre 10 e 100 | - | Sim |
sort_by | string | Campo para ordenação: created_at | created_at | Não |
sort_order | string | Ordem: asc ou desc | desc | Não |
Response: 200 OK
{
"count": 5,
"comments": [
{
"id": "uuid",
"body": "Estamos investigando o problema",
"public": true,
"client_id": null,
"created_at": "2025-01-01T11:00:00Z",
"updated_at": null,
"author": {
"id": "uuid",
"name": "Maria Santos",
"role": "agent",
"email": "[email protected]",
"verified": true
},
"attachments": [
{
"id": "uuid",
"file_name": "screenshot.png",
"content_type": "image/png",
"size": 102400,
"content_url": "https://cdn.pumahelp.com/attachments/abc123"
}
]
}
]
}
client_id ecoa o identificador enviado no envio, ou vem null para comentários que não usaram um.
Redigir Comentário
PUT https://acme.pumahelp.com/api/v1/tickets/{public_id}/comments/{comment_id}/redact
Authorization: Bearer {token}
Content-Type: application/json
Scope: comment:redact
Request Body:
{
"body": "Comentário <redact>removido</redact>"
}
Response: 204 No Content
O texto entre as tags <redact> é 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 |
|---|---|
<redact>texto</redact> | 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:
<a href="https://x/token=abc" redact><redact>clique aqui</redact></a> → ████████
<a href="https://x/token=abc" redact>clique aqui</a> → 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 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
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.
É 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
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
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
POST https://acme.pumahelp.com/api/v1/users/login
Content-Type: application/json
Rate limit: balde ip, por endereço (ver Rate Limiting)
Request Body:
{
"email": "[email protected]",
"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
{
"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)
POST https://acme.pumahelp.com/api/v1/users/refresh
Content-Type: application/json
Rate limit: balde ip, por endereço (ver Rate Limiting)
Request Body:
{
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Response: 201 Created
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Logout
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)
Request Body:
{
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Response: 204 No Content
Criar Usuário
POST https://acme.pumahelp.com/api/v1/users
Authorization: Bearer {token}
Content-Type: application/json
Scope: user:create
Request Body:
{
"name": "João Silva",
"email": "[email protected]",
"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
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "João Silva",
"email": "[email protected]",
"role": "agent",
"external_id": "crm-user-123",
"verified": false
}
Response: 409 Conflict — o e-mail já identifica outra pessoa da organização:
{
"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).".
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. Para dar acesso à equipe a um cliente que já existe, promova-o em 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)
POST https://acme.pumahelp.com/api/v1/users/create_or_update
Authorization: Bearer {token}
Content-Type: application/json
Scope: user:upsert
Request Body:
{
"name": "João Silva",
"email": "[email protected]",
"role": "agent",
"external_id": "crm-user-123",
"verified": false,
"group_ids": ["uuid-grupo-1"]
}
Response: 201 Created — tanto ao criar quanto ao atualizar.
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "João Silva",
"email": "[email protected]",
"role": "agent",
"external_id": "crm-user-123",
"verified": false
}
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. As regras de 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
POST https://acme.pumahelp.com/api/v1/users/impersonate
Authorization: Bearer {token}
Content-Type: application/json
Scope: user:impersonate
Request Body:
{
"email": "[email protected]",
"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
{
"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 |
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.
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
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
{
"count": 150,
"users": [
{
"id": "uuid",
"name": "Maria Santos",
"email": "[email protected]",
"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 |
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
GET https://acme.pumahelp.com/api/v1/users/me
Authorization: Bearer {token}
Scope: profile:read:own
Response: 200 OK
{
"id": "uuid",
"name": "João Silva",
"email": "[email protected]",
"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
GET https://acme.pumahelp.com/api/v1/users/{user_id}
Authorization: Bearer {token}
Scope: user:read
Response: 200 OK
{
"id": "uuid",
"name": "João Silva",
"email": "[email protected]",
"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
PUT https://acme.pumahelp.com/api/v1/users/{user_id}
Authorization: Bearer {token}
Content-Type: application/json
Scope: user:update
Request Body:
{
"name": "João Silva Santos",
"role": "admin",
"email": "[email protected]",
"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, 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 |
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
emailenviado 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
{
"id": "uuid",
"name": "João Silva Santos",
"email": "[email protected]",
"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
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
POST https://acme.pumahelp.com/api/v1/users/{user_id}/email/send/verification
Authorization: Bearer {token}
Scope: user:manage
Response: 204 No Content
Envia um email para o usuário com link para verificar o endereço de email.
Verificar Email
POST https://acme.pumahelp.com/api/v1/users/email/verify?token={verification_token}
Rate limit: balde ip, por endereço (ver 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
POST https://acme.pumahelp.com/api/v1/users/email/resend/verification?email={user_email}
Rate limit: balde ip, por endereço (ver 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).
Mesclar Sessão (Merge Session)
POST https://acme.pumahelp.com/api/v1/users/merge-session
Authorization: Bearer {token}
Content-Type: application/json
Scope: session:merge
Request Body:
{
"target_auth_token": "token-jwt-do-usuario-autenticado"
}
Response: 204 No Content
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
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:
{
"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
{
"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:
# 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
}
}'
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 já faz tudo isto — sessão, tempo real, anexos e avaliação — sem você escrever nenhuma dessas chamadas.
Criar Sessão de Convidado (v3)
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.
{
"name": "Visitante João"
}
Response: 201 Created
{
"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 |
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
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), 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:
{
"email": "[email protected]"
}
| 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:
{
"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:
{
"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
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), 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:
{
"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
{
"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 |
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
POST https://acme.pumahelp.com/api/v1/groups
Authorization: Bearer {token}
Content-Type: application/json
Scope: group:create
Request Body:
{
"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
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Suporte Técnico",
"description": "Equipe responsável por suporte técnico",
"default": false
}
Listar Grupos
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
{
"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
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
{
"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": "[email protected]",
"role": "agent",
"external_id": null,
"verified": true
},
{
"id": "uuid",
"name": "João Silva",
"email": "[email protected]",
"role": "agent",
"external_id": null,
"verified": true
}
]
}
]
}
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
GET https://acme.pumahelp.com/api/v1/groups/{group_id}
Authorization: Bearer {token}
Scope: group:read
Response: 200 OK
{
"id": "uuid",
"name": "Suporte Técnico",
"description": "Equipe responsável por suporte técnico",
"default": false
}
Atualizar Grupo
PUT https://acme.pumahelp.com/api/v1/groups/{group_id}
Authorization: Bearer {token}
Content-Type: application/json
Scope: group:update
Request Body:
{
"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 |
Todos os campos são opcionais. Apenas os campos enviados serão atualizados.
Response: 204 No Content
Deletar Grupo
DELETE https://acme.pumahelp.com/api/v1/groups/{group_id}
Authorization: Bearer {token}
Scope: group:delete
Response: 204 No Content
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
POST https://acme.pumahelp.com/api/v1/macros
Authorization: Bearer {token}
Content-Type: application/json
Scope: macro:create
Request Body:
{
"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
{
"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
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
{
"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
GET https://acme.pumahelp.com/api/v1/macros/{macro_id}
Authorization: Bearer {token}
Scope: macro:read
Response: 200 OK
{
"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
PUT https://acme.pumahelp.com/api/v1/macros/{macro_id}
Authorization: Bearer {token}
Content-Type: application/json
Scope: macro:update
Request Body:
{
"title": "Resposta Padrão - Login Atualizada",
"description": "Nova descrição",
"type": "group",
"group_ids": ["uuid-grupo-3"],
"actions": [
{
"field": "status",
"value": "solved"
}
]
}
Todos os campos são opcionais. Apenas os campos enviados serão atualizados.
Response: 200 OK
{
"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
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
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
{
"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
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
{
"counts": [
{ "view_id": "550e8400-e29b-41d4-a716-446655440000", "count": 12 },
{ "view_id": "660e8400-e29b-41d4-a716-446655440001", "count": 3 }
]
}
Criar Visualização
POST https://acme.pumahelp.com/api/v1/views
Authorization: Bearer {token}
Content-Type: application/json
Scope: view:create
Request Body:
{
"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álido401— agente tentando criar uma visualizaçãoorganization
Atualizar Visualização
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.
{
"name": "Urgentes",
"query": "priority:urgent status:open"
}
Response: 200 OK — a visualização atualizada.
Erros:
401— agente tentando alterar uma visualizaçãoorganization404— visualização inexistente ou pessoal de outro usuário
Deletar Visualização
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
POST https://acme.pumahelp.com/api/v1/webhooks
Authorization: Bearer {token}
Content-Type: application/json
Scope: webhook:create
Request Body:
{
"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
{
"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"
}
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.
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
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
{
"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
GET https://acme.pumahelp.com/api/v1/webhooks/{webhook_id}
Authorization: Bearer {token}
Scope: webhook:read
Response: 200 OK
{
"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
PUT https://acme.pumahelp.com/api/v1/webhooks/{webhook_id}
Authorization: Bearer {token}
Content-Type: application/json
Scope: webhook:update
Request Body:
{
"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 |
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
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
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.
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 por até cerca de um dia e passam a ser aceitas assim que
ela usar a chave nova.
Response: 200 OK
{
"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:
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);
});
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
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 |
{
"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": "<p>Pode confirmar se o erro continua?</p>",
"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 |
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 |
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 — inclusive4xx. 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 Goneencerra 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 respostas429e503, 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
2xxtambé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 otimedo 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
timedo 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
2xxmas a resposta se perdeu. Deduplique peloiddo corpo ou pelo cabeçalhoX-Pumahelp-Webhook-Event-Id. - Excluir o webhook descarta as entregas pendentes dele.
ticket.updatedtambé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
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
{
"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
POST https://acme.pumahelp.com/api/v1/keys
Authorization: Bearer {token}
Content-Type: application/json
Scope: apikey:create
Request Body:
{
"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
{
"key": "rk_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
}
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
PUT https://acme.pumahelp.com/api/v1/keys/{key_id}
Authorization: Bearer {token}
Content-Type: application/json
Scope: apikey:update
Request Body:
{
"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 ou repetido devolve 400 |
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
{
"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
PUT https://acme.pumahelp.com/api/v1/keys/rotate/{key_id}
Authorization: Bearer {token}
Scope: apikey:rotate
Response: 200 OK
{
"key": "rk_live_x9y8z7w6v5u4t3s2r1q0p9o8n7m6l5k4j3i2h1g0"
}
Ao rotacionar, a chave antiga torna-se inválida imediatamente. Atualize seus sistemas com a nova chave antes de rotacionar!
Deletar API Key
DELETE https://acme.pumahelp.com/api/v1/keys/{key_id}
Authorization: Bearer {token}
Scope: apikey:delete
Response: 204 No Content
Esta ação é irreversível. Sistemas usando esta chave perderão acesso imediatamente.
🏢 Organização
Obter Organização
GET https://acme.pumahelp.com/api/v1/organizations
Authorization: Bearer {token}
Scope: organization:read
Response: 200 OK
{
"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. Padrão false |
sla_enabled | Se o SLA da organização está ligado. Ver SLA da organização |
Fechamento automático de tickets resolvidos
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:
{
"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
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:
{
"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.
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
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:
{
"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
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.
Request Body:
{
"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
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
{
"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
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.").
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:
curl -X POST https://acme.pumahelp.com/api/v1/uploads \
-H "Authorization: Bearer SEU_TOKEN" \
-F "file=@/caminho/para/arquivo.png"
Exemplo com 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);
});
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
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
{
"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.
⚙️ Configurações de Conta
Alterar Email
PUT https://acme.pumahelp.com/api/v1/accounts/email
Authorization: Bearer {token}
Content-Type: application/json
Scope: account:update:own
Request Body:
{
"new_email": "[email protected]",
"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
POST https://acme.pumahelp.com/api/v1/accounts/forgot/password
Content-Type: application/json
Rate limit: balde ip, por endereço (ver Rate Limiting)
Endpoint público. Envia email com token de redefinição.
Request Body:
{
"email": "[email protected]"
}
Response: 204 No Content
Redefinir Senha
POST https://acme.pumahelp.com/api/v1/accounts/reset/password
Content-Type: application/json
Rate limit: balde ip, por endereço (ver Rate Limiting)
Request Body:
{
"reset_password_token": "token-recebido-por-email",
"new_password": "nova-senha-segura"
}
Response: 204 No Content
Alterar Senha
PUT https://acme.pumahelp.com/api/v1/accounts/password
Authorization: Bearer {token}
Content-Type: application/json
Scope: account:update:own
Request Body:
{
"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
POST https://acme.pumahelp.com/api/v1/exports
Authorization: Bearer {token}
Sem corpo.
Response: 202 Accepted
{
"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ápendingouprocessing."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 — inclusivefailed.
Listar Exportações
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
[
{
"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
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
{
"exported_at": "2026-09-13T12:01:07Z",
"organization": { "id": "...", "name": "Acme", "app_name": "acme", "created_at": "..." },
"users": [
{ "id": "...", "name": "Maria Santos", "email": "[email protected]", "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": "[email protected]" },
"assignee": { "id": "...", "name": "Maria Santos", "email": "[email protected]" },
"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": "[email protected]",
"body": "<p>Não consigo pagar o boleto.</p>",
"attachments": [ { "file_name": "print.png", "content_type": "image/png", "size": 102400, "url": "https://cdn.pumahelp.com/..." } ]
}
]
}
]
}
userstraz só os ativos;ticketstraz todos, arquivados inclusive, com todos os comentários (públicos e internos) e obodycomo foi gravado.satisfactioné a avaliação do cliente —score, o comentário livre,rated_at(última) efirst_rated_at(primeira) — ounullse 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), ou código de e-mail enviado (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), ou código de e-mail do usuário final vencido, usado ou esgotado (Confirmar E-mail) |
429 | Too Many Requests | Rate limit excedido (Rate Limiting), ou um limite do código de 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 |
"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:
{
"error_messages": [
"O campo 'email' é obrigatório",
"O campo 'password' deve ter no mínimo 6 caracteres"
]
}
error_messagesNã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:
{
"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 |
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/billinge/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
# 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": "[email protected]",
"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"
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_sizeadequado) - ✅ 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:
- Token JWT expirado
- API Key inválida ou revogada
- Header de autorização mal formatado
- Token de um usuário excluído ou desativado
Soluções:
# 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:
- API Key sem o scope necessário
- Usuário sem permissão para a ação
Soluções:
# 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:
- Campos obrigatórios faltando
- Formato de dados inválido
- Valor fora do range permitido
Exemplo de Response:
{
"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,
um limite do código, que vem com o motivo em error_messages
Solução:
// 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:
- ✅ URL está acessível publicamente (não localhost)?
- ✅ URL usa HTTPS? (recomendado)
- ✅ A URL é a final, sem redirecionamento? Um redirecionamento pode fazer a chamada chegar sem o corpo.
- ✅ Servidor responde com
2xxem menos de 30 segundos? - ✅ Eventos selecionados estão corretos?
- ✅ Respondeu
2xx? Qualquer outro status conta como falha, e depois de 8 tentativas (~27,6 h) a entrega não é mais repetida;410encerra na hora. Ver Entrega, retentativas e ordem - ✅ A assinatura foi validada com o
X-Pumahelp-Webhook-Timestampda tentativa, não com otimedo corpo, e sobre o corpo bruto?
Testar Webhook:
# 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). 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) | 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 — 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 e gerencia billing.
A tabela completa está no 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.
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 com X-API-Key |
POST /v1/guests (header X-Public-Key) | POST /v3/guests com API Key e guest:create |
GET/PUT /v1/organizations/key/{secret,public,webhook} | API Keys e a chave por webhook em Rotacionar Chave do Webhook |
GET /v1/tickets/stats-over-time | GET /v1/reports/volume |
GET /v1/tickets/agent-ranking | GET /v1/reports/team |
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.