Pular para o conteúdo principal

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
observação

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​


📝 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
}
important

Todos os nomes de campos devem usar snake_case_lower (letras minúsculas com underscores).


🔐 Autenticação​

A PumaHelp API suporta dois métodos de autenticação:

1. JWT (JSON Web Token)​

Para usuários que fazem login via email/senha.

Como obter:

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:

  1. Faça login na plataforma PumaHelp
  2. Navegue até Configurações → API Keys
  3. Clique em "Criar Nova API Key"
  4. Selecione os scopes necessários
  5. Copie o secret (visível apenas uma vez!)

Como usar:

GET https://acme.pumahelp.com/api/v1/tickets
X-API-Key: seu-api-key-secret-aqui
important

O secret da API Key é exibido apenas uma vez durante a criação. Guarde-o em local seguro!

Cabeçalho opcional: X-PumaHelp-Prior-Session​

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.

observação

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çãoComportamento
JWT (Login)Scopes atribuídos automaticamente baseados na role do usuário
API KeyScopes devem ser explicitamente selecionados na criação
observaçã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)

ScopePermiteNo JWT
ticket:createCriar ticketsEnd-user e acima
ticket:readLer tickets — a posse (agente fora do grupo, cliente em ticket alheio) é aplicada pela regra de negócioEnd-user e acima
ticket:updateAtualizar tickets, com a mesma restrição de posseEnd-user e acima
ticket:deleteArquivar ticketsAdmin e Owner
ticket:events:readLer a linha do tempo — visão de equipe, nunca do clienteAgente e acima

Comentários (comment)

ScopePermiteNo JWT
comment:readLer comentáriosEnd-user e acima
comment:updateMarcar comentários como privadosAgente e acima
comment:redactSuprimir conteúdo de comentários e anexosAgente e acima

Usuários (user)

ScopePermiteNo JWT
user:createCriar usuáriosAdmin e Owner
user:readLer usuáriosAgente e acima
user:updateAtualizar usuáriosAdmin e Owner
user:deleteRemover usuáriosAdmin e Owner
user:upsertCriar ou atualizar (create_or_update)Admin e Owner
user:manageAções de gestão, como enviar a verificação de e-mailAgente e acima
user:impersonateGerar access token em nome de um usuárioAdmin e Owner
guest:createCriar convidados (/v2/guests, /v3/guests)End-user e acima

Perfil e conta (profile)

ScopePermiteNo JWT
profile:read:ownLer o próprio perfil (GET /v1/users/me)End-user e acima
account:update:ownAlterar o próprio e-mail e senhaEnd-user e acima

Sessão (session)

ScopePermiteNo JWT
session:delete:ownLogoutEnd-user e acima
session:mergeFundir uma sessão de convidado numa autenticadaSó end-user

Grupos (group)

ScopePermiteNo JWT
group:createCriar gruposAdmin e Owner
group:readLer grupos e membrosAgente e acima
group:updateAtualizar gruposAdmin e Owner
group:deleteRemover gruposAdmin e Owner

Macros (macro)

ScopePermiteNo JWT
macro:createCriar macrosAgente e acima
macro:readLer macrosAgente e acima
macro:updateAtualizar macrosAgente e acima
macro:deleteRemover macrosAgente e acima

API Keys (key)

ScopePermiteNo JWT
apikey:createCriar API KeysAdmin e Owner
apikey:readListar API KeysAdmin e Owner
apikey:updateAtualizar metadados e escopos de uma chaveAdmin e Owner
apikey:deleteRemover API KeysAdmin e Owner
apikey:rotateGerar novo secret para uma chaveAdmin e Owner

Organização (organization)

ScopePermiteNo JWT
organization:readLer a organizaçãoAgente e acima
organization:updateAlterar configurações da organização, políticas de SLA e horário comercialAdmin e Owner

Webhooks (webhook)

ScopePermiteNo JWT
webhook:createCriar webhooksAdmin e Owner
webhook:readListar webhooksAdmin e Owner
webhook:updateAtualizar webhooksAdmin e Owner
webhook:deleteRemover webhooksAdmin e Owner
webhook:rotateRotacionar a chave de assinatura de um webhookAdmin e Owner

Visualizações (view)

ScopePermiteNo JWT
view:createCriar visualizações salvas (as da organização só por Admin/Owner)Agente e acima
view:readListar visualizações e contagensAgente e acima
view:updateEditar visualizações, respeitando a posseAgente e acima
view:deleteRemover visualizações, respeitando a posseAgente e acima

Satisfação (satisfaction)

ScopePermiteNo JWT
satisfaction:createRegistrar a avaliação de um ticket em nome do solicitanteEnd-user e acima (só o próprio solicitante)
satisfaction:readLer o relatório de satisfação (CSAT) sem os demais relatóriosAdmin e Owner

Outros

ScopeCategoriaPermiteNo JWT
reports:readreportLer os relatórios (/v1/reports/*) e as políticas/horário de SLAAdmin e Owner
file:uploadfileEnviar 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.

BaldeQuem cai nele
api-keyRequisições com X-API-Key — uma fatia por chave
dashboardSessões de agente no painel
accountTeto agregado da organização, acima dos dois anteriores
ipLogin, refresh, logout, redefinição de senha, verificação de e-mail e as rotas de E-mail do Usuário Final — por endereço
anonymousRequisiçã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.

O limite por cliente ainda não bloqueia

Os baldes acima são medidos e os cabeçalhos saem, mas ainda não recusam requisições. Quando o bloqueio for ativado, o limite de cada cliente é o que vier em X-RateLimit-Limit, e os valores serão publicados aqui.

Acima deles há um teto de proteção por organização (por endereço, nas rotas sem login), bem acima do uso normal, que já vale: uma rajada que passa dele recebe 429 com Retry-After.

Headers, nas respostas das rotas com limite — não só nas recusadas:

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"
}
}
CampoTipoObrigatórioDescrição
subjectstringSimAssunto do ticket
prioritystringNãoPrioridade: low, normal, high, urgent (padrão: normal)
typestringNãoTipo: question, incident, problem, task
group_iduuidNãoID do grupo responsável
requesterobjectNãoDados do solicitante (se criar em nome de outro usuário)
requester.namestringNãoNome do solicitante
requester.emailstringNãoEmail do solicitante
requester.external_idstringNãoID externo do solicitante
requester.iduuidNãoID do solicitante
commentobjectSimPrimeiro comentário do ticket
comment.bodystringSim, salvo quando há uploadsConteúdo do comentário. Uma mensagem só com anexo é válida: informe uploads e deixe body vazio ou ausente
comment.publicbooleanNãoSe visível para o cliente (padrão: true)
comment.author_iduuidNãoID do autor (se diferente do usuário autenticado)
comment.uploadsarray[uuid]NãoIDs de arquivos anexados
comment.client_idstringNãoIdentificador 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
tagsarray[string]NãoTags para categorização
viaobjectNãoCanal de origem
via.channelstringNãoCanal: api, widget, discord
follow_up_of_public_idint64NãoAbre 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âmetroTipoDescriçãoObrigatório
pageintegerNúmero da páginaSim
page_sizeintegerItens por página — entre 10 e 100Sim
querystringFiltros e busca (ver abaixo). Máx. 500 caracteresNão
sort_bystringCampo para ordenação: created_at, updated_at, priority, status, slaNão
sort_orderstringOrdem: asc ou descNão
includearray[string]Campos extras: assignee, requester, last_comment, tags, sla, unreadNão

include=unread acrescenta unread_count a cada ticket: mensagens públicas de outra pessoa desde a última leitura de quem está pedindo. Sem o include, o campo vem null — o que é diferente de 0, que significa "nada por ler". Marque a leitura com POST /v1/tickets/{public_id}/read.

sort_by=sla ordena por urgência de prazo: usa o menor due_at entre os ciclos de SLA abertos e não pausados do ticket. Tickets sem ciclo aberto (ou com todos pausados) vão para o fim da lista, independente de sort_order — a ordem só decide entre os que têm prazo correndo.

Sintaxe do parâmetro query​

A busca é uma sequência de termos chave:valor separados por espaço, mais palavras soltas.

Como os termos se combinam:

  • Repetir a mesma chave é "ou": status:open status:pending traz tickets abertos ou pendentes. Vale para todas as chaves — assignee:joao assignee:maria traz os tickets dos dois. A exceção é subject, onde repetir significa "e" (o assunto precisa conter os dois textos), assim como acontece com as palavras da busca livre.
  • Chaves diferentes são "e": status:open priority:high traz apenas os que são abertos e de prioridade alta.
  • Não existem operadores AND/OR escritos. Se você escrever status:open AND priority:high, a palavra AND é tratada como texto de busca. Use apenas espaços.
  • Palavras sem chave: são busca textual no assunto do ticket: boleto atrasado procura tickets cujo assunto contenha "boleto" e "atrasado". Para uma frase exata, use aspas: "nota fiscal".
  • Valores com espaço precisam de aspas (simples ou duplas): subject:'erro no login'.

Chaves disponíveis:

ChaveValoresExemplo
statusnew, open, pending, solved, closedstatus:open
prioritylow, normal, high, urgentpriority:urgent
typequestion, incident, problem, tasktype:incident
tagsnome da tag. Várias na mesma aspa = "e"tags:financeiro, tags:'fiscal urgente'
assigneeUUID, e-mail, external_id, parte do nome, me, ou none/null para não atribuídosassignee:me, assignee:none
requesterUUID, e-mail, external_id, parte do nome, ou merequester:me
subjecttexto contido no assuntosubject:'erro no login'
group / group_idnome do grupo / UUID do grupogroup:suporte
idnúmero do ticketid:1042
archivedtrue ou falsearchived:true
created / updateddata (2026-01-31) ou período: today, yesterday, last_24_hours, last_7_days, last_30_dayscreated: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
Valores inválidos

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.

Para segmentar por cliente, use identificadores — nunca o nome

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):

CampoDescrição
solved_atQuando o ticket foi resolvido. Continua preenchido depois que o ticket é fechado; volta a nulo quando o ticket é reaberto
pending_sinceDesde 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_untilAté 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âmetroTipoDescrição
pageintPágina (a partir de 1)
page_sizeint10 a 100
sort_orderstringdesc (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" }
}
]
}
CampoDescrição
typeTipo do evento (catálogo abaixo)
from_value / to_valueO que foi gravado: descrição do enum (open, high, task…), id, texto. Nulo quando não se aplica
from_label / to_labelNome 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.typeend_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.nameNulo quando o ator não é um usuário (system) ou não existe mais (id continua)
countTotal 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

typefrom_value → to_valueSignificado
creatednulo → status inicialTicket aberto
status_changedstatus anterior → novoMudança de status (pelo agente, pelo cliente ao responder, ou pelo sistema — actor.type = system — no fechamento e na resolução automáticos)
assignedid do responsável anterior → do novo (nulo = sem responsável)Atribuição, transferência ou remoção do responsável
priority_changedprioridade anterior → nova
group_changedid do grupo anterior → do novo
subject_changedassunto anterior → novo
type_changedtipo anterior → novo
tags_changedetiquetas anteriores → novas (nomes ordenados, separados por vírgula; nulo = nenhuma)
requester_changedid do solicitante anterior → do novo
archived_changedfalse → true ao arquivar, true → false ao desarquivarTambém pelo DELETE
comment_addednulo → public ou internalUm comentário
comment_made_privatenulo → id do comentárioComentário público tornado interno
comment_redactednulo → id do comentárioSupressão de conteúdo — nunca guarda o que foi removido
attachment_redactednulo → id do anexoSupressão de anexo
satisfaction_ratednota anterior (nulo na primeira) → novaO cliente avaliou
follow_up_creatednulo → public_id do acompanhamentoGravado no ticket fechado quando a resposta do cliente abre um acompanhamento
sla_breachedmé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"
}
}
CampoTipoDescrição
subjectstringNovo assunto
statusstringNovo status: new, open, pending, solved, closed. A partir de closed nenhum status é aceito (400): fechado é definitivo
prioritystringNova prioridade
typestringNovo tipo
requester_iduuidTransferir para outro requester
assignee_iduuidAtribuir a agente (use null para desatribuir)
group_iduuidAtribuir a grupo
archivedbooleanArquivar ticket
tagsarray[string]Substituir tags
commentobjectAdicionar 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_iduuidAutor do comentário (se diferente do usuário autenticado)
comment.author_external_idstringAutor 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_idstringIdentificador que você gera para tornar a retentativa segura. Reenviar o mesmo valor na mesma conversa não cria um segundo comentário
observação

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 em comment.author_id ou comment.author_external_id; esse autor é um end-user; e é o solicitante do ticket. A requisição pode trazer junto só status open ou closed, que não mudam nada; qualquer outro campo devolve 400.

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 status diferente de closed, 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

observação

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"
}
CampoTipoDescrição
scorestringObrigatório. good, neutral ou bad
commentstringTexto livre do cliente (máx. 1000 caracteres)
author_iduuidIdentifica o autor quando quem chama não é o end-user (API key / staff)
author_external_idstringIdem, por external_id — author_id tem precedência
requested_atdatetimeQuando o convite de avaliação foi feito ao cliente (opcional, não pode ser futuro)

Regras:

  • Só tickets com status solved ou closed podem ser avaliados — reabrir o ticket fecha a janela — e só até 30 dias após a última resolução (rateable_until no 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 campos author_* são ignorados.
  • Integrações (API Key, ou o token de organização client_credentials, obsoleto) identificam o cliente por author_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_rated na 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âmetroDescrição
from, toObrigatórios. Janela em ISO 8601 (UTC)
tzFuso IANA para os buckets da série (default UTC)
channel, type, priority, group_id, assignee_id, tagFiltros 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.

Quem lê os relatórios

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âmetroTipoDescrição
fromdatetimeObrigatório. Início da janela, ISO 8601 em UTC. Valor sem indicador de fuso é lido como UTC
todatetimeObrigatório. Fim da janela, ISO 8601 em UTC. Deve ser posterior a from e no máximo 366 dias depois
tzstringFuso IANA usado para montar os buckets (ex.: America/Sao_Paulo). Default UTC
channelstringFiltro de segmento: api, discord ou widget
typestringFiltro de segmento: question, incident, problem ou task
prioritystringFiltro de segmento: urgent, high, normal ou low
group_iduuidFiltro de segmento: grupo responsável
assignee_iduuidFiltro de segmento: agente atribuído
tagstringFiltro 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 null quando 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 de tz, 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: null marca 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; reopened só 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órioCache
overview, team, sla1 minuto (misturam tendência com estado do instante)
attention1 minuto
volume, times, satisfaction5 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.

CampoDescrição
createdTickets criados na janela
resolvedResoluções na janela, por first_solved_at
reopenedDos 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_sampleMediana da 1ª resposta e o tamanho da amostra
resolution_median_minutes / resolution_sampleMediana da resolução e o tamanho da amostra
backlog.unrepliedAbertos sem nenhuma resposta pública de agente
backlog.age_bucketsIdade 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.

  • heatmap conta criações por (dia da semana, hora) no fuso pedido — day_of_week 0 = domingo.
  • backlog vem 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.

CampoDescrição
overallachieved e assessed do período inteiro
attainmentUma linha por (metric, priority) com o par achieved/assessed
overageMagnitude 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_riskAté 10 ciclos ativos (estado agora, não a janela), os de prazo mais próximo primeiro; pausados por último
recent_breachesAté 10 violações da janela, mais recentes primeiro
seriesAderência por bucket, com zero-fill

Três armadilhas de leitura:

  • overage.sample NÃ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 em recent_breaches com completed: false.
  • overage é retroativamente mutável, ao contrário do assessed. Uma violação estampada em julho e encerrada em agosto entra no sample de 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.

CampoDescrição
open_nowAbertos atribuídos agora — carga atual
resolvedResolvidos na janela, pela atribuição atual do ticket
resolution_median_minutesMediana da resolução dos tickets desse agente. null com amostra zero
recontact_rateFração (0–1, 4 casas) dos resolvidos cujo solicitante abriu outro ticket em até 24h. null com amostra zero
recontact_sampleDenominador 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​

Depreciado

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âmetroValores Aceitos
period24h, 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​

Depreciado

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âmetroValores Aceitos
period24h, 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. position nã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_condition e tag_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 resolution volta a correr no mesmo registro (o tempo em solved vira pausa, o prazo é reprojetado); o de first_reply não reabre (já aconteceu); um next_reply só 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.
  • clock escolhe o relógio: business (horas úteis do calendário da organização) ou calendar (24×7 corrido).
  • Sem horário comercial configurado, os alvos business correm 24×7 até alguém configurar o calendário.
  • first_reply e resolution abrem 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 em pending — resolver ou fechar o ticket encerra o ciclo aberto.
important

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ê pedir include=sla. Sem o include, o campo é null.
  • Para um usuário final (end_user), é sempre null — 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
}
]
CampoTipoDescrição
metricstringfirst_reply, next_reply ou resolution
cycle_numberintOrdinal do ciclo dentro da métrica (sempre 1 em first_reply/resolution; cresce em next_reply). Distingue dois ciclos da mesma métrica
prioritystringPrioridade com que o ciclo foi pactuado — a do ticket na época, não necessariamente a atual
due_atdatetime (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_atdatetime (UTC)Quando o ciclo entra em risco: 75% do prazo consumido
pausedbooleanReló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
breachedbooleanO 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"
}
CampoTipoDescrição
scorestringgood, neutral ou bad
commentstring | nullComentário livre, opcional
rated_atdatetime (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" }
]
}
CampoTipoDescrição
namestringObrigatório. Máx. 100 caracteres
positionintOrdem de avaliação (menor = primeiro), ≥ 0. Ausente → entra no fim da fila, antes da padrão
is_activeboolAusente → ativa
channel_conditionstringapi, discord ou widget. Nula = qualquer
type_conditionstringquestion, incident, problem ou task. Nula = qualquer
group_id_conditionuuidPrecisa existir na organização. Nula = qualquer
tag_conditionstringMáx. 50 caracteres, normalizada. Nula = qualquer
targetsarrayObrigatório, não vazio. Política sem alvo desligaria o SLA dos tickets que ela casar
targets[].metricstringfirst_reply, next_reply ou resolution
targets[].prioritystringurgent, high, normal ou low
targets[].duration_minutesint1 a 131.400
targets[].clockstringcalendar 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:

  • targets substitui 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; position e is_active nulos 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 }
]
}
CampoTipoDescrição
time_zonestringObrigatório. Identificador IANA (ex.: America/Sao_Paulo) — sem default silencioso
days[].day_of_weekint0 = domingo … 6 = sábado. Cada dia só pode aparecer uma vez; dia ausente = sem expediente
days[].start_minute / end_minuteintMinutos desde a meia-noite local, entre 0 e 1440, com início antes do fim. É uma janela contígua por dia
holidays[].datedateObrigatório. YYYY-MM-DD
holidays[].namestringObrigatório. Máx. 100 caracteres
holidays[].recurringboolRepete 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ênciaControlaPadrão
in_app_enabledo sino: as notificações no aplicativo (GET /v1/notifications e o evento em tempo real)ligado para staff
email_enabledo e-mail com o que não foi visto no paineldesligado

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âmetroTipoDescriçãoPadrãoObrigatório
pageintegerNúmero da página-Sim
page_sizeintegerItens por página — entre 10 e 100-Sim
sort_bystringCampo para ordenação: created_atcreated_atNão
sort_orderstringOrdem: asc ou descdescNã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

aviso

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): &nbsp; no lugar de espaço, espaços repetidos, e caracteres de largura zero (U+200B, U+FEFF).

Duas marcações.

MarcaçãoEfeito
<redact>texto</redact>O texto vira ████████.
atributo redact no elementoO 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.

aviso

É 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

observação

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"
}
CampoTipoObrigatórioDescrição
emailstringSimEmail do usuário
passwordstringSimSenha do usuário

Response: 200 OK

{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"verified": true
}
CampoTipoDescrição
access_tokenstringToken JWT para autenticação. A validade não é fixa: ao receber 401, renove com o refresh_token em POST /v1/users/refresh
refresh_tokenstringToken para renovar o access_token
verifiedbooleanSe 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"]
}
CampoTipoObrigatórioDescrição
namestringSimNome completo do usuário
emailstringCondicionalE-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
rolestringSimRole: end-user, agent, admin, owner
external_idstringNãoID externo para integração com outros sistemas
verifiedbooleanNãoMarca 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_idsarray[uuid]NãoIDs 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).".
Um e-mail, uma pessoa

Dentro de uma organização, um endereço identifica uma pessoa só: o e-mail de contato de um cliente (end-user) ou o e-mail de login de alguém da equipe. Criar um cliente com o e-mail de um agente, ou um agente com o de um cliente, é recusado. A comparação ignora maiúsculas e espaços nas pontas, e contam também os visitantes do widget que confirmaram o e-mail.

Para achar quem já usa o endereço, busque por ele em Listar Usuários. 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
}
dica

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"]
}
CampoTipoObrigatórioDescrição
emailstringCondicionalEmail 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_idstringCondicionalID externo (obrigatório se email não fornecido)
namestringNãoNome completo (usado apenas se criar novo usuário)
rolestringNãoRole: end-user, agent, admin, owner (padrão: end-user)
verifiedbooleanNãoMarca o e-mail como verificado — para quem já sabe que o endereço é da pessoa
group_idsarray[uuid]NãoNova 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
}
CampoTipoDescrição
access_tokenstringToken JWT para autenticação em nome do usuário
token_typestringSempre "Bearer"
expires_inintegerTempo de expiração em segundos
dica

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.

aviso

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âmetroTipoDescriçãoPadrão
pageintegerNúmero da página— (obrigatório)
page_sizeintegerItens por página — entre 10 e 100— (obrigatório)
rolesarray[string]Filtrar por roles (pode enviar múltiplos)-
querystringBusca por nome ou e-mail — de contato (clientes) ou de login (equipe)-
external_idstringFiltrar por ID externo-
group_iduuidFiltrar por grupo-
activebooleanFiltrar por usuários ativos-
include_guestsbooleanIncluir os visitantes anônimos do widgetfalse

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
}
]
}
CampoTipoDescrição
verifiedbooleanE-mail confirmado
guestbooleantrue para sessões anônimas do widget. Quem confirma o e-mail pelo widget passa a false: vira uma identidade duradoura
observação

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"]
}
CampoTipoDescrição
namestringNome do usuário
rolestringNova role do usuário. Promover um end-user à equipe dá a ele acesso ao painel (veja abaixo)
emailstringPara 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
notesstringNotas internas (não visível para end-users)
external_idstringID externo, único na organização
verifiedbooleanStatus de verificação de email
group_idsarray[uuid]Grupos do usuário
observação

Todos os campos são opcionais. Apenas os campos enviados serão atualizados.

Promover um cliente à equipe

Mudar o role de um end-user para agent, admin ou owner dá a ele acesso ao painel:

  • o login é o e-mail de contato do cliente, ou o email enviado na mesma requisição;
  • a pessoa recebe o e-mail de boas-vindas, o mesmo do convite de um agente, com o link de confirmação quando o endereço ainda não foi confirmado;
  • a sessão de cliente que ela tiver no widget deixa de ser renovada: continua valendo até expirar, sempre como cliente, e depois o widget abre uma sessão nova;
  • sem group_ids, ela entra no grupo padrão;
  • ela passa a ocupar uma vaga de agente.

Sem e-mail nenhum, a promoção é recusada com 400 ("Para fazer parte da equipe, o usuário precisa de um e-mail."). O e-mail pode ser dado antes, nesta mesma rota, ou na própria requisição da promoção. Com o limite de agentes do plano atingido, a promoção também é recusada com 400 ("Limite de agentes atingido. …"), e nada é alterado.

Response: 200 OK

{
"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

observação

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

dica

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:

CampoTipoObrigatórioDescrição
namestringNãoNome 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:

CampoTipoDescrição
iduuidID único do usuário convidado
namestringNome do convidado
access_tokenstringToken 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
}
}'
dica

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
}
CampoTipoDescrição
iduuidID único do usuário convidado
namestringNome do convidado
access_tokenstringToken JWT para autenticar as requisições
refresh_tokenstringRenove em POST /v1/users/refresh
expires_inintSegundos de validade do access_token. Não presuma um valor fixo: use este campo, não o número do exemplo
observação

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]"
}
CampoTipoObrigatórioDescrição
emailstringSimEndereço a confirmar, até 255 caracteres

Response: 202 Accepted — o código foi gerado e enviado:

{
"expires_in": 600,
"resend_in": 60
}
CampoTipoDescrição
expires_inintSegundos de validade do código
resend_inintSegundos 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:

LimiteValorMensagem do 429
Pedir de novo o mesmo endereço, na mesma sessão1 a cada 60 segundos"Aguarde para pedir outro código."
Códigos pedidos por uma sessão10 nas últimas 24 horas"Muitos códigos pedidos para este e-mail. Tente mais tarde."
Códigos enviados para um endereço5 na última hora"Muitos códigos pedidos para este e-mail. Tente mais tarde."
Tentativas de confirmação de um endereço, acertos incluídos10 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:

StatusQuando
400email 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.")
401sem sessão
429um 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"
}
CampoTipoObrigatórioDescrição
codestringSimOs 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:

StatusMensagemQuandoConta tentativa
400"Informe o código de 6 dígitos."o valor não tem 6 dígitosNão
400"Este recurso é apenas para usuários finais."token de alguém da equipeNão
400"Código incorreto."o código não confereSim
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ódigoSim — o código foi usado
401—sem sessãoNã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 tentativasNã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-AfterNão
observaçã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"
}
CampoTipoObrigatórioDescrição
namestringSimNome do grupo
descriptionstringNãoDescriçã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âmetroTipoDescriçãoObrigatório
pageintegerNúmero da páginaSim
page_sizeintegerItens por página — entre 10 e 100Sim
querystringBusca por nome do grupoNão
user_iduuidFiltrar grupos que contêm um usuário específicoNã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âmetroTipoDescriçãoObrigatório
pageintegerNúmero da páginaSim
page_sizeintegerItens por página — entre 10 e 100Sim
querystringBusca por nome do grupo ou por nome e e-mail dos membrosNão
user_iduuidFiltrar grupos que contêm um usuário específicoNã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
}
]
}
]
}
dica

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"
}
CampoTipoDescrição
namestringNovo nome do grupo
descriptionstringNova descrição
observaçã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

observação

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."
}
]
}
CampoTipoObrigatórioDescrição
titlestringSimTítulo da macro
descriptionstringNãoDescrição da macro
typestringSimTipo: user (usuário) ou group (grupo)
group_idsarray[uuid]NãoIDs dos grupos que podem usar esta macro. Omitir numa macro de grupo usa o grupo padrão da organização
actionsarray[object]SimLista de ações a executar
actions[].fieldstringSimCampo a modificar: comment_value, status, type, priority
actions[].valueanySimValor 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âmetroTipoDescriçãoObrigatório
pageintegerNúmero da páginaSim
page_sizeintegerItens por página — entre 10 e 100Sim

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"
}
]
}
observação

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:

EscopoQuem vêQuem cria/edita/apagaLimite
organizationTodos os agentes da organizaçãoOwner e Admin5 por organização
personalApenas quem criouO 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"
}
CampoTipoObrigatórioDescrição
namestringSimNome exibido (máx. 80). Único dentro do escopo
querystringNãoFiltro na sintaxe de GET /v1/tickets?query= (máx. 500). Vazio = todos os tickets visíveis
sort_bystringNãostatus, priority, created_at, updated_at ou sla (mesmos campos e mesma regra da listagem de tickets)
sort_orderstringNãoasc ou desc
scopestringSimorganization (somente Owner/Admin) ou personal

Response: 201 Created — mesmo formato de um item da listagem.

Erros:

  • 400 — limite de 5 atingido no escopo, nome já usado no escopo, ou campo inválido
  • 401 — agente tentando criar uma visualização organization

Atualizar Visualização​

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ção organization
  • 404 — 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"]
}
CampoTipoObrigatórioDescrição
namestringSimNome do webhook
urlstringSimURL que receberá as notificações
eventsarray[string]SimEventos 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"
}
observação

Cada organização pode criar no máximo 5 webhooks. Se você atingir esse limite, será necessário deletar um webhook existente antes de criar um novo.

important

A key é gerada automaticamente durante a criação do webhook e é usada para assinar as requisições enviadas. Guarde-a de forma segura, pois será necessária para validar a autenticidade dos webhooks recebidos.


Listar Webhooks​

GET https://acme.pumahelp.com/api/v1/webhooks?page=1&page_size=50
Authorization: Bearer {token}

Scope: webhook:read

Query Parameters:

ParâmetroTipoDescriçãoObrigatório
pageintegerNúmero da páginaSim
page_sizeintegerItens por página — entre 10 e 100Sim

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"]
}
CampoTipoDescrição
namestringNovo nome do webhook
urlstringNova URL
eventsarray[string]Novos eventos
observação

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.

aviso

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"
}
CampoTipoDescrição
keystringNova 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);
});
Valide sobre o corpo bruto, não sobre o objeto

JSON.stringify de um corpo já convertido em objeto não reproduz a string que foi assinada: a ordem das chaves, os espaços e o escaping se perdem no caminho. O sintoma é falha de validação em payloads não triviais.

Formato do payload​

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çalhoDescrição
X-Pumahelp-Webhook-TimestampHorário da tentativa, em segundos Unix. Entra na assinatura
X-Pumahelp-Webhook-SignatureBase64 do HMAC-SHA256, com a chave do webhook, sobre {timestamp}.{corpo bruto}
X-Pumahelp-Webhook-Event-IdO id do evento, igual ao do corpo e o mesmo em todas as tentativas
X-Pumahelp-Webhook-AttemptNúmero da tentativa: 1 na primeira, até 8
User-AgentPumaHelp-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
}
}
CampoDescrição
idIdentificador do evento. É o mesmo em todas as tentativas de entrega — use-o para descartar repetições
typeUm dos cinco eventos
timeQuando 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_version1.0
detailO recurso — o ticket ou o usuário — como estava no instante do evento
eventO 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):

CampoDescrição
id, public_idIdentificador interno e número do ticket
status, type, priorityOs mesmos valores do detalhe do ticket
subject, tagsAssunto e nomes das tags
created_at, updated_at, archived_atDatas em UTC; archived_at nulo se o ticket não está arquivado
organization_id, requester_id, assignee_id, group_idOs ids ligados ao ticket; nulos quando não há
via.channelCanal de origem: api, widget ou discord
follow_up_of{ "public_id", "subject" } do ticket fechado que este continua, quando o ticket é um acompanhamento; null nos demais. Sempre presente

event.comment traz o comentário que veio junto com o evento: id, body (HTML), public, author e, quando há anexos, uploads (id, file_name, content_type, size, content_url). body vem vazio ("") quando o comentário tem só anexos.

Nos eventos de usuário, detail é o usuário, e event traz changes no user.updated e é null nos demais.

Entrega, retentativas e ordem​

A entrega é pelo menos uma vez e sem garantia de ordem. Um receptor correto deduplica pelo id e ordena pelo time.

  • Se a mudança foi salva, a entrega existe; se falhou, nenhuma entrega é criada. Cada webhook inscrito recebe a própria entrega, e os destinos são tratados em separado: um destino lento ou fora do ar não atrasa os demais.
  • Sucesso é qualquer 2xx. Qualquer outra resposta, timeout (30 s) ou falha de rede conta como tentativa falha — inclusive 4xx. Cada tentativa é uma requisição.
  • Até 8 tentativas, com estas esperas entre elas: 5 s, 5 min, 30 min, 2 h, 5 h, 10 h e 10 h — cerca de 27,6 horas no total, com uma variação de até 20% em cada espera. Depois da oitava falha a entrega não é mais repetida.
  • 410 Gone encerra a entrega na hora, sem novas tentativas. Use-o para um evento que o seu sistema nunca vai aceitar; os próximos eventos continuam chegando.
  • Retry-After é respeitado nas respostas 429 e 503, em segundos ou como data HTTP: a próxima tentativa sai no maior valor entre a espera do cronograma e o pedido, com teto de 10 horas.
  • Responda 2xx também aos eventos que você ignora. Qualquer outra resposta faz a entrega ser repetida.
  • A assinatura é refeita a cada tentativa, com o horário daquela tentativa em X-Pumahelp-Webhook-Timestamp. Não compare esse cabeçalho com o time do corpo: numa retentativa eles diferem por minutos ou horas. Valide a assinatura pelo cabeçalho (janela de ±5 min é suficiente) e leia a data do evento pelo corpo.
  • O corpo é o do instante do evento, byte a byte o mesmo em todas as tentativas. Uma retentativa horas depois entrega o ticket como ele estava quando o evento ocorreu, não como está agora.
  • Não há garantia de ordem. Duas atualizações do mesmo ticket podem chegar invertidas — a primeira falhou e foi reenviada depois da segunda. Ordene pelo time do corpo e descarte o que for mais antigo do que o último estado que você já aplicou.
  • Pode haver repetição. O mesmo evento pode chegar duas vezes — por exemplo, quando o seu servidor respondeu 2xx mas a resposta se perdeu. Deduplique pelo id do corpo ou pelo cabeçalho X-Pumahelp-Webhook-Event-Id.
  • Excluir o webhook descarta as entregas pendentes dele.
  • ticket.updated também é emitido pelo sistema, no fechamento e na resolução automáticos.
  • Não há endpoint para consultar ou reenviar entregas. Se o seu endpoint ficou fora por mais tempo que a janela de tentativas, leia o estado atual pela API (por exemplo, GET /v1/tickets?query=updated>=2026-09-12).

🔑 API Keys​

Listar API Keys​

GET https://acme.pumahelp.com/api/v1/keys?page=1&page_size=50
Authorization: Bearer {token}

Scope: apikey:read

Query Parameters:

ParâmetroTipoDescriçãoObrigatório
pageintegerNúmero da páginaSim
page_sizeintegerItens por página — entre 10 e 100Sim

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"
}
]
}
CampoTipoDescrição
iduuidID da API Key
prefixstringPrefixo da chave para identificação rápida
namestringNome da API Key
descriptionstringDescrição
scopesarray[string]Escopos atribuídos
key_lookupstringIdentificador parcial da chave, usado para localizá-la nas listagens
created_atdatetimeData 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"]
}
CampoTipoObrigatórioDescrição
namestringSimNome da API Key
descriptionstringNãoDescrição do uso da chave
scopesarray[string]SimLista de escopos permitidos

Response: 201 Created

{
"key": "rk_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
}
cuidado

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"]
}
CampoTipoDescrição
namestringNovo nome
descriptionstringNova descrição
scopesarray[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
observação

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"
}
aviso

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

cuidado

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"
}
CampoDescrição
auto_close_resolved_tickets_minutesMinutos 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_minutesMinutos 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_ticketsVer Avisar o grupo sobre tickets sem responsável. Padrão false
sla_enabledSe 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.

Ao ligar, vale também para quem já está esperando

A regra conta a partir de pending_since, e não do momento em que foi ligada. Os tickets que já estão aguardando o cliente há mais tempo que o prazo são resolvidos logo em seguida, em até um minuto — cada um com o seu ticket.updated.

Response: 204 No Content


Avisar o grupo sobre tickets sem responsável​

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
Tipos fora da lista

Envie apenas os tipos acima. Um arquivo sem extensão ou com extensão fora da lista, ou cujo conteúdo não corresponda ao que a extensão promete — um executável renomeado para .png, por exemplo —, pode ser recusado com 400 ("Tipo de arquivo não permitido." ou "O conteúdo do arquivo não corresponde à extensão informada.").

observação

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);
});
observação

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âmetroTipoDescriçãoObrigatório
categorystringFiltrar por categoriaNã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)

observação

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á pending ou processing.
  • "Aguarde antes de gerar outra exportação (limite de 24h entre exportações)." — a última foi criada há menos de 24 h. A janela conta a partir da última solicitação, qualquer que seja o status — inclusive failed.

Listar Exportações​

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
}
]
CampoDescrição
statuspending (na fila), processing (gerando), ready (disponível), failed (erro ao gerar — peça outra)
file_size_bytesTamanho do arquivo; null até ficar pronto
completed_atQuando ficou pronta
expires_atcompleted_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/..." } ]
}
]
}
]
}
  • users traz só os ativos; tickets traz todos, arquivados inclusive, com todos os comentários (públicos e internos) e o body como foi gravado.
  • satisfaction é a avaliação do cliente — score, o comentário livre, rated_at (última) e first_rated_at (primeira) — ou null se o ticket não foi avaliado.
  • Anexos suprimidos não entram. Uma exportação gerada antes da supressão continua servindo o anexo até expirar.
  • A linha do tempo dos tickets e os ciclos de SLA não fazem parte do arquivo.

❌ Códigos de Erro​

A API retorna códigos HTTP padrão:

CódigoSignificadoDescrição
200OKRequisição bem-sucedida
201CreatedRecurso criado com sucesso
202AcceptedPedido aceito: processado em segundo plano (exportação de dados), ou código de e-mail enviado (Declarar E-mail)
204No ContentRecurso excluído com sucesso
400Bad RequestDados inválidos na requisição
401UnauthorizedToken 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
402Payment RequiredAssinatura inativa. A organização fica somente leitura para a equipe; ver abaixo
403ForbiddenA política de autorização reprovou: papel ou escopo insuficiente para o endpoint
404Not FoundRecurso 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
409ConflictConflito: 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
410GoneExportação expirada (Baixar Exportação), ou código de e-mail do usuário final vencido, usado ou esgotado (Confirmar E-mail)
429Too Many RequestsRate limit excedido (Rate Limiting), ou um limite do código de E-mail do Usuário Final
500Internal Server ErrorErro 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:

MensagemQuandoO que fazer
"Este e-mail já está em uso."o endereço já é de outra pessoa da organizaçãouse 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áriouse outro valor, ou atualize quem já o tem
"Este recurso já existe."duas requisições gravando o mesmo valor ao mesmo temporeleia e reenvie
"Este recurso foi alterado por outra pessoa. Recarregue e tente novamente."a linha mudou ou sumiu entre a leitura e a gravaçãoreleia 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"
]
}
O campo é error_messages

Não errors. Um cliente que leia errors recebe undefined e acaba mostrando uma mensagem genérica no lugar do motivo real.

Alguns erros trazem também um error_code, estável, para a sua integração decidir o que fazer sem depender do texto — que é para pessoas e pode mudar de redação. O campo só aparece quando existe:

{
"error_messages": ["Ticket fechado não pode ser reaberto. Uma resposta do cliente abre um ticket de acompanhamento."],
"error_code": "ticket_closed"
}
error_codeQuando
ticket_closedO 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/billing e /v1/exports, para dar como regularizar e como levar os dados embora;
  • login, logout e refresh;
  • DELETE /v1/users/{id}, para a organização poder reduzir agentes e caber num plano menor.

⚡ Quick Start​

# 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"
dica

Substitua acme em todos os exemplos pelo subdomínio da sua organização, disponível em Configurações → Organização.


💡 Melhores Práticas​

Segurança​

  • ✅ Use HTTPS sempre
  • ✅ Nunca exponha secrets no código versionado
  • ✅ Rotacione API Keys regularmente
  • ✅ Use princípio do menor privilégio (scopes mínimos necessários)
  • ✅ Valide assinaturas HMAC dos webhooks

Performance​

  • ✅ Use paginação adequada (page_size adequado)
  • ✅ Implemente retry logic com backoff exponencial
  • ✅ Cache dados que mudam pouco
  • ✅ Monitore headers X-RateLimit-*
  • ✅ Use webhooks em vez de polling

Integração​

  • ✅ Valide dados antes de enviar
  • ✅ Trate todos os códigos de erro
  • ✅ Implemente logs para auditoria
  • ✅ Teste em ambiente de desenvolvimento primeiro

🔧 Troubleshooting​

Erro 401: Unauthorized​

Sintoma: Todas as requisições retornam 401 Unauthorized

Causas Comuns:

  1. Token JWT expirado
  2. API Key inválida ou revogada
  3. Header de autorização mal formatado
  4. Token de um usuário excluído ou desativado

Soluções:

# Verifique o formato do header
# ✅ Correto:
Authorization: Bearer eyJhbGci...

# ❌ Incorreto:
Authorization: eyJhbGci...
Authorization: bearer eyJhbGci...

# Para API Key:
# ✅ Correto:
X-API-Key: rk_live_abc123...

# Renovar token expirado:
curl -X POST https://acme.pumahelp.com/api/v1/users/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token": "SEU_REFRESH_TOKEN"}'

Erro 403: Forbidden​

Sintoma: Request autenticado mas retorna 403 Forbidden

Causas Comuns:

  1. API Key sem o scope necessário
  2. Usuário sem permissão para a ação

Soluções:

# Verificar scopes da sua API Key:
curl -X GET https://acme.pumahelp.com/api/v1/keys \
-H "Authorization: Bearer SEU_TOKEN"

# Verificar scopes disponíveis:
curl -X GET https://acme.pumahelp.com/api/v1/scopes \
-H "Authorization: Bearer SEU_TOKEN"

# Atualizar API Key com scopes necessários ou criar nova

Erro 400: Bad Request​

Sintoma: Request retorna 400 com mensagem de validação

Causas Comuns:

  1. Campos obrigatórios faltando
  2. Formato de dados inválido
  3. Valor fora do range permitido

Exemplo de Response:

{
"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:

  1. ✅ URL está acessível publicamente (não localhost)?
  2. ✅ URL usa HTTPS? (recomendado)
  3. ✅ A URL é a final, sem redirecionamento? Um redirecionamento pode fazer a chamada chegar sem o corpo.
  4. ✅ Servidor responde com 2xx em menos de 30 segundos?
  5. ✅ Eventos selecionados estão corretos?
  6. ✅ Respondeu 2xx? Qualquer outro status conta como falha, e depois de 8 tentativas (~27,6 h) a entrega não é mais repetida; 410 encerra na hora. Ver Entrega, retentativas e ordem
  7. ✅ A assinatura foi validada com o X-Pumahelp-Webhook-Timestamp da tentativa, não com o time do corpo, e sobre o corpo bruto?

Testar Webhook:

# 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
MovimentoQuem fazO que fica registrado
new/open/pending/solved/closedagente ou API (PUT /v1/tickets/{public_id})status_changed na linha do tempo, com o ator
resposta do cliente em pending/solved → opencliente com token de end-user (o widget); uma integração que responde em nome dele reabre enviando "status": "open" junto do comentáriostatus_changed; a partir de solved conta como reabertura nos relatórios; first_solved_at não muda
pending → solved por inatividadesistema, se auto_solve_pending_tickets_minutes estiver definidostatus_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 temposistema, se auto_close_resolved_tickets_minutes estiver definidostatus_changed com ator system; solved_at é mantido
resposta do cliente em closedcliente 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.

EndpointUse 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-timeGET /v1/reports/volume
GET /v1/tickets/agent-rankingGET /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.