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. Também não há escopo para os Retidos do canal de e-mail nem para o e-mail original de um comentário: essas rotas aceitam só o JWT de alguém da equipe, e uma API Key recebe 403.

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, sem diferenciar maiúsculas de minúsculas (ian1 e IAN1 são a mesma pessoa) — use um identificador que o seu sistema nunca dá a duas pessoas mudando só as maiúsculas. Quem ainda não existe é criado com o valor como foi enviado
requester.iduuidNãoID do solicitante
commentobjectSimPrimeiro comentário do ticket
comment.bodystringSim, salvo quando há uploadsConteúdo do comentário, com até 4.000 caracteres contando a marcação HTML. 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": {} vale o mesmo que não enviar
via.channelstringNãoCanal: api, widget ou discord. email é reservado aos tickets que chegam pelo canal de e-mail e devolve 400. Ausente, null ou vazio ("") vale como não informado: o ticket fica com o canal do original, num acompanhamento, ou com api
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

Como o solicitante é achado. Com o token de um usuário final, o solicitante é sempre quem chama. Nos demais casos, pelo requester.id, quando vem (404 se não for de ninguém da organização); senão, pelo requester.email, de contato ou de login, sem diferenciar maiúsculas; e, só sem e-mail, pelo requester.external_id. Quem não é achado pelo e-mail nem pelo external_id é criado como cliente (end-user), com o e-mail e o external_id do pedido. Achada pelo e-mail, a pessoa não ganha o external_id do pedido: para gravá-lo, use Criar ou Atualizar Usuário. Um requester só com name cria sempre uma pessoa nova.

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.

Response: 409 Conflict — "O ExternalId informado já pertence a outro usuário.": o solicitante seria criado com um external_id que já é de outra pessoa da organização, em qualquer combinação de maiúsculas e minúsculas — um requester.email que ninguém tem, com o external_id de outra pessoa. Nada é criado: nem o ticket, nem o solicitante.


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'.
  • Codifique o query na URL. Um + sem codificar chega como espaço e quebra o valor em dois — num e-mail ou num external_id: requester:[email protected] precisa ir como requester%3Amaria%2Bloja%40example.com.

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 (o endereço inteiro), external_id, parte do nome, me, ou none/null para não atribuídosassignee:me, assignee:none
requesterUUID, e-mail (o endereço inteiro), external_id, parte do nome, ou merequester:me, requester:[email protected]
requester_external_ido external_id do solicitante — o valor inteiro, sem diferenciar maiúsculas de minúsculas (crm-4211 e CRM-4211 são o mesmo). Não procura o id da pessoa, o e-mail nem o nome — um external_id com forma de UUID é comparado como qualquer outrorequester_external_id:'crm-4211'
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.

Pessoas (requester, assignee). Um UUID ou um e-mail — qualquer valor com @ — acha só a pessoa dona dele; o e-mail vale o endereço inteiro. Qualquer outro valor acha a pessoa cujo external_id é igual a ele ou cujo nome o contém. Maiúsculas e minúsculas não fazem diferença em nenhum desses casos: requester:'CRM-4211' acha quem tem o external_id crm-4211. Para achar o solicitante só pelo external_id, sem passar pelo nome, use requester_external_id.

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". O external_id passa pelo mesmo caminho: requester:'24' traz o cliente 24 e quem tiver "24" no nome. Portanto filtrar requester: por nome ou por external_id não isola um cliente.

Se você usa a API para mostrar a cada cliente final apenas os tickets dele, filtre por requester_external_id, pelo UUID da pessoa ou pelo e-mail dela — ou por group_id, quando cada cliente tem um grupo próprio. Eles comparam o valor inteiro — o external_id e o e-mail sem diferenciar maiúsculas de minúsculas — e nunca o nome. A chave só acha quem tem o external_id gravado: para gravá-lo numa pessoa que já existe pelo e-mail, use POST /v1/users/create_or_update. Uma lista vazia quer dizer que nenhum ticket é dessa pessoa — mostre a lista vazia, sem refazer a busca com requester:.

# Correto — exato
requester_external_id:'crm-4211'
requester:'3f2a9c10-4b8e-4c1a-9f2e-8a7b6c5d4e3f'
requester:[email protected]

# Perigoso — traz outros clientes
requester:'Ana'
requester:'24'

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)

Para a equipe, o detalhe traz ainda email_reply quando a resposta pública vai também por e-mail (ver Respostas por e-mail):

"email_reply": { "mode": "email", "to": "[email protected]" }
CampoDescrição
email_reply.modeemail — ticket do canal de e-mail: cada resposta pública sai na hora, dentro do limite de envio da organização (ver Respostas por e-mail). widget — ticket do widget com o e-mail do solicitante confirmado: a resposta sai se o solicitante não a ler em 5 minutos
email_reply.toO endereço que recebe

O campo não vem quando a resposta não vai por e-mail. Num ticket do canal de e-mail, vem no lugar dele email_reply_unavailable, com o motivo:

"email_reply_unavailable": { "reason": "requester_inactive" }
reasonA resposta pública não vai por e-mail porque
no_requestero ticket não tem solicitante
requester_not_end_usero solicitante é da equipe
requester_inactiveo solicitante foi desativado
no_addresso solicitante não tem um e-mail válido
channel_unavailableo canal de e-mail não está ativo no servidor

Os dois campos nunca vêm juntos, só vêm no detalhe — a listagem de tickets não os traz — e nunca vêm para o usuário final.


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, SLA estourado e e-mail de resposta não entregue — 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
email_delivery_failedmotivo (bounced, complained, suppressed, delivery_failed, attempts_exhausted, send_window_expired, expired) → id do comentário da resposta (nulo na confirmação de abertura)O e-mail de uma resposta, ou da confirmação, não chegou ao cliente: voltou, foi marcado como spam, o endereço está bloqueado para envio, ou o envio não se completou. expired: a resposta não saiu em 7 dias e não é mais enviada. Ver Respostas por e-mail. Gravado uma vez por e-mail, 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 (até 4.000 caracteres, contando a marcação HTML), 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, sem diferenciar maiúsculas de minúsculas (ian1 e IAN1 são a mesma pessoa) — 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.

Um comentário público da equipe num ticket do canal de e-mail, ou do widget com o e-mail do solicitante confirmado, vai também por e-mail para o solicitante — ver Respostas por e-mail.

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, sem diferenciar maiúsculas de minúsculas (ian1 e IAN1 são a mesma pessoa) — 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, tagFiltros de segmento, opcionais
assignee_idFiltro de segmento, opcional: os tickets que esse agente resolveu pela primeira vez (veja abaixo)

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.

Satisfação de um agente. Com assignee_id, a resposta inteira — totals, series e recent_comments — passa a ser a dos tickets que esse agente resolveu pela primeira vez: reatribuir o ticket depois não move a avaliação, e um ticket cuja primeira resolução foi sem responsável não entra no recorte de nenhum agente (só no total da organização, sem assignee_id). A mesma conta, para todos os agentes de uma vez, está em GET /v1/reports/team.

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, widget ou email
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: um agente. Em satisfaction e team, os tickets que ele resolveu pela primeira vez (em team, open_now continua pelo responsável atual); nos demais relatórios, os tickets de que ele é o responsável atual
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 crédito de cada agente é de quem resolveu o ticket pela primeira vez (próximo item), e reatribuir não o reescreve; reopened só conta reaberturas ocorridas dentro da janela.
  • Crédito por agente: quem resolveu o ticket pela primeira vez — o responsável pelo ticket no momento da primeira resolução, mesmo quando outra pessoa o marcou como resolvido. Vale para os números por agente de team (menos open_now) e para satisfaction com assignee_id. Atribuir e resolver na mesma requisição (Atualizar Ticket) credita quem ficou como responsável; se a requisição deixar o ticket sem responsável, o crédito fica com quem era o responsável antes dela. Uma primeira resolução sem responsável não é creditada a ninguém, nem depois: reabrir, atribuir e resolver de novo não cria o crédito.
  • 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)
attention30 segundos
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, recontato e satisfação saem juntos na mesma linha. Tem linha todo agente com carga aberta agora, com tickets resolvidos no período ou com avaliações recebidas no período — um agente avaliado neste mês por um ticket que resolveu no mês passado aparece com resolved zero.

Os números do período contam para quem resolveu o ticket pela primeira vez (veja Regras de Leitura); só open_now segue o responsável atual.

CampoDescrição
open_nowAbertos atribuídos agora — carga atual
resolvedTickets cuja primeira resolução caiu na janela, creditados a quem os resolveu pela primeira vez
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 que têm solicitante
ratedAvaliações recebidas na janela pelos tickets que o agente resolveu pela primeira vez, contadas pela data da primeira resposta do cliente — inclusive as de tickets resolvidos antes da janela. Denominador do CSAT
good, neutral, badQuantas dessas avaliações são boas, neutras e ruins. CSAT = good ÷ rated, com o neutro no denominador
resolved_with_ratingDos resolved, quantos têm avaliação, de qualquer data. Taxa de resposta = resolved_with_rating ÷ resolved

Para ler a tabela:

  • A linha de um agente é a satisfação filtrada por ele. rated, good, neutral e bad são os totals de GET /v1/reports/satisfaction com o assignee_id desse agente e os mesmos filtros; resolved é o solved_in_period e resolved_with_rating, o solved_with_rating. Os dois relatórios têm prazos de cache diferentes (veja Regras de Leitura).
  • A soma das linhas pode ficar abaixo do total da organização. Um ticket cuja primeira resolução foi sem responsável conta no resolved da Visão Geral e nos totals da satisfação sem assignee_id, mas em nenhuma linha daqui. A avaliação de um ticket fechado sem ter passado por solved também entra nos totals da satisfação e em nenhuma linha.
  • Com assignee_id, vem no máximo uma linha, a desse agente: os números do período são dos tickets que ele resolveu pela primeira vez, e open_now, dos abertos atribuídos a ele agora.

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.0945,
"recontact_sample": 127,
"rated": 41,
"good": 35,
"neutral": 4,
"bad": 2,
"resolved_with_rating": 38
},
{
"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,
"rated": 0,
"good": 0,
"neutral": 0,
"bad": 0,
"resolved_with_rating": 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, taxa de recontato e satisfação 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, widget ou email. 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 essas notificações — nem no sino, nem no resumo por e-mail —, e as rotas respondem 401 a um token de end-user. Por e-mail, o cliente recebe a própria conversa: as respostas públicas da equipe nos tickets do canal de e-mail e nos tickets do widget em que ele confirmou o e-mail (Respostas por e-mail), além da confirmação de abertura dos tickets que chegam por e-mail. Nenhum outro aviso é enviado a ele.

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.

Comentários que vieram por e-mail. O body de um comentário que chegou pelo canal de e-mail pode ter até 20.000 caracteres, contando a marcação HTML. O limite de 4.000 vale para o que a API recebe em comment.body, não para o que ela devolve.

Para a equipe, esses comentários trazem também o campo email:

"email": {
"sender_verified": true,
"other_address": false,
"original_available_until": "2026-11-03T14:22:05Z",
"disposition": "reply"
}
CampoDescrição
sender_verifiedO remetente provou ser o dono do endereço: pela assinatura DKIM do domínio dele ou — fora do encaminhamento — por DMARC ou SPF alinhado ao remetente. Num e-mail encaminhado, só o DKIM conta. false: remetente não autenticado
other_addressO comentário é uma observação interna criada pelo e-mail de quem não é o solicitante — outra pessoa ou alguém da equipe. disposition separa os dois
original_available_untilAté quando o e-mail original pode ser lido (UTC). null depois dos 30 dias, quando um comentário desta conversa teve o texto suprimido — neste ticket ou num ticket fechado que ele continua —, ou quando quem enviou o e-mail foi excluído
dispositionComo o e-mail entrou: new_ticket (abriu o ticket), reply (comentário do solicitante), follow_up (abriu um acompanhamento de um ticket fechado), other_address_note (observação interna de quem não é o solicitante) ou staff_note (observação interna de alguém da equipe)

O campo não vem nos comentários que não chegaram por e-mail, e nunca vem para o usuário final.


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.

Num ticket com comentários que vieram por e-mail, suprimir o texto de qualquer comentário apaga também o e-mail original de todos eles e dos comentários dos acompanhamentos deste ticket, e o conteúdo dos e-mails dessa conversa que estão em Retidos (content_erased em Ver E-mail Retido). Os e-mails que chegarem depois nesses tickets, ou em Retidos para essa conversa, não guardam o original: a rota do original passa a responder 410, e email.original_available_until vem null. Os comentários continuam.

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. Uma resposta tornada privada antes de o e-mail dela sair não é enviada.


Ver E-mail Original​

GET https://acme.pumahelp.com/api/v1/tickets/{public_id}/comments/{comment_id}/email
Authorization: Bearer {token}

Quem pode: a equipe que vê o ticket — Owner, Admin e Agent, autenticados por JWT. Não existe escopo para esta rota: uma API Key recebe 403, assim como o usuário final. Agente fora do grupo do ticket recebe 404.

O e-mail de um comentário que chegou pelo canal de e-mail, como chegou: o texto inteiro, com o histórico citado que o comentário deixou de fora, quem mandou e para quem, um resumo dos cabeçalhos e os resultados de SPF, DKIM e DMARC. Sempre em texto: um e-mail que só tinha HTML vem convertido, e nenhum HTML de e-mail é devolvido. O original fica guardado por 30 dias.

Response: 200 OK

{
"sender": "[email protected]",
"sender_name": "Maria Souza",
"to": ["[email protected]"],
"cc": [],
"subject": "Boleto não chegou",
"received_at": "2026-10-04T14:22:05Z",
"text": "Bom dia! O boleto de outubro não chegou.",
"headers": "From: Maria Souza <[email protected]>\nTo: [email protected]\nSubject: Boleto não chegou",
"spf": "pass",
"dkim": "pass",
"dmarc": "pass",
"sender_verified": true,
"attachments": [
{ "file_name": "print.png", "size": 48213 }
],
"available_until": "2026-11-03T14:22:05Z"
}
CampoDescrição
sender, sender_nameO endereço do remetente, em minúsculas, e o nome de exibição (sem nome, ou com um nome que traz um @, a parte antes do @)
to, ccOs endereços do Para e do Cc, como vieram no e-mail
subjectO assunto como chegou
received_atQuando o e-mail chegou (UTC)
textO e-mail inteiro em texto, com o histórico citado
headersResumo dos cabeçalhos, um por linha (From: …, To: …, Message-ID: ……). null quando não houve nenhum
spf, dkim, dmarcOs resultados como foram apurados no recebimento (pass quando passou)
sender_verifiedO remetente provou ser o dono do endereço: pela assinatura DKIM do domínio dele ou — fora do encaminhamento — por DMARC ou SPF alinhado ao remetente. Num e-mail encaminhado, só o DKIM conta
attachmentsOs anexos como chegaram no e-mail: file_name e size, em bytes (null quando não informado). Os que foram gravados estão no próprio comentário
available_untilAté quando o original existe (UTC)

Erros:

StatusQuando
404O ticket não existe ou quem chama não pode vê-lo ("Ticket não encontrado."), ou o comentário não veio por e-mail ou é de outro ticket ("E-mail original não encontrado.")
410Passaram os 30 dias, um comentário desta conversa teve o texto suprimido — neste ticket ou num ticket fechado que ele continua —, ou quem enviou o e-mail foi excluído ("O e-mail original não está mais disponível: ele fica guardado por 30 dias e é apagado quando um comentário desta conversa é tarjado ou quando quem o enviou é excluído.")

📨 Canal de E-mail​

Tickets abertos por e-mail no endereço da organização chegam com via.channel: "email" — como ligar o canal e encaminhar a caixa de suporte está em E-mail. Para a API, eles são tickets como os outros: aparecem na listagem, disparam os webhooks, entram no SLA e nos relatórios e aceitam PUT normalmente. O que é próprio deles:

email não pode ser informado na criação de ticket: só o canal de e-mail abre tickets com ele.

Respostas por e-mail​

Um comentário público cujo autor é da equipe — o usuário autenticado, ou o autor informado no comentário — vai também por e-mail para o solicitante em dois casos:

TicketO e-mail
Do canal de e-mail (via.channel: "email")Sai na hora, dentro do limite de envio da organização (abaixo), um por resposta, com o assunto Re: [#N] seguido do assunto do ticket
Do widget (via.channel: "widget"), com o e-mail do solicitante confirmado pelo código de 6 dígitos (E-mail do Usuário Final)Sai 5 minutos depois da última resposta que ele não leu, num e-mail só com todas as não lidas. Se ele ler a conversa antes — POST /v1/tickets/{public_id}/read —, nada é enviado

Não vão por e-mail: observação interna; comentário do próprio cliente, ou de uma integração em nome dele; e os tickets do Discord e da API. O solicitante precisa ser um usuário final ativo, com e-mail. Nos tickets do widget, só conta o endereço confirmado pelo código: marcar o e-mail com verified: true, ou mandar email_verified: true em Impersonificar Usuário, não liga o envio. Os anexos da resposta vão como links, e a resposta vai só para o solicitante.

Cada organização envia até 300 e-mails do canal por hora; acima disso, as respostas esperam na fila e saem na ordem.

A resposta do cliente a esse e-mail entra no mesmo ticket, como comentário dele. Quando o e-mail não chega ao cliente, a linha do tempo do ticket ganha um evento email_delivery_failed. Uma resposta que não puder sair em até 7 dias não é mais enviada, e o ticket mostra que o e-mail não foi entregue.

Listar Retidos​

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

Quem pode: Owner e Admin, autenticados por JWT. Não existe escopo para Retidos: uma API Key recebe 403, assim como o agente e o usuário final. No painel, fica em Configurações → E-mail.

Os e-mails que chegaram ao endereço da organização e foram retidos nos últimos 14 dias, do mais novo para o mais antigo. Passado o prazo, o e-mail sai da lista e é apagado.

ParâmetroTipoDescriçãoObrigatório
pageintegerNúmero da página, a partir de 1Sim
page_sizeintegerItens por página — entre 10 e 100Sim

Response: 200 OK

{
"count": 1,
"emails": [
{
"id": "6b0e4c1a-…",
"sender": "[email protected]",
"subject": "Resposta automática: Boleto não chegou",
"reason": "auto_reply",
"received_at": "2026-10-04T15:02:11Z",
"spf": "pass",
"dkim": "pass",
"dmarc": "pass",
"releasable": true,
"expires_at": "2026-10-18T15:02:12Z"
}
]
}
CampoDescrição
senderO endereço do remetente, em minúsculas
subjectO assunto como chegou. null quando o e-mail não tinha, ou quando a supressão de um comentário da conversa apagou o conteúdo (content_erased em Ver E-mail Retido)
reasonPor que foi retido (tabela abaixo)
received_atQuando o e-mail chegou (UTC)
spf, dkim, dmarcOs resultados como foram apurados no recebimento. null quando não vieram
releasableSe pode ser liberado
expires_atAté quando fica em Retidos (UTC)
countTotal de e-mails em Retidos, não o tamanho da página
reasonQuando
possible_loopO remetente é um endereço @mail.pumahelp.com — uma resposta da organização voltando pelo encaminhamento —, ou está entre os destinatários do Para, ou é a própria caixa que encaminhou o e-mail: a caixa que encaminha escrevendo para si mesma, ou quem escreveu para si mesmo com cópia para o suporte
auto_replyResposta automática: Auto-Submitted com valor diferente de no, Precedence: auto_reply ou cabeçalhos de autorresposta
system_senderRemetente de sistema: mailer-daemon, postmaster ou endereço com noreply, no-reply, donotreply
authentication_failedSPF fail e nenhuma assinatura DKIM válida do domínio do remetente
sender_volumeDa 21ª à 40ª mensagem do mesmo remetente na última hora. Da 41ª em diante, a mensagem é descartada e não aparece aqui — menos a resposta a um e-mail enviado pela organização, que continua vindo para cá
staff_senderAlguém da equipe abrindo ticket, respondendo a um ticket fechado ou sem a assinatura DKIM do domínio dele

releasable é false em staff_sender, no possible_loop de um endereço @mail.pumahelp.com e no e-mail de alguém da equipe retido por outro motivo — possible_loop inclusive — quando ele não entraria como observação interna. O outro possible_loop — o remetente entre os destinatários, sem ser da equipe — pode ser liberado. Decida pelo campo, não pelo motivo.

Ver E-mail Retido​

GET https://acme.pumahelp.com/api/v1/email/retained/{id}
Authorization: Bearer {token}

Quem pode: Owner e Admin, como em Listar Retidos.

Os campos da lista, mais o conteúdo — sempre em texto: um e-mail que só tinha HTML vem convertido, e nenhum HTML de e-mail é devolvido. Um e-mail retido não guarda os anexos: eles aparecem só pelo nome e pelo tamanho.

Response: 200 OK

{
"id": "6b0e4c1a-…",
"sender": "[email protected]",
"sender_name": "Maria Souza",
"sender_verified": true,
"subject": "Resposta automática: Boleto não chegou",
"reason": "auto_reply",
"received_at": "2026-10-04T15:02:11Z",
"spf": "pass",
"dkim": "pass",
"dmarc": "pass",
"releasable": true,
"expires_at": "2026-10-18T15:02:12Z",
"text": "Estou fora do escritório até segunda-feira.",
"headers": "From: Maria Souza <[email protected]>\nTo: [email protected]\nSubject: Resposta automática: Boleto não chegou\nAuto-Submitted: auto-replied",
"attachments": []
}
CampoDescrição
sender_nameO nome de exibição do remetente (sem nome, ou com um nome que traz um @, a parte antes do @)
sender_verifiedO remetente provou ser o dono do endereço: pela assinatura DKIM do domínio dele ou — fora do encaminhamento — por DMARC ou SPF alinhado ao remetente. Num e-mail encaminhado, só o DKIM conta
textO e-mail inteiro em texto, com o histórico citado
headersResumo dos cabeçalhos, um por linha. null quando não houve nenhum
attachmentsfile_name e size (bytes; null quando não informado) de cada anexo
content_erasedtrue quando o conteúdo foi apagado porque um comentário da conversa a que o e-mail pertence teve o texto suprimido: subject e headers vêm null, e text vazio. Não vem nos outros casos

404 quando o e-mail não existe nesta organização ou não está mais em Retidos — liberado, descartado ou com os 14 dias vencidos.

Liberar E-mail Retido​

POST https://acme.pumahelp.com/api/v1/email/retained/{id}/release
Authorization: Bearer {token}

Quem pode: Owner e Admin, como em Listar Retidos.

Sem corpo. Processa o e-mail como se não tivesse sido retido, pelas regras de sempre: a resposta entra no ticket dela, como comentário ou observação interna; a resposta a um ticket fechado entra no acompanhamento aberto de quem escreve, ou abre um; e um e-mail novo vira ticket. O conteúdo e os anexos são buscados de novo nessa hora. Nenhuma confirmação é enviada, e o comentário mantém email.sender_verified: false quando o remetente não era autenticado.

Response: 201 Created — o e-mail abriu um ticket, novo ou acompanhamento. 200 OK — entrou num ticket que já existia. Liberar de novo devolve o mesmo resultado, sem duplicar nada.

{
"ticket": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"public_id": 482,
"subject": "Boleto não chegou"
},
"disposition": "reply"
}
dispositionComo o e-mail entrou
new_ticketAbriu um ticket novo, com o remetente como solicitante
replyComentário público do solicitante num ticket que não está fechado
follow_upAbriu um acompanhamento de um ticket fechado, com o remetente como solicitante
other_address_noteObservação interna de quem não é o solicitante
staff_noteObservação interna de alguém da equipe

Erros:

StatusQuando
400O e-mail não pode ser liberado, e o motivo vem em error_messages: releasable: false (alguém da equipe, ou um possível laço com um endereço @mail.pumahelp.com); o remetente é da equipe — ou passou a ser — e o e-mail não entraria, agora, como observação interna com a assinatura DKIM do domínio dele; o e-mail abriria um ticket novo com o canal desligado na organização; ou o canal de e-mail não está ativo no servidor
404O e-mail não existe nesta organização, não está em Retidos (já descartado, por exemplo) ou passou dos 14 dias
409Outro pedido está liberando o mesmo e-mail ("Este e-mail está sendo liberado agora. Recarregue Retidos em instantes para ver o resultado."). Nada muda: releia Retidos e, se ele continuar lá, repita
410O e-mail já não pode ser buscado de novo para a liberação
503O provedor de e-mail não respondeu ("O provedor de e-mail não respondeu. Tente de novo em instantes.") ou a cota de e-mails dele acabou ("A cota de e-mails do provedor acabou. Este e-mail poderá ser liberado quando a cota renovar."). O e-mail continua em Retidos, e o pedido pode ser repetido

Descartar E-mail Retido​

DELETE https://acme.pumahelp.com/api/v1/email/retained/{id}
Authorization: Bearer {token}

Quem pode: Owner e Admin, como em Listar Retidos.

Apaga na hora o conteúdo do e-mail — assunto, texto, cabeçalhos e a lista de anexos — e o tira de Retidos. Vale também para os e-mails que não podem ser liberados.

Response: 204 No Content — também quando o e-mail já tinha sido descartado. 404 quando ele não existe nesta organização, não está em Retidos (já liberado, por exemplo) ou passou dos 14 dias. 409 enquanto o mesmo e-mail está sendo liberado ("Este e-mail está sendo liberado agora. Recarregue Retidos em instantes para ver o resultado."): nada muda, e o pedido pode ser repetido.


👥 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. É único na organização sem diferenciar maiúsculas de minúsculas (crm-7 e CRM-7 são o mesmo) — use um identificador que o seu sistema nunca dá a duas pessoas mudando só as maiúsculas — e é gravado e devolvido como foi enviado. Em branco ("" ou só espaços) conta como não informado
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 — nada é criado:

  • "Este e-mail já está em uso." — o e-mail já identifica outra pessoa da organização;
  • "O ExternalId informado já pertence a outro usuário." — o external_id já é de outra pessoa da organização, em qualquer combinação de maiúsculas e minúsculas.

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, também sem diferenciar maiúsculas de minúsculas. Se existir, atualiza; se não, cria. Ao atualizar, o external_id fica como você o enviou, inclusive nas maiúsculas e minúsculas: quem tinha crm-7 e é achado por CRM-7 passa a ter CRM-7, e a troca sai num user.updated. Use um identificador que o seu sistema nunca dá a duas pessoas mudando só as maiúsculas: aqui, abc e ABC são a mesma pessoa, e sincronizar uma atualiza a outra. Um external_id em branco ("" ou só espaços) conta como não informado e, ao contrário de Atualizar Usuário, não remove o de quem for encontrado. Um usuário excluído não é mais encontrado pelo e-mail nem pelo external_id que tinha: 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 — em qualquer combinação de maiúsculas e minúsculas — é 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]",
"email_verified": true,
"external_id": "crm-user-123",
"name": "João Silva",
"role": "end-user",
"verified": false
}
CampoTipoObrigatórioDescrição
emailstringCondicionalEmail do usuário (obrigatório se external_id não fornecido). Em branco ("" ou só espaços) conta como não informado. Com external_id, o e-mail só conta para um usuário final com email_verified: true (abaixo)
email_verifiedbooleanNãoO seu sistema confirmou que o email é desta pessoa — por um link ou um código enviado a ela, por exemplo. Só conta junto com external_id. O campo não é gravado: ele não marca o e-mail como verificado (isso é o verified) e não faz as respostas do widget irem por e-mail — só o código do widget faz
external_idstringCondicionalO identificador da pessoa no seu sistema (obrigatório se email não fornecido), sem diferenciar maiúsculas de minúsculas: ian1 e IAN1 são a mesma pessoa — use um identificador que o seu sistema nunca dá a duas pessoas mudando só as maiúsculas. Vem antes do e-mail na busca. Quem é criado nasce com o valor como foi enviado. Em branco ("" ou só espaços) conta como não informado
namestringNãoNome completo (usado apenas se criar novo usuário)
rolestringNãoRole: end-user, agent, admin, owner (padrão: end-user). Sem role, ou com end-user, o pedido é de cliente e nunca devolve o token de alguém da equipe. O login da equipe pelo seu site manda o role da pessoa
verifiedbooleanNãoMarca o e-mail como verificado quando a pessoa nasce com ele ou ganha o do pedido (abaixo) — para quem já sabe que o endereço é da pessoa. Não liga ninguém: isso é o email_verified
group_idsarray[uuid]NãoGrupos de quem é criado na equipe, como em Criar Usuário. Os grupos de quem já existe não mudam

Como a pessoa é achada. Sem external_id, o e-mail acha a pessoa, ou ela é criada com ele. Num pedido de cliente, o e-mail de alguém da equipe é recusado com 409.

Com external_id:

  1. Pelo external_id, sem diferenciar maiúsculas de minúsculas. Achou: é ela — num pedido de cliente, nunca alguém da equipe (409). Com email_verified: true, quem só tinha aquele e-mail na organização — um cliente que escreveu para o suporte por e-mail, por exemplo — é juntado a ela, com os tickets, as conversas, as avaliações e, se ela ainda não tem e-mail, o e-mail. Sem ninguém a juntar, ela ganha o e-mail do pedido se ainda não tem um e ninguém mais o usa.
  2. Pelo e-mail, quando ninguém tem o external_id:
    • o e-mail é de um usuário final → com email_verified: true, é ele, e ele passa a ser achado também pelo external_id (se ele já tem outro, 409); sem email_verified, nasce uma pessoa nova, sem o e-mail — num pedido de equipe, 409;
    • o e-mail é de alguém da equipe → num pedido de equipe, é essa pessoa; num pedido de cliente, nasce uma pessoa nova, sem o e-mail.
  3. Ninguém com o e-mail → a pessoa é criada. Um usuário final só nasce com o e-mail com email_verified: true; sem ele, nasce sem e-mail. Alguém da equipe nasce sempre com ele, que é o login.

O usuário final que já existia na organização antes de o email_verified existir continua sendo achado pelo e-mail, com ou sem email_verified, e, se ainda não tinha um external_id, fica com o do pedido.

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

Response: 409 Conflict — nada é criado nem ligado, e nenhum token sai:

  • "Este e-mail já está em uso." — num pedido de cliente sem external_id, o e-mail é de alguém da equipe; com email_verified: true, o e-mail é de um usuário final que já tem outro external_id; num pedido de equipe, o e-mail é de um usuário final e o pedido não traz email_verified: true; ou, como em toda criação, outra pessoa ficou com o e-mail no mesmo instante;
  • "O ExternalId informado já pertence a outro usuário." — num pedido de cliente, o external_id é de alguém da equipe: o external_id gravado nela, em qualquer combinação de maiúsculas e minúsculas, ou, quando o valor tem a forma de um UUID, o id dela. Em qualquer pedido, também quando a pessoa seria criada com um external_id que, em qualquer combinação de maiúsculas e minúsculas, ainda é de alguém absorvido por outra pessoa.

Os 409 de e-mail não valem para o usuário final que já existia antes do email_verified (acima): o e-mail continua achando-o.

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) — no login da equipe, mande o role da pessoa: sem ele, o pedido é de cliente.

Um usuário excluído não é mais encontrado — nem pelo e-mail, nem pelo external_id que tinha, nem pelo id dele mandado como external_id: a chamada segue como se ele não existisse e, se ninguém mais for achado, 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 — o valor inteiro, sem diferenciar maiúsculas de minúsculas (CRM-123 acha quem tem crm-123)-
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 sem diferenciar maiúsculas de minúsculas (crm-7 e CRM-7 são o mesmo), gravado como foi enviado. Em branco ("" ou só espaços) remove o ID externo; ausente ou null, não muda nada
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." (o external_id de outra pessoa, em qualquer combinação de maiúsculas e minúsculas). Nada é alterado, nem os outros campos da mesma requisição. Reenviar o e-mail que o usuário já tem nunca é conflito, nem mudar só as maiúsculas e minúsculas do external_id dele: trocar crm-7 por CRM-7 grava CRM-7.


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.

No canal de e-mail, a exclusão também cancela os e-mails que ainda iam sair para a pessoa e apaga as cópias dos e-mails dela guardadas pelo canal: o e-mail original das mensagens dela passa a responder 410, e os e-mails dela em Retidos são apagados.


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.

Confirmado o endereço, as respostas públicas da equipe que a pessoa não ler no widget também chegam por e-mail — ver Respostas por e-mail.

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, discord ou email
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, e pode ter até 20.000 caracteres quando o comentário chegou por e-mail.

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,
"email_channel_enabled": false,
"email_channel_address": "[email protected]",
"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
email_channel_enabledSe a organização recebe tickets por e-mail. Ver Canal de e-mail da organização. Padrão false
email_channel_addressO endereço de recebimento da organização, em minúsculas: o subdomínio seguido de @mail.pumahelp.com. Vem com o canal ligado ou desligado; null quando o recebimento por e-mail não está disponível

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 conclui os ciclos de SLA abertos, entra na linha do tempo com ator system, dispara ticket.updated e abre a janela de avaliação. Quando é a primeira resolução do ticket, ela é creditada ao responsável (aparece em GET /v1/reports/team); um ticket sem responsável não é creditado a ninguém. 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.


Canal de e-mail da organização​

PUT https://acme.pumahelp.com/api/v1/organizations/settings/email-channel
Authorization: Bearer {token}
Content-Type: application/json

Scope: organization:update (Owner/Admin)

Liga ou desliga o recebimento de tickets por e-mail no endereço da organização (email_channel_address). Desligado por padrão. Desligado, os e-mails novos para o endereço da organização são descartados e não ficam guardados — antes de desligar, remova o encaminhamento da caixa de suporte. As conversas em andamento não param: as respostas aos e-mails que a organização já enviou continuam entrando nos tickets, e as respostas da equipe continuam indo por e-mail (Respostas por e-mail). No painel, fica em Configurações → E-mail; o encaminhamento da caixa de suporte está em E-mail.

Request Body:

{
"email_channel_enabled": true
}

O campo é obrigatório: um corpo sem ele, ou com null, devolve 400 ("Informe email_channel_enabled: true liga o recebimento de tickets por e-mail, false desliga.").

Response: 204 No Content

O valor atual vem em GET /v1/organizations como email_channel_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.").

O content_type do arquivo

O content_type da resposta — o mesmo com que o arquivo é servido no content_url — sai da extensão do nome do arquivo. O tipo declarado no envio só vale quando é de imagem, vídeo, áudio ou PDF: image/png, image/jpeg, image/gif, image/webp, image/bmp, image/tiff, image/heic, image/heif, image/avif, video/mp4, video/quicktime, video/webm, audio/mpeg, audio/wav, audio/ogg, audio/mp4 e application/pdf. Assim, uma foto enviada sem extensão, como image/jpeg, continua uma imagem. text/html e image/svg+xml não estão na lista, nem .html e .svg entre as extensões: um arquivo HTML ou SVG é servido como application/octet-stream, para baixar, e não abre no navegador.

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, foi alterado por outra pessoa entre a leitura e a gravação, ou está sendo liberado por outro pedido (Liberar E-mail Retido)
410GoneExportação expirada (Baixar Exportação), código de e-mail do usuário final vencido, usado ou esgotado (Confirmar E-mail), ou e-mail original que não está mais disponível (Ver E-mail Original)
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
503Service UnavailableO provedor de e-mail não respondeu, ou a cota dele acabou, e nada mudou: o pedido pode ser repetido (Liberar E-mail Retido)

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ário, em qualquer combinação de maiúsculas e minúsculasuse 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
"Este e-mail está sendo liberado agora. Recarregue Retidos em instantes para ver o resultado."outro pedido está liberando o mesmo e-mail retidoreleia Retidos em instantes e, se ele continuar lá, 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 ou por e-mail 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) ou respondendo por e-mail; 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; na primeira resolução do ticket, 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 por e-mail, ou integração que identifica o solicitante como autor (ver Atualizar Ticket)ticket novo com follow_up_of; o original ganha follow_up_created. Por e-mail, se quem escreve já tem um acompanhamento desse ticket que não está fechado, a mensagem entra nele

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. Quando é a primeira resolução do ticket, a resolução automática conta no mês em que aconteceu e é creditada a quem estava atribuído (sem responsável, a ninguém); uma primeira resolução que já existia 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: Outubro de 2026.