Pular para o conteúdo

Webhooks

Com webhooks, o Pega Vez faz um POST no seu servidor quando algo acontece no salão — em vez de você consultar a agenda de tempos em tempos.

Webhooks são gerenciados só no painel, em Configurações → Integrações → Webhooks, por um usuário logado com a permissão Gerenciar integrações (integrations.manage). Essa permissão nunca vai num token: um token de API não consegue criar, alterar nem ler webhooks.

Como os eventos levam dados de clientes para fora (nome, telefone, e-mail), criar, editar, ativar, enviar teste e ver as entregas exigem também a permissão Ver clientes (customers.view) — o perfil Dono tem as duas. Só com integrations.manage dá para listar, desativar, excluir e gerar um novo segredo.

  1. Informe a URL que vai receber os eventos: https://, até 2000 caracteres, sem usuário e senha na URL, e acessível pela internet (veja Segurança).
  2. Escolha os eventos e, se quiser, uma descrição (até 200 caracteres).
  3. Copie o segredo de assinatura (whsec_...). Ele aparece uma única vez, na criação; o Pega Vez o guarda cifrado para assinar os envios.

Cada salão pode ter até 10 webhooks. No painel também dá para:

  • Editar a URL, os eventos e a descrição. Editar não muda se o webhook está ativo ou desativado (só os botões de desativar e reativar mudam).
  • Desativar e reativar. Desativar cancela as entregas pendentes; reativar zera a contagem de falhas, e o que ficou pendente de antes não é mais enviado (nada chega atrasado depois de reativar).
  • Gerar um novo segredo (rotação). O novo segredo vale na hora e aparece uma única vez; o antigo para de valer no mesmo instante — não há período em que os dois convivam. Atualize o receptor logo em seguida (veja Trocar o segredo).
  • Enviar teste: manda agora um evento ping e mostra o resultado. Funciona mesmo com o webhook desativado. Um teste que falha não conta como falha; um teste com sucesso zera a contagem de falhas seguidas (o receptor voltou), sem reativar o webhook.
  • Ver as entregas dos últimos 30 dias, com cada tentativa: status, código HTTP, duração, erro e até 2 KB do corpo da sua resposta.
  • Excluir o webhook, junto com o histórico de entregas.

Se o salão mudar para um plano sem integrações, o painel ainda deixa ver, desativar e excluir os webhooks (para limpar o que não vai mais usar); criar, editar, reativar, testar e gerar segredo voltam com um plano que tenha integrações.

Evento Quando acontece
appointment.created Um agendamento foi criado: pela equipe, pela página pública, numa série recorrente, pela API ou pelo MCP.
appointment.rescheduled Um agendamento mudou de horário ou de profissional.
appointment.cancelled Um agendamento foi cancelado (pela equipe, pelo cliente, pelo link do lembrete, pela API, pelo MCP ou pela anonimização do cliente, que cancela os futuros).
appointment.status_changed O status mudou: confirmado, chegou, em atendimento, concluído, não compareceu. O cancelamento sai só como appointment.cancelled.
order.closed Uma comanda foi fechada.
order.cancelled Uma comanda foi cancelada.
customer.created Um cliente novo foi cadastrado (pela equipe, num agendamento com cliente novo ou pela página pública).
ping Só no Enviar teste do painel; não dá para assinar.

Os eventos nascem junto com a mudança, na mesma transação: valem para todo caminho (painel, página pública, links dos lembretes, séries, API e MCP) e nunca saem de uma mudança que não foi gravada.

Eventos só são gerados enquanto o webhook está ativo e assina aquele tipo. Também não são gerados se a conta do salão estiver bloqueada (assinatura inativa) ou num plano sem integrações; o que já tinha sido gerado segue as retentativas. Depois de reativar um webhook, use a API para buscar o que mudou no intervalo.

Todo evento tem o mesmo envelope, em JSON:

{
"id": "0b8e6f3a-5d21-4c7e-9f14-7a2c3d9e8b10",
"type": "appointment.created",
"createdAt": "2026-11-03T13:02:11.482913+00:00",
"salonId": "3f6c2b1e-8d4a-4c4e-9b7a-2a5d0e9f1c11",
"data": { }
}
Campo O que é
id Identificador único do evento (UUID). Igual em todas as tentativas de entrega: use para descartar repetidos.
type Um dos eventos da tabela acima.
createdAt Quando o evento aconteceu.
salonId Salão de origem.
data O recurso afetado, no formato do tipo de evento (abaixo).

Regras de formato, iguais para todos os eventos:

  • campos em camelCase e enums como texto ("Confirmed", "Pix");
  • instantes em UTC (ISO 8601 com +00:00; frações de segundo podem vir ou não) — diferente da API, que devolve no fuso do salão. Converta para America/Sao_Paulo para exibir;
  • valores em reais (BRL), número com até 2 casas decimais;
  • campos sem valor vêm como null;
  • campos novos podem aparecer em data sem aviso (veja compatibilidade): ignore o que não conhece.

Os quatro eventos de agendamento têm o mesmo data, com o estado do agendamento depois da mudança.

Campo Tipo O que é
id UUID Agendamento.
status enum Requested, Confirmed, Arrived, InService, Completed, Cancelled ou NoShow.
previousStatus enum ou null Só em appointment.status_changed: o status anterior.
origin enum Por onde entrou: Online (página pública), Reception (recepção) ou Professional (o próprio profissional).
startsAt, endsAt instante Início e fim.
previousStartsAt instante ou null Só em appointment.rescheduled: o início anterior (igual ao atual se só o profissional mudou).
previousProfessionalId UUID ou null Só em appointment.rescheduled: o profissional anterior (igual ao atual se só o horário mudou).
professional objeto id e name do profissional.
customer objeto id, name, phone (E.164, ex.: +5511999998888) e email. Cliente anonimizado sai como "Cliente anonimizado", sem telefone nem e-mail.
services lista serviceId, name, price (cobrado, já com promoção) e durationMinutes, na ordem do atendimento.
totalPrice número Soma dos preços cobrados.
comboId, comboName UUID / texto ou null Combo, se o agendamento veio de um.
seriesId UUID ou null Série recorrente, se faz parte de uma.
arrivedAt, startedAt, completedAt, noShowAt, cancelledAt instante ou null Quando cada etapa aconteceu.
cancelledBy enum ou null Customer ou Salon.
cancellationReason texto ou null Motivo do cancelamento.
createdAt, updatedAt instante Criação e última alteração.
{
"id": "0b8e6f3a-5d21-4c7e-9f14-7a2c3d9e8b10",
"type": "appointment.rescheduled",
"createdAt": "2026-11-03T13:02:11.482913+00:00",
"salonId": "3f6c2b1e-8d4a-4c4e-9b7a-2a5d0e9f1c11",
"data": {
"id": "9a1d7c52-3e0f-4b8e-a6d2-5c4f1e7b9a30",
"status": "Confirmed",
"previousStatus": null,
"origin": "Online",
"startsAt": "2026-11-04T18:00:00+00:00",
"endsAt": "2026-11-04T18:45:00+00:00",
"previousStartsAt": "2026-11-04T17:00:00+00:00",
"previousProfessionalId": "5b7e2c10-4a3d-4f8e-9c21-0d6a8e3f1b42",
"professional": { "id": "e41f9a7c-2b6d-4c3e-8a15-7f0d2c9b6e83", "name": "Ana" },
"customer": {
"id": "c2d8b4e6-7a1f-4e9c-b3d5-8f6a0e2c4b17",
"name": "Maria Souza",
"phone": "+5511999998888",
"email": "maria@exemplo.com.br"
},
"services": [
{
"serviceId": "7f3a1c9e-5b2d-4e8f-a6c4-1d9e3b7f5a20",
"name": "Corte feminino",
"price": 80.00,
"durationMinutes": 45
}
],
"totalPrice": 80.00,
"comboId": null,
"comboName": null,
"seriesId": null,
"arrivedAt": null,
"startedAt": null,
"completedAt": null,
"noShowAt": null,
"cancelledAt": null,
"cancelledBy": null,
"cancellationReason": null,
"createdAt": "2026-11-01T22:14:05.120394+00:00",
"updatedAt": "2026-11-03T13:02:11.482913+00:00"
}
}
Campo Tipo O que é
id UUID Comanda.
number inteiro Número da comanda no salão.
status enum Closed ou Cancelled (Open não gera evento).
customer objeto ou null id e name do cliente (sem contatos).
businessDate data Data de movimento, no fuso do salão (AAAA-MM-DD).
openedAt, closedAt, cancelledAt instante ou null Abertura, fechamento e cancelamento.
cancellationReason texto ou null Motivo do cancelamento.
subtotal número Soma dos itens antes dos descontos.
discountTotal número Descontos.
total número Total da comanda (sem gorjeta).
tipTotal número Gorjetas.
paidTotal número Pago.
balance número Falta receber (fiado): total + tipTotal − paidTotal.
items lista kind (Service, Product ou Custom), description, quantity, unitPrice, netAmount (depois dos descontos), professionalId, serviceId, productId e appointmentId (os ids podem ser null).
payments lista method (nome da forma de pagamento no salão), kind (Cash, Pix, Debit, Credit ou Other), amount, installments, status (Active ou Reversed — estornado no cancelamento) e paidAt.
tips lista professionalId e amount de cada gorjeta.
{
"id": "4c9e2a7d-1b3f-4d8e-a5c6-9e0f7b2d3a14",
"type": "order.closed",
"createdAt": "2026-11-04T19:05:42.017356+00:00",
"salonId": "3f6c2b1e-8d4a-4c4e-9b7a-2a5d0e9f1c11",
"data": {
"id": "a8f3c1e5-6d2b-4a9f-8e7c-3b5d1f9a0c62",
"number": 1287,
"status": "Closed",
"customer": { "id": "c2d8b4e6-7a1f-4e9c-b3d5-8f6a0e2c4b17", "name": "Maria Souza" },
"businessDate": "2026-11-04",
"openedAt": "2026-11-04T18:02:10.441208+00:00",
"closedAt": "2026-11-04T19:05:41.998127+00:00",
"cancelledAt": null,
"cancellationReason": null,
"subtotal": 125.00,
"discountTotal": 5.00,
"total": 120.00,
"tipTotal": 10.00,
"paidTotal": 130.00,
"balance": 0.00,
"items": [
{
"kind": "Service",
"description": "Corte feminino",
"quantity": 1,
"unitPrice": 80.00,
"netAmount": 80.00,
"professionalId": "e41f9a7c-2b6d-4c3e-8a15-7f0d2c9b6e83",
"serviceId": "7f3a1c9e-5b2d-4e8f-a6c4-1d9e3b7f5a20",
"productId": null,
"appointmentId": "9a1d7c52-3e0f-4b8e-a6d2-5c4f1e7b9a30"
},
{
"kind": "Product",
"description": "Máscara hidratante",
"quantity": 1,
"unitPrice": 45.00,
"netAmount": 40.00,
"professionalId": null,
"serviceId": null,
"productId": "d6b0e3a9-8c4f-4b1d-9e2a-5f7c3d1b8e46",
"appointmentId": null
}
],
"payments": [
{
"method": "Pix",
"kind": "Pix",
"amount": 130.00,
"installments": 1,
"status": "Active",
"paidAt": "2026-11-04T19:05:30.512873+00:00"
}
],
"tips": [
{ "professionalId": "e41f9a7c-2b6d-4c3e-8a15-7f0d2c9b6e83", "amount": 10.00 }
]
}
}
Campo Tipo O que é
id UUID Cliente.
name texto Nome.
phone texto ou null Telefone normalizado em E.164 (ex.: +5511999998888).
email texto ou null E-mail em minúsculas.
hasAccount booleano O cadastro está ligado a uma conta de cliente (agendou logado na página pública).
createdAt instante Quando foi cadastrado.

Documento, data de nascimento e observações internas nunca vão no webhook.

Quando o salão anonimiza um cliente (LGPD), os eventos dele guardados no Pega Vez (customer.created, appointment.* e order.* dos últimos 30 dias) passam a ter o nome "Cliente anonimizado", sem telefone, e-mail nem o motivo que o cliente escreveu ao cancelar. Entregas ainda pendentes saem já assim; o que você recebeu antes continua com você (apague do seu lado também, se for o caso).

{
"id": "e7b1d3f5-9a2c-4e6b-8d0f-2c4a6e8b1d39",
"type": "customer.created",
"createdAt": "2026-11-01T22:14:05.120394+00:00",
"salonId": "3f6c2b1e-8d4a-4c4e-9b7a-2a5d0e9f1c11",
"data": {
"id": "c2d8b4e6-7a1f-4e9c-b3d5-8f6a0e2c4b17",
"name": "Maria Souza",
"phone": "+5511999998888",
"email": "maria@exemplo.com.br",
"hasAccount": false,
"createdAt": "2026-11-01T22:14:05.120394+00:00"
}
}

Enviado pelo botão Enviar teste do painel, com uma tentativa só. Use para conferir a URL e a verificação da assinatura.

Campo Tipo O que é
subscriptionId UUID O webhook testado.
message texto "Teste de webhook do Pega Vez."
{
"id": "1f5a9c3e-7b2d-4f8a-9c6e-0d4b2f8a6c51",
"type": "ping",
"createdAt": "2026-11-03T13:10:00.204517+00:00",
"salonId": "3f6c2b1e-8d4a-4c4e-9b7a-2a5d0e9f1c11",
"data": {
"subscriptionId": "b3e7a1c9-5d2f-4b8e-a0c6-9f1d3e5b7a28",
"message": "Teste de webhook do Pega Vez."
}
}
POST /webhooks/pegavez HTTP/1.1
Host: seu-servidor.exemplo.com
Content-Type: application/json; charset=utf-8
User-Agent: PegaVez-Webhooks/1.0
Pegavez-Signature: t=1793710932,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Pegavez-Event-Id: 0b8e6f3a-5d21-4c7e-9f14-7a2c3d9e8b10
Pegavez-Event-Type: appointment.rescheduled
Header O que é
Content-Type application/json (corpo em UTF-8).
User-Agent PegaVez-Webhooks/1.0.
Pegavez-Signature Assinatura do corpo: t=<segundos Unix>,v1=<hex>. Veja Assinatura.
Pegavez-Event-Id O mesmo id do envelope; igual em todas as tentativas. Dá para descartar repetidos antes de ler o corpo.
Pegavez-Event-Type O mesmo type do envelope.
Pegavez-Signature: t=1793710932,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t: momento do envio desta tentativa, em segundos Unix.
  • v1: HMAC-SHA256, em hexadecimal minúsculo, de "{t}.{corpo}" — o valor de t, um ponto e o corpo cru da requisição, exatamente os bytes recebidos —, usando como chave o segredo do webhook (whsec_... inteiro, com o prefixo) em UTF-8.

Para verificar:

  1. Leia o corpo cru, antes de qualquer parse de JSON (reserializar muda os bytes e a assinatura não bate).
  2. Separe t e v1 do header. Hoje vem um único v1, mas trate o header como lista e aceite se qualquer v1 conferir: assim a sua verificação continua valendo se o formato ganhar assinaturas extras.
  3. Recuse se t estiver a mais de 5 minutos do seu relógio (proteção contra repetição). Mantenha o relógio do servidor sincronizado (NTP).
  4. Calcule o HMAC-SHA256 de "{t}.{corpo}" com o segredo e compare com o v1 em tempo constante (nunca com == de texto).

Os exemplos abaixo seguem esses passos.

using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var secret = app.Configuration["Pegavez:WebhookSecret"]!;
app.MapPost("/webhooks/pegavez", async (HttpRequest request) =>
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer);
var body = buffer.ToArray();
if (!PegavezSignature.IsValid(request.Headers["Pegavez-Signature"].ToString(), body, secret))
return Results.Unauthorized();
var evt = JsonSerializer.Deserialize<JsonElement>(body);
// Guarde evt (descartando ids repetidos) e processe fora da requisição.
return Results.Ok();
});
app.Run();
static class PegavezSignature
{
static readonly TimeSpan Tolerance = TimeSpan.FromMinutes(5);
public static bool IsValid(string header, byte[] body, string secret)
{
long? timestamp = null;
var signatures = new List<string>();
foreach (var part in header.Split(','))
{
var pair = part.Split('=', 2, StringSplitOptions.TrimEntries);
if (pair.Length != 2) continue;
if (pair[0] == "t" && long.TryParse(pair[1], out var t)) timestamp = t;
else if (pair[0] == "v1") signatures.Add(pair[1]);
}
if (timestamp is null || signatures.Count == 0) return false;
var age = DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(timestamp.Value);
if (age.Duration() > Tolerance) return false;
byte[] signed = [.. Encoding.UTF8.GetBytes($"{timestamp}."), .. body];
var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signed);
foreach (var signature in signatures)
{
byte[] received;
try { received = Convert.FromHexString(signature); }
catch (FormatException) { continue; }
if (CryptographicOperations.FixedTimeEquals(expected, received)) return true;
}
return false;
}
}

Para testar a sua verificação, calcule uma assinatura na linha de comando:

Janela do terminal
corpo='{"id":"0b8e6f3a-5d21-4c7e-9f14-7a2c3d9e8b10","type":"customer.created"}'
t=$(date +%s)
v1=$(printf '%s.%s' "$t" "$corpo" | openssl dgst -sha256 -hmac "$PEGAVEZ_WEBHOOK_SECRET" -hex | sed 's/^.* //')
curl -s https://seu-servidor.exemplo.com/webhooks/pegavez \
-H "Content-Type: application/json" \
-H "Pegavez-Signature: t=$t,v1=$v1" \
-d "$corpo"
  • Responda 2xx em até 10 segundos, contando o envio do corpo inteiro da resposta: mandar os headers e demorar no corpo também estoura o prazo. Qualquer outro status, tempo esgotado ou erro de conexão conta como falha.
  • Redirecionamentos (3xx) não são seguidos e contam como falha: cadastre a URL final.
  • Não processe nada pesado dentro da requisição: grave o evento numa fila ou tabela, responda 200 e processe depois.
  • O corpo da sua resposta é ignorado, mas os primeiros 2 KB aparecem no histórico de entregas do painel: uma mensagem de erro curta ajuda quem for investigar.

Cada entrega que falha é tentada de novo, até 8 tentativas ao todo, nestes instantes contados a partir da criação do evento:

Tentativa 1ª 2ª 3ª 4ª 5ª 6ª 7ª 8ª
Depois do evento imediata 1 min 5 min 30 min 2 h 5 h 10 h 24 h
  • Todas as tentativas do mesmo evento têm o mesmo id (e o mesmo Pegavez-Event-Id); a cada tentativa, t e a assinatura são recalculados.
  • Depois da 8ª falha, a entrega fica como falhou no histórico e não é mais tentada.
  • O webhook é desativado automaticamente quando está falhando sem parar (nenhuma entrega com sucesso no meio) há pelo menos 24 horas com 20 ou mais tentativas seguidas falhando, ou há 3 dias. Uma queda de alguns minutos com muitos eventos na fila não desativa. O motivo aparece em Configurações → Integrações → Webhooks, e as entregas pendentes dele são canceladas. Corrija o receptor e reative no painel (a contagem de falhas recomeça do zero). Os eventos do período em que ficou desativado não são reenviados: busque o estado atual na API.
  • “Sem parar” conta só o tempo com tentativas: se passaram mais de 25 horas desde a falha anterior (dias sem nenhum evento, por exemplo), o período “falhando” recomeça na falha atual. Um Enviar teste com sucesso também zera a contagem.
  • Eventos e entregas ficam guardados por 30 dias.

A entrega é pelo menos uma vez e sem ordem garantida:

  • o mesmo evento pode chegar mais de uma vez — descarte pelo id (ou pelo header Pegavez-Event-Id);
  • um appointment.rescheduled pode chegar antes do appointment.created correspondente se a primeira entrega falhou, e eventos de um mesmo agendamento podem chegar trocados — compare o updatedAt do data com o que você já tem e, na dúvida, busque o estado atual do recurso na API.

A rotação no painel troca o segredo na hora: a partir daquele instante, os envios (inclusive as retentativas de eventos antigos) são assinados só com o segredo novo, e o antigo deixa de valer. Para não perder eventos:

  1. Gere o novo segredo no painel e copie.
  2. Atualize o receptor o quanto antes. Se precisar de folga, deixe o receptor aceitar os dois segredos (o novo e o antigo) por alguns minutos e depois remova o antigo.
  3. Use Enviar teste para confirmar que a verificação passa.

Eventos que falharem nesse intervalo são reenviados pelas retentativas, já com o segredo novo.

  • Sempre verifique a assinatura e a janela de 5 minutos antes de confiar no conteúdo.
  • Guarde o segredo como qualquer outra credencial (cofre ou variável de ambiente), nunca no código.
  • Suspeita de vazamento: gere um segredo novo no painel e atualize o receptor.
  • A URL precisa ser pública. Na hora de cadastrar, localhost, *.localhost e IPs internos são recusados; na hora de enviar, o Pega Vez resolve o DNS e não conecta em endereços internos (loopback, redes privadas, link-local, CGNAT, documentação, multicast e reservados). Em IPv6, só endereços globais (2000::/3) passam, sem 6to4, Teredo e NAT64. Uma URL que passe a apontar para a rede interna falha com “Bloqueado: o endereço aponta para uma rede interna.”