Skip to content

Guia de webhooks ​

Como receber os eventos do Standcast: payloads, headers, verificação da assinatura, novas tentativas e testes. Para adicionar um endpoint na página de setup, veja Webhooks para admins.

O Standcast manda eventos para os seus sistemas na hora em que acontecem. Um admin da organização cadastra um endpoint HTTPS na página de setup, escolhe os eventos e recebe um segredo de assinatura. A partir daí, o Standcast manda um POST assinado com um corpo JSON para cada evento.

Cadastrar um endpoint ​

Só um admin da organização gerencia webhooks. Na página de setup da organização, abra a seção Webhooks, informe a URL do endpoint, escolha os eventos e adicione.

A URL do endpoint tem que:

  • usar https://;
  • usar a porta padrão (443);
  • não ter usuário nem senha;
  • apontar para um endereço público. O Standcast resolve o nome do host antes de cada entrega e recusa endereços privados, de loopback, link-local, NAT de operadora, multicast, reservados e de metadados de nuvem, em IPv4 e IPv6. Depois ele conecta no endereço que conferiu, então uma mudança de DNS entre a checagem e a requisição não desvia a entrega.

O segredo de assinatura (whsec_...) aparece uma vez, quando o endpoint é criado. O Standcast guarda o segredo criptografado e não consegue mostrar de novo. Se você perder o segredo, desative o endpoint e crie outro. Uma organização pode ter até 10 endpoints ativos.

Eventos ​

EventoEnviado quando
daily.submittedUma pessoa envia a daily. Um reenvio no mesmo dia é enviado de novo com replaced: true.
blocker.openedUma daily traz um impedimento novo naquele dia. Um impedimento cujo texto (ignorando maiúsculas e espaços extras) já estava na versão anterior da mesma daily não é enviado de novo.
digest.sentO consolidado do time é publicado no chat.
pingUm admin clica em Enviar evento de teste. Os endpoints não assinam esse evento; ele vai só para o endpoint testado.

Todo corpo é um envelope com os mesmos campos:

CampoSignificado
idId do evento (UUID). As novas tentativas do mesmo evento reaproveitam esse id.
typeO nome do evento.
created_atQuando o evento aconteceu, em ISO 8601 no UTC.
installation{ "id": <id da instalação do GitHub App>, "login": "<login da organização>" }.
dataO payload do evento, descrito abaixo.

daily.submitted:

json
{
  "id": "5f0c3a8e-0f4e-4d1a-9b51-6a4d0f2c9e11",
  "type": "daily.submitted",
  "created_at": "2026-10-28T12:03:00.000Z",
  "installation": { "id": 12345678, "login": "acme" },
  "data": {
    "id": "7b1c2f0e-5a0b-4a63-9a43-0d2a1f0f9d11",
    "login": "alice",
    "date": "2026-10-28",
    "teams": ["Alpha"],
    "yesterday": [
      {
        "text": "Shipped rate limiting",
        "ref": { "kind": "pull_request", "url": "https://github.com/acme/api/pull/42" }
      }
    ],
    "today": [{ "text": "Write the integrator guides" }],
    "blockers": [
      {
        "text": "Waiting for the staging database",
        "ref": { "kind": "issue", "url": "https://github.com/acme/infra/issues/7" }
      }
    ],
    "replaced": false
  }
}

blocker.opened:

json
{
  "id": "9a3e7c51-2d4b-4f0a-8e6d-1b2c3d4e5f60",
  "type": "blocker.opened",
  "created_at": "2026-10-28T12:03:00.000Z",
  "installation": { "id": 12345678, "login": "acme" },
  "data": {
    "dailyId": "7b1c2f0e-5a0b-4a63-9a43-0d2a1f0f9d11",
    "login": "alice",
    "date": "2026-10-28",
    "text": "Waiting for the staging database",
    "ref": { "kind": "issue", "url": "https://github.com/acme/infra/issues/7" },
    "teams": ["Alpha"]
  }
}

digest.sent:

json
{
  "id": "c41d2e8f-6b7a-4c3d-9e0f-a1b2c3d4e5f6",
  "type": "digest.sent",
  "created_at": "2026-10-28T13:00:04.000Z",
  "installation": { "id": 12345678, "login": "acme" },
  "data": { "team": "Alpha", "date": "2026-10-28", "submitted": ["alice"], "missing": ["bob"] }
}

ping:

json
{
  "id": "e2f3a4b5-c6d7-4e8f-9a0b-1c2d3e4f5a6b",
  "type": "ping",
  "created_at": "2026-10-28T12:03:00.000Z",
  "installation": { "id": 12345678, "login": "acme" },
  "data": { "endpoint_id": "0d9c8b7a-6f5e-4d3c-8b2a-19f8e7d6c5b4" }
}

O ref só aparece quando o item aponta para um pull request, issue, commit ou card. Novos campos podem entrar em data; ignore os que você não conhece.

Headers ​

HeaderValor
Content-Typeapplication/json
User-AgentStandcast-Webhooks/1
Standcast-EventO nome do evento, igual ao type do corpo.
Standcast-DeliveryId da entrega (UUID). Um por endpoint e evento; continua o mesmo nas novas tentativas.
Standcast-Signaturet=<segundos unix>,v1=<assinatura>

Verificar a assinatura ​

Verifique toda requisição antes de agir sobre ela. A assinatura é o HMAC-SHA256 em hexadecimal de <t>.<corpo bruto>, com o segredo do endpoint como chave, onde <t> é o valor de t no header. Calcule sobre os bytes brutos que você recebeu, antes de interpretar o JSON. Compare em tempo constante e recuse requisições cujo t esteja a mais de 5 minutos do seu relógio, para uma requisição capturada não poder ser repetida depois.

TypeScript (Node.js ou Bun):

ts
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300;

export function verifyStandcastSignature(
  secret: string,
  rawBody: string,
  header: string,
  now: number = Math.floor(Date.now() / 1000),
): boolean {
  let timestamp: number | undefined;
  const signatures: Buffer[] = [];
  for (const part of header.split(',')) {
    const index = part.indexOf('=');
    const key = part.slice(0, index);
    const value = part.slice(index + 1);
    if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value);
    if (key === 'v1' && /^[0-9a-f]{64}$/.test(value)) signatures.push(Buffer.from(value, 'hex'));
  }
  if (timestamp === undefined || Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;
  const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest();
  return signatures.some((signature) => timingSafeEqual(expected, signature));
}

const secret = process.env.STANDCAST_WEBHOOK_SECRET ?? '';
const seen = new Set<string>();

Bun.serve({
  port: 3000,
  async fetch(request) {
    const rawBody = await request.text();
    const header = request.headers.get('standcast-signature') ?? '';
    if (!verifyStandcastSignature(secret, rawBody, header)) {
      return new Response('invalid signature', { status: 401 });
    }
    const event = JSON.parse(rawBody) as { id: string; type: string; data: unknown };
    if (seen.has(event.id)) return new Response(null, { status: 204 });
    seen.add(event.id);
    console.log(event.type, event.data);
    return new Response(null, { status: 204 });
  },
});

Com Node.js e Express, leia o corpo bruto com express.raw({ type: 'application/json' }) e passe request.body.toString('utf8') como rawBody.

Python:

python
import hashlib
import hmac
import json
import os
import re
import time
from typing import Optional

from flask import Flask, request

TOLERANCE_SECONDS = 300


def verify_standcast_signature(
    secret: str, raw_body: bytes, header: str, now: Optional[int] = None
) -> bool:
    timestamp = None
    signatures = []
    for part in header.split(","):
        key, _, value = part.partition("=")
        if key == "t" and value.isdigit():
            timestamp = int(value)
        elif key == "v1" and re.fullmatch(r"[0-9a-f]{64}", value):
            signatures.append(value)
    if timestamp is None:
        return False
    current = int(time.time()) if now is None else now
    if abs(current - timestamp) > TOLERANCE_SECONDS:
        return False
    signed = str(timestamp).encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, signature) for signature in signatures)


app = Flask(__name__)
secret = os.environ["STANDCAST_WEBHOOK_SECRET"]
seen = set()


@app.post("/standcast")
def standcast_webhook():
    raw_body = request.get_data()
    header = request.headers.get("Standcast-Signature", "")
    if not verify_standcast_signature(secret, raw_body, header):
        return "invalid signature", 401
    event = json.loads(raw_body)
    if event["id"] in seen:
        return "", 204
    seen.add(event["id"])
    print(event["type"], event["data"])
    return "", 204

Idempotência ​

Uma entrega pode chegar mais de uma vez, por exemplo quando o seu endpoint processou o evento mas a resposta se perdeu. Use o id do envelope para descartar duplicatas: guarde os ids já processados (uma tabela com chave única funciona) e pule um evento cujo id você já tem. Os exemplos acima guardam os ids só em memória para ficarem curtos.

Respostas, novas tentativas e desativação ​

  • Qualquer resposta 2xx conta como entregue. O corpo da resposta é ignorado.
  • Todo o resto conta como falha: outros códigos de status, redirecionamentos (nunca seguidos), erros de conexão e nenhuma resposta em 10 segundos.
  • Uma entrega com falha é tentada de novo, até 8 tentativas no total, esperando 1 minuto, 5 minutos, 30 minutos, 2 horas, 6 horas, 12 horas e 24 horas entre elas. Depois da 8ª falha, a entrega fica marcada como falha e não é tentada de novo.
  • Depois de 20 entregas seguidas com falha, o Standcast desativa o endpoint. A página de setup mostra o endpoint como desativado após falhas. Uma entrega com sucesso zera a contagem. Para voltar, crie o endpoint de novo.
  • Responda rápido: confirme com 2xx e faça o trabalho lento em segundo plano.

A ordem de chegada dos eventos não é garantida. Use o created_at quando a ordem importar.

Testar um endpoint ​

Na página de setup, clique em Enviar evento de teste ao lado do endpoint. O Standcast manda um evento ping assinado só para esse endpoint. A tabela Últimas 20 entregas mostra cada entrega com o evento, o estado, as tentativas e o último status de resposta ou erro, para você confirmar que o endpoint respondeu com 2xx e que a sua verificação de assinatura passa.