# API pública do Gamerfy

Consulte usuários, lives, replays, clipes e jogos do Gamerfy e receba webhooks
quando algo acontece. A API segue o molde da Helix, da Twitch: um cliente
escrito para a Twitch precisa mudar pouco além do endereço.

Esta página é a referência completa. À esquerda ficam o guia e cada rota; à
direita, exemplos prontos para copiar. O botão **Testar** faz a chamada de
verdade daqui mesmo.

## Primeiros passos

### 1. Crie um app

1. No Gamerfy, abra **Configurações → Desenvolvedor**.
2. Clique em **Criar app** e dê um nome (até 60 caracteres).
3. Copie o **client secret** na hora. Ele aparece **uma única vez** (o
   Gamerfy guarda só o SHA-256). Se perder, use **Gerar novo segredo**: o
   anterior deixa de valer e todos os tokens emitidos com ele são apagados.
4. O **client id** fica visível na lista e pode ser copiado quando quiser.

| Credencial | Formato | Onde guardar |
|---|---|---|
| `client_id` | 30 caracteres `[a-z0-9]` | público; pode ir em arquivo de configuração |
| `client_secret` | `gfs_` + 40 caracteres `[A-Za-z0-9]` | segredo; só no servidor de quem integra |
| token de app | `gfa_` + 40 caracteres `[A-Za-z0-9]` | segredo; expira em 60 dias |

Cada conta pode ter até **10 apps**. Apagar o app apaga o segredo e todos os
tokens.

### 2. Peça o token

```bash
curl -X POST https://api.gamerfy.gg/oauth2/token \
  -d 'client_id=abc123…' \
  -d 'client_secret=gfs_…' \
  -d 'grant_type=client_credentials'
```

A resposta traz `access_token` e `expires_in` (em segundos, 60 dias). O corpo
também pode ser JSON. Guarde o token e reutilize até expirar: pedir um novo a
cada chamada é desperdício e conta no limite.

### 3. Faça a primeira chamada

```bash
curl 'https://api.gamerfy.gg/v1/streams?game_id=pubg' \
  -H 'Authorization: Bearer gfa_…'
```

Para testar aqui na página, clique em **Testar** numa rota e cole o token em
**Autenticação**.

### Importar no Postman ou no Insomnia

A especificação OpenAPI 3.1 está em `https://api.gamerfy.gg/openapi.json`. No
Postman: **Import → Link** e cole o endereço; a coleção sai com todas as rotas
e exemplos.

## Formato das respostas

- Todas as rotas `/v1/*` exigem `Authorization: Bearer gfa_…`.
- Listas vêm em `{ "data": [ … ] }`. As rotas paginadas (`/v1/streams`,
  `/v1/videos`, `/v1/clips`) trazem também `pagination`.
- Campos em `snake_case`; datas em ISO 8601 UTC com milissegundos
  (`2026-09-08T21:30:00.123Z`).
- Ids são strings (snowflakes de 64 bits: não cabem em `Number` do
  JavaScript sem perda).
- Parâmetros repetíveis aceitam `?id=1&id=2`, até 100 valores por chamada.
- Um id ou login que não existe simplesmente não aparece em `data`; não é erro.
- Qualquer parâmetro recusado é `400 invalid_query`, com `details` listando
  `{ path, message }` por campo.

## Paginação

`/v1/streams`, `/v1/videos` e `/v1/clips` paginam por cursor. `first` é o
tamanho da página (1 a 100; padrão 20). Enquanto houver mais, a resposta traz
`pagination.cursor`: repita a chamada com os **mesmos filtros** e
`after=<cursor>`.

```bash
curl 'https://api.gamerfy.gg/v1/videos?game_id=pubg&first=100&after=eyJzdGFydGVkX2F0Ijoi…' \
  -H 'Authorization: Bearer gfa_…'
```

- O cursor é opaco: não monte nem edite. Um cursor corrompido é
  `400 invalid_cursor`.
- Use cada cursor só na rota e com os filtros que o emitiram.
- Uma página que veio **cheia** sempre traz cursor, mesmo quando por acaso é a
  última: a chamada seguinte devolve `data: []` e `pagination: {}`. Pare quando
  `pagination.cursor` faltar ou `data` vier vazio.
- A lista é sempre do mais novo para o mais velho; não existe `before`.

## Ligar um evento da partida ao replay

É o caso do pubg.report: a telemetria diz que uma morte aconteceu em
`event_time_utc`, e a API diz que o replay começou em `video.created_at`. O
instante no vídeo é a diferença, sem nenhuma correção:

```
offset_s = (event_time_utc − video.created_at) / 1000
url      = video.url + "?t=" + formatar(offset_s)     # 1h2m3s, ou só os segundos
```

**Por que sem recuo.** `created_at` não é a hora em que a live foi anunciada.
É o `EXT-X-PROGRAM-DATE-TIME` do primeiro segmento gravado: o relógio do
servidor de mídia no primeiro quadro, com milissegundos. Na Twitch o
`created_at` do VOD chega a ficar dezenas de segundos antes do primeiro quadro,
e por isso o pubg.report desconta 10 s. Aqui o desconto seria erro.

O que ainda entra na conta é a latência do encoder (OBS ou o app) até o
servidor, normalmente abaixo de 2 s. Eventos antes de `created_at` ou depois de
`created_at + duration_seconds` não têm imagem: o replay pode ter começado
depois da partida ou ter sido truncado.

A página `https://gamerfy.gg/videos/{id}?t=…` aceita `1h2m3s`, `2m3s`, `45s`
e `45`. Um `t` inválido abre o vídeo do começo.

**Clipes.** O `vod_offset` de um clipe é contado a partir do mesmo
`video.created_at`, então é comparável ao `offset_s` acima. O replay abre no
ponto do clipe com `https://gamerfy.gg/videos/{video_id}?t={vod_offset}s`.

## Autorização de apps

O token de app (`gfa_…`) lê o que é público. Para ler a live **de um streamer** —
o chat, quem seguiu, quem mandou presente — o seu app pede a autorização dele,
no molde da Twitch: código de uso único com PKCE (OAuth 2, RFC 6749 e 7636). Ele
vê em palavras o que o app vai poder, aceita ou recusa, e pode tirar o acesso a
qualquer momento em **Configurações → Conexões**.

### 1. Cadastre os endereços de volta

Em **Configurações → Desenvolvedor → Autorização**, cadastre até **10**
endereços para onde o streamer volta depois de aceitar. Cada um é `https://`
exato, ou o do próprio computador — `http://127.0.0.1:<porta>/…` e
`http://localhost:<porta>/…` com **qualquer porta** (RFC 8252), para apps de
desktop como o Streamer.bot.

No mesmo lugar, **Página do app**: o endereço onde o streamer **usa** o seu app — o painel, as configurações. É
opcional, e segue a mesma regra dos endereços de volta (`https://`, ou `http://localhost:<porta>` para um app que
roda na máquina dele). Com ela, a linha do seu app em **Configurações → Conexões** vira um botão que abre a página.

### 2. Mande o streamer para a tela de autorizar

```
https://gamerfy.gg/autorizar?response_type=code&client_id=…&redirect_uri=…&scope=chat:read%20gifts:read&state=…&code_challenge=…&code_challenge_method=S256
```

- `scope`: as permissões, separadas por espaço (a tabela abaixo).
- `state`: um valor seu, aleatório; ele volta igual — confira para recusar uma
  volta que você não pediu.
- `code_challenge`: `base64url(SHA-256(code_verifier))`. **PKCE com `S256` é
  obrigatório** para todo app; `plain` é recusado. O `code_verifier` tem de 43 a
  128 caracteres `[A-Za-z0-9-._~]` e fica guardado com você até a volta.

Sem sessão no Gamerfy, o streamer entra primeiro e volta para a tela. Aceitou:
ele vai para `redirect_uri?code=…&state=…`. Recusou:
`redirect_uri?error=access_denied&state=…`. O código vale **60 segundos** e uma
vez só.

### 3. Troque o código pelas chaves

```bash
curl -X POST https://api.gamerfy.gg/oauth2/token \
  -d 'grant_type=authorization_code' \
  -d 'client_id=abc123…' \
  -d 'code=…' \
  -d 'redirect_uri=http://127.0.0.1:8080/callback' \
  -d 'code_verifier=…'
```

```json
{ "access_token": "gfp_…", "refresh_token": "gfr_…", "token_type": "bearer", "expires_in": 14400, "scope": "chat:read gifts:read" }
```

O mesmo caminho em JavaScript, num app de desktop:

```js
import { createHash, randomBytes } from 'node:crypto';

const CLIENT_ID = 'abc123…';
const REDIRECT_URI = 'http://127.0.0.1:8080/callback';

const verifier = randomBytes(32).toString('base64url'); // 43 caracteres; guarde até a volta
const state = randomBytes(16).toString('base64url');
const url = new URL('https://gamerfy.gg/autorizar');
url.search = new URLSearchParams({
  response_type: 'code',
  client_id: CLIENT_ID,
  redirect_uri: REDIRECT_URI,
  scope: 'chat:read gifts:read',
  state,
  code_challenge: createHash('sha256').update(verifier).digest('base64url'),
  code_challenge_method: 'S256',
}).toString();
// Abra `url` no navegador. Na volta, confira o `state` e leia o `code`:
async function exchange(code) {
  const res = await fetch('https://api.gamerfy.gg/oauth2/token', {
    method: 'POST',
    body: new URLSearchParams({ grant_type: 'authorization_code', client_id: CLIENT_ID, code, redirect_uri: REDIRECT_URI, code_verifier: verifier }),
  });
  if (!res.ok) throw new Error((await res.json()).error_description);
  return res.json(); // { access_token, refresh_token, expires_in, scope }
}
```

### 4. Renove, confira e devolva

- A chave `gfp_…` vale **4 horas**. Antes de vencer, troque a `gfr_…` por um par
  novo: `grant_type=refresh_token`, `refresh_token` e `client_id`. A `gfr_` vale
  **60 dias**, renovados a cada troca, e **morre na hora em que é trocada**: usar
  de novo uma `gfr_` já trocada tira a autorização inteira, porque é o sinal de
  uma chave roubada. Guarde sempre o par mais novo.
- `GET /oauth2/validate` com `Authorization: Bearer gfp_…` responde `client_id`,
  `user_id`, `login`, `scopes` e `expires_in`.
- `POST /oauth2/revoke` com `token` e `client_id` devolve aquela chave; com uma
  `gfr_`, a autorização inteira.
- Pedir a autorização de novo **soma** as permissões novas às que o streamer já
  deu; as chaves antigas continuam valendo.
- Um `client_secret` é aceito junto, mas não é preciso: um app de desktop não tem
  onde guardá-lo, e quem protege a troca é o PKCE.

Os erros de `/oauth2/token` e `/oauth2/revoke` seguem a RFC 6749
(`invalid_request`, `invalid_client`, `invalid_grant`, `unsupported_grant_type`)
e trazem `error_description`, que as bibliotecas de OAuth leem, ao lado do
`message` de sempre. Um endereço de volta que não está cadastrado, ou uma
permissão que não existe, nem abrem a tela: o streamer lê o motivo nela, e nada
volta para o seu app.

### Permissões

| Permissão | O que o streamer lê na tela | O que abre |
|---|---|---|
| `live:read` | Ver título, jogo e quantas pessoas assistem | `stream.*`, `channel.update`, `clip.ready` e o `snapshot.live` |
| `chat:read` | Ler o chat da sua live | `channel.chat.*` e o `snapshot.chat` |
| `chat:write` | Escrever no chat da sua live, com o nome do app | `POST /v1/chat/messages` |
| `chat:moderate` | Apagar mensagens do chat da sua live | `DELETE /v1/chat/messages` |
| `followers:read` | Saber quando alguém te segue | `channel.follow` |
| `subscriptions:read` | Saber quando alguém assina | `channel.subscribe`, `channel.subscription.end` |
| `gifts:read` | Saber quando alguém manda presente, com a mensagem | `channel.gift*`, `overlay.gifts_today` e o `snapshot.gifts_today` |
| `goals:read` | Ver as suas metas | `channel.goal.*` |
| `polls:read` | Ver as enquetes e os votos | `channel.poll.*` |
| `polls:manage` | Abrir e encerrar enquetes | `POST` e `PATCH /v1/polls` |
| `alerts:read` | Ver a fila dos seus alertas | `overlay.alert*` |
| `raids:read` | Saber quando você manda ou recebe um raid | `channel.raid` |
| `raids:manage` | Mandar e cancelar raids por você | `POST` e `DELETE /v1/raids` |

A tela diz também, sempre, o que **nenhum** app pode: ver a senha ou os dados de
pagamento, usar a chave de transmissão, mexer no saldo ou pedir saque. Não existe
permissão para isso.

**App novo:** enquanto o Gamerfy não revisa o seu app, até **10 streamers** além
de você podem autorizá-lo; o 11.º vê "Este app ainda não foi revisado pelo
Gamerfy e já chegou ao limite de 10 streamers. Fale com quem o fez.". Para pedir
a revisão, fale com o Gamerfy.

## Canal de eventos

```
wss://api.gamerfy.gg/v1/events
```

O que acontece na live de um streamer, na hora, por WebSocket: chat, quem seguiu,
quem assinou, presentes, título e jogo, audiência. O protocolo é o mesmo do
**Gateway em tempo real** dos bots — quem já fez um bot já sabe usar:

```
← {"op":"hello","d":{"heartbeat_interval_ms":30000,"version":1}}
→ {"op":"identify","d":{"token":"gfp_…"}}
← {"op":"dispatch","t":"ready","id":"…","seq":"1727640423120-0","d":{"session_id":"…","broadcaster":{…},"scopes":[…],"overlay":null,"snapshot":{…}}}
→ {"op":"heartbeat"}
← {"op":"heartbeat_ack"}
← {"op":"dispatch","t":"channel.gift","id":"1834021894356992001","seq":"1727640423458-0","d":{…,"created_at":"2026-09-29T21:47:03.120Z"}}
```

- **A chave:** uma `gfp_…` da **Autorização de apps**. O canal entrega
  só o que as permissões dela abrem. (As páginas de overlay do próprio Gamerfy
  entram com a chave de overlay do streamer, `gfo_…`, que lê tudo e não escreve
  nada.) Qualquer outra chave, `gfa_` e `gfb_` inclusive, fecha com 4002.
- Todo evento vem em `dispatch`: `t` é o tipo, `id` o do evento — **o mesmo do
  webhook**, para descartar repetições —, `seq` uma marca opaca para a retomada, e
  `d` o `data` do evento com o `created_at` dentro.
- `identify` em até 10 s depois do `hello`; `heartbeat` a cada
  `heartbeat_interval_ms` (leia do `hello`); 75 s de silêncio fecham. Mais de 60
  quadros por minuto fecham. Quadro acima de 4 KB é recusado pelo WebSocket (1009).

### O `ready`

```json
{
  "session_id": "k3Jm9qLx0Zt2",
  "broadcaster": { "id": "1834021894356992001", "login": "henrique", "name": "Henrique" },
  "scopes": ["live:read", "chat:read", "gifts:read"],
  "overlay": null,
  "snapshot": {
    "live": { "online": true, "title": "Ranked de terça", "game_id": "pubg", "game_name": "PUBG: Battlegrounds", "viewer_count": 37, "started_at": "2026-09-29T21:30:00.123Z" },
    "gifts_today": { "total_cents": 18600, "top": [{ "user_login": "fulano", "user_name": "Fulano", "total_cents": 7000 }] },
    "settings": {},
    "goal": null,
    "poll": null,
    "alert": null
  }
}
```

O `snapshot` é o estado da live no instante do `ready`, para você desenhar sem uma
segunda leitura. Cada campo só vem com a permissão que o abre: `live` com
`live:read`, `gifts_today` com `gifts:read`.

Com a chave de overlay (`gfo_…`), o `identify` pode dizer qual overlay a página é —
`{"op":"identify","d":{"token":"gfo_…","overlay":"alerts"}}`, com `chat`, `alerts`,
`gift_qr`, `goal`, `poll` ou `gift_rank` — e então o `ready` traz `overlay` com esse
nome e `snapshot.settings` com o visual que o streamer salvou para ele (as cores, a
posição, o tamanho). Sem `overlay`, ou com uma chave `gfp_…`, `settings` vem `{}`.

### Retomada

Caiu? Reconecte e, depois do `hello`, mande
`{"op":"resume","d":{"token":"gfp_…","session_id":"…","seq":"<o último recebido>"}}`.
O canal repõe o que veio depois daquele `seq` e fecha com
`{"op":"dispatch","t":"resumed","d":{"replayed":N}}`. A retomada funciona em
qualquer servidor do Gamerfy. A sessão espera **2 minutos**, e o canal guarda os
**200** eventos mais recentes da live por até **5 minutos**; fora disso, o
fechamento é 4006 — mande `identify` e trate o `ready` novo como sempre.

### Fechamentos

| Código | Motivo | Reconectar? |
|---|---|---|
| 4000 | quadro que esta versão não aceita | corrija o cliente |
| 4001 | 10 s sem `identify`, ou `heartbeat` antes dele | sim |
| 4002 | chave inválida, vencida ou de outro tipo | **não** |
| 4003 | o streamer tirou a autorização (ou trocou a chave de overlay) | **não**: peça a autorização de novo |
| 4006 | a retomada não cobre mais esse trecho | sim, com `identify` |
| 4008 | quadros demais | sim, com calma |
| 4009 | 75 s sem `heartbeat` | sim |
| 4010 | mais de **20** conexões com a mesma chave | **não**: feche as sobras |
| 1001 | estamos saindo do ar (um deploy) | sim |
| 1011 | falha do nosso lado | sim |

Ao reconectar, espere 1, 2, 4, 8, 16 e até 30 segundos, com uma variação
aleatória. Código desconhecido: tente de novo. Até **30 conexões novas por
minuto** por endereço IP.

### Os eventos

| Tipo | Quando | `data`, além de `broadcaster_user_id`, `broadcaster_user_login` e `broadcaster_user_name` | Permissão |
|---|---|---|---|
| `stream.online` | a live entrou no ar | o item de `/v1/streams` (sem os três campos do broadcaster: ele já traz `user_id`) | `live:read` |
| `stream.offline` | a live acabou | o do webhook `stream.offline` | `live:read` |
| `channel.update` | o streamer mudou título ou jogo | `title`, `game_id`, `game_name` | `live:read` |
| `stream.viewers` | a audiência mudou (no máximo a cada 5 s) | `viewer_count` | `live:read` |
| `stream.peak` | a audiência passou o pico da live | `viewer_count`, `previous_peak` | `live:read` |
| `clip.ready` | um clipe público da live ficou pronto | o item de `/v1/clips` | `live:read` |
| `channel.chat.message` | mensagem nova no chat, inclusive as do sistema | `message_id`, `user_id`, `user_login`, `user_name`, `text`, `kind` (`default` ou `system`), `gift_amount_cents` (só na linha de um presente), `badges` (`["subscriber"]`, ou `["bot"]` na linha escrita por um app), `is_command`. Dois campos **podem não vir** — trate a falta como "nada a mostrar": `user_rank`, a patente de quem escreveu na carreira (`iron`, `bronze`, `silver`, `gold`, `platinum`, `diamond`, `master` ou `legend`; nunca na linha de um bot), e `highlight_rank`, só numa **mensagem destacada** — a patente com que ela foi mandada, em cujo metal o chat a desenha. Desde 12/10/2026 vêm mais dois, também opcionais: `user_seal`, o selo que a pessoa escolheu para aparecer antes do nome (`founder`, a etiqueta de Founder da Gamerfy; `partner`, a de parceiro oficial; `rank`, a patente; `plan`, o plano), e `user_plan`, só com `plan` — qual plano (`player`, `competitor` ou `creator`). Sem `user_seal`, desenhe a patente como antes. Desde 13/10/2026, junto de `user_rank` vem `user_rank_step`, o degrau dentro da patente (`0` é I, `1` II, `2` III; Lenda só tem `0`), para escrever o lugar inteiro ("Platina II"); sem ele, escreva só a patente | `chat:read` |
| `channel.chat.message_update` | mensagem editada | `message_id`, `text` | `chat:read` |
| `channel.chat.message_delete` | mensagem apagada | `message_id` | `chat:read` |
| `channel.follow` | alguém seguiu | `user_id`, `user_login`, `user_name`, `followed_at` | `followers:read` |
| `channel.subscribe` | o pagamento de uma assinatura foi confirmado | `user_id`, `user_login`, `user_name`, `ends_at`, `is_renewal` | `subscriptions:read` |
| `channel.subscription.end` | uma assinatura acabou sem renovação | `user_id`, `user_login`, `user_name` | `subscriptions:read` |
| `channel.gift` | o pagamento de um presente foi confirmado | `gift_id`, `user_id`, `user_login`, `user_name`, `tier` (`confetti`, `crown`, `rocket`), `amount_cents`, `message`, `message_state` | `gifts:read` |
| `channel.gift.message_update` | o streamer liberou ou recusou a mensagem | `gift_id`, `message`, `message_state` (`shown` ou `rejected`) | `gifts:read` |
| `channel.raid` | um raid saiu (chega a quem mandou e a quem recebeu) | `from_broadcaster_user_*`, `to_broadcaster_user_*`, `viewers` (as contas levadas), `message` | `raids:read` |
| `overlay.gifts_today` | o ranking "Presentes de hoje" mudou | `total_cents`, `top` (até 3: `user_login`, `user_name`, `total_cents`) | `gifts:read` |
| `channel.goal.begin` · `.progress` · `.achieved` · `.end` | uma meta nasceu, andou, chegou ao alvo, acabou | a meta (o corpo do webhook) | `goals:read` |
| `channel.poll.begin` · `.progress` · `.end` | uma enquete abriu, recebeu votos, fechou | a enquete (o corpo do webhook) | `polls:read` |
| `overlay.alert` · `.skip` · `.queue` | a fila de alertas tocou, pulou ou mudou | o alerta decidido pelo servidor | `alerts:read` |
| `overlay.settings` | o streamer salvou o visual de um overlay | `kind` (o overlay) e `settings` (o visual, o mesmo do `snapshot.settings`) | a chave de overlay; `alerts:read` para `kind: alerts` |
| `overlay.command` | o streamer pediu algo a um overlay agora | `kind` e `action`: `show` (o QR de presente, "Mostrar agora") ou `test_line` (o chat, "Mandar linha de teste") | a chave de overlay |

- A mensagem de um presente só vem quando passou no filtro (`message_state:
  shown`). Nos outros estados — `none` (sem mensagem), `filtered` (o filtro
  barrou), `pending` (espera o streamer) — `message` é `null`.
- Dinheiro é sempre inteiro em **centavos**; ids são strings.
- O visual de um overlay traz `design`, de `"01"` a `"30"`: o estilo que o
  streamer escolheu na galeria (nos alertas, um por tipo, em
  `styles.<tipo>.design`, e ele vem dentro de cada `overlay.alert`). É o nome
  de um desenho das páginas do Gamerfy; a sua página pode ignorar.
- **Ainda chegando ao Gamerfy:** metas, enquetes, a fila de alertas, o ranking
  `overlay.gifts_today` e a mensagem de presente que espera o streamer
  (`pending` e `channel.gift.message_update`). Os tipos estão aqui para você já
  preparar o código, e passam a vir quando cada um for lançado.
- Numa mesma live o canal entrega na ordem em que os eventos aconteceram; entre
  lives, a ordem real é o `created_at`. Um evento pode chegar duas vezes (numa
  retomada): descarte pelo `id`.

### Montar um overlay

Um overlay é uma página que o OBS abre como fonte de navegador, transparente, por
cima do jogo. Os do Gamerfy (`gamerfy.gg/overlay/alertas`, `…/chat`, `…/qr`…) são
feitos exatamente assim, e você pode fazer o seu do mesmo jeito. A página do site
**Overlays para o OBS** (`gamerfy.gg/overlays`) mostra o caminho para quem não programa.

1. **A chave.** Para a página de um streamer só, use a chave de overlay dele (`gfo_…`):
   ele a copia na área Live, em Overlays, e ela só lê a live — nunca transmite, nunca
   mexe em dinheiro. Para um app que atende muitos streamers, peça a cada um que
   autorize o seu app e use a `gfp_…` dele. Guarde a chave **depois do `#`** do
   endereço da página (`https://seu.site/alertas.html#gfo_…`): o que vem depois do
   `#` nunca vai a servidor nenhum.
2. **Conectar.** Abra `wss://api.gamerfy.gg/v1/events`. No `hello`, mande o
   `identify` com a chave (e o `overlay`, se a página for um dos seis), e o `heartbeat`
   a cada `heartbeat_interval_ms`.
3. **Desenhar.** No `ready`, desenhe o estado do `snapshot` (o ranking, o visual). Cada
   `dispatch` depois é um fato: `channel.gift` toca um presente, `overlay.alert` toca o
   alerta que a fila do streamer decidiu — com a duração e o visual já dentro —,
   `overlay.settings` troca o visual na hora.
4. **Voltar.** Caiu? Espere 1, 2, 4… até 30 segundos e mande o `resume` com o último
   `seq`. Fechou com 4002 ou 4003? A chave não vale mais (o streamer trocou ou tirou
   a autorização): mostre isso na página e não reconecte.

```js
const key = location.hash.slice(1); // gfo_…, depois do # do endereço
const ws = new WebSocket('wss://api.gamerfy.gg/v1/events');
ws.onmessage = ({ data }) => {
  const frame = JSON.parse(data);
  if (frame.op === 'hello') {
    ws.send(JSON.stringify({ op: 'identify', d: { token: key, overlay: 'alerts' } }));
    setInterval(() => ws.send(JSON.stringify({ op: 'heartbeat' })), frame.d.heartbeat_interval_ms);
  }
  if (frame.t === 'overlay.alert') showAlert(frame.d);
};
```

> **Nunca mostre o endereço com a chave na transmissão.** Quem tiver o endereço vê
> tudo o que ele mostra. Se vazar, o streamer troca a chave em Overlays, e os
> endereços antigos param na hora (o canal fecha com 4003).

## Raids

Um app que o streamer autorizou com `raids:manage` manda um raid da live dele para outra
(`POST /v1/raids` com `from_broadcaster_id` e `to_broadcaster_id`) e cancela o raid em contagem
(`DELETE /v1/raids?broadcaster_id=`). São as regras do painel do Gamerfy: a contagem de 60 segundos
aparece para todo mundo e o raid sai sozinho no fim dela (ou depois do sim de quem pergunta a cada
raid); a live precisa de pelo menos 5 contas assistindo, a conta de 7 dias ou mais, 1 hora entre dois
raids para o mesmo canal e no máximo 10 raids em 24 horas; e quem recebe precisa aceitar raids de quem
manda. Cada recusa vem com o seu código (`raid_too_few`, `raid_not_accepted`, `raid_daily_limit`…) e a
frase que o painel mostra. Com `raids:read`, o canal de eventos e os webhooks dizem `channel.raid`
quando um raid sai, para os dois lados.

## Enquetes

Um app que o streamer autorizou com `polls:manage` abre e encerra enquetes na live dele, como o painel
do Gamerfy faria; com `polls:read`, lê a aberta e as anteriores. Sempre com a chave do streamer
(`Authorization: Bearer gfp_…`), e `broadcaster_id` tem de ser o dono da chave (senão,
`403 broadcaster_mismatch`). As três respostas vêm no molde da Twitch, `{ "data": [ … ] }`, e cada
item é a mesma enquete que os eventos `channel.poll.*` levam.

```bash
curl -X POST https://api.gamerfy.gg/v1/polls \
  -H "Authorization: Bearer gfp_…" -H "Content-Type: application/json" \
  -d '{"broadcaster_id":"1834021894356992012","title":"Qual mapa agora?","choices":[{"title":"Mirage"},{"title":"Inferno"}],"duration":180}'
```

- **Abrir** (`POST /v1/polls`): `title` de 1 a 60 caracteres, `choices` de 2 a 5 (cada `title` de 1 a 25,
  sem repetir), `duration` de 15 a 1800 segundos (sem o campo, a enquete não tem prazo: fica aberta até o `PATCH` ou o fim da live, e `ends_at` vem `null`) e, se quiser, `eligibility`: `everyone` (o padrão),
  `followers` ou `subscribers`. Só com a live no ar (`409 creator_not_live`) e uma por vez
  (`409 poll_open`). Cada campo fora das regras tem o seu código: `poll_question_invalid`,
  `poll_options_invalid`, `poll_option_invalid`, `poll_option_repeated`, `poll_duration_invalid`.
- **Encerrar** (`PATCH /v1/polls`, `{ "broadcaster_id", "id", "status": "TERMINATED" }`): fecha agora,
  com o resultado contado no servidor; uma que já fechou é `409 poll_closed`.
- **Ler** (`GET /v1/polls?broadcaster_id=`): a aberta primeiro e as dez anteriores; `&id=` traz uma só.

Quem vota é quem assiste, no topo do chat da live: uma conta, um voto, sem trocar. Para acompanhar a
contagem, ouça `channel.poll.progress` no canal de eventos (uma vez por segundo, no máximo) — não
pergunte de tempos em tempos.

## Chat: escrever e moderar

Ler o chat é pelo canal de eventos (`chat:read`). Com mais duas permissões o seu app também **fala** e
**modera** — o que um bot de moderação, um sorteio ou um comando `!regras` precisam. As duas rotas usam a
chave do streamer (`Authorization: Bearer gfp_…`), e `broadcaster_id` tem de ser o dono da chave.

```bash
# escrever (chat:write)
curl -X POST https://api.gamerfy.gg/v1/chat/messages \
  -H "Authorization: Bearer gfp_…" -H "Content-Type: application/json" \
  -d '{"broadcaster_id":"1834021894356992012","message":"Lembrete: sem spoiler no chat!"}'
# → { "data": [ { "message_id": "98765432109876543", "is_sent": true } ] }

# apagar uma linha (chat:moderate)
curl -X DELETE "https://api.gamerfy.gg/v1/chat/messages?broadcaster_id=1834021894356992012&message_id=98765432109876543" \
  -H "Authorization: Bearer gfp_…"
# → 204
```

- **Quem fala é o bot do app**, nunca o streamer. Em **Configurações → Desenvolvedor**, no seu app, aperte
  **Ligar bot**: é essa conta que escreve, com o nome e o ícone do app e o selo **BOT** ao lado. Um app sem
  bot não tem como falar: `409 app_has_no_bot`. (O token de bot, `gfb_…`, não entra aqui: ele é a porta dos
  servidores. No chat da live quem autoriza é o streamer, com a chave `gfp_…`.)
- **A linha**: texto simples, de 1 a 500 caracteres, sem começar com `/`. Ela chega a todo mundo como qualquer
  outra — no app, no site, no overlay de chat do OBS — e ao canal de eventos como `channel.chat.message` com
  `badges: ["bot"]`. É por aí (ou pelo `user_id`) que o seu app reconhece as próprias linhas e não responde a si
  mesmo.
- **O limite**: 20 linhas a cada 30 segundos por app **e chat** (`429 rate_limited`, com a espera em
  `details.retry_after_ms` e em `Retry-After`). Uma linha recusada não gasta vaga.
- **Apagar** tira a linha para todo mundo na hora, como o streamer faria, e o canal diz
  `channel.chat.message_delete`. Só linhas **daquele chat**: o id de uma mensagem de outro lugar — um servidor,
  uma conversa, outra live — é o mesmo `404 message_not_found` de um id que não existe. O `message_id` é
  obrigatório: não há como limpar o chat inteiro.
- Um streamer que nunca abriu a área Live ainda não tem chat: `404 chat_not_found`. Se ele bloqueou o bot,
  escrever responde `403 blocked`.

Um exemplo completo, que você roda na sua máquina ou hospeda num container:
[GAMERFY-MODERATOR-BOT](https://github.com/trojahnn/GAMERFY-MODERATOR-BOT) — um moderador com IA que lê o chat,
apaga o que foge das regras e avisa quem escreveu. Um processo só atende todos os streamers que o instalaram.

## Webhooks

Em vez de consultar `/v1/streams` e `/v1/videos` de minuto em minuto, registre
uma URL com `POST /v1/webhooks` e o Gamerfy faz um `POST` nela a cada evento. É
o espírito do EventSub da Twitch sem a cerimônia: sem *challenge* na inscrição
e sem tipos de transporte. É um `POST` assinado, e pronto.

Os eventos e o corpo de cada um estão na seção **Webhooks** da referência. Todo
corpo tem o formato `{ id, type, created_at, data }`. Nos quatro de sempre
(`stream.online`, `stream.offline`, `video.ready`, `clip.ready`), `data` é
exatamente o item que a rota correspondente devolveria naquele instante; nos da
live, é o mesmo `data` do **Canal de eventos**, com o mesmo `id`.

### Os eventos da live, só com autorização

| Chega de toda live pública | Só das lives de quem autorizou o seu app |
|---|---|
| `stream.online`, `stream.offline`, `video.ready`, `clip.ready`, `channel.update` | `channel.follow` (`followers:read`), `channel.subscribe` e `channel.subscription.end` (`subscriptions:read`), `channel.gift` e `channel.gift.message_update` (`gifts:read`), `channel.goal.begin` · `.progress` · `.achieved` · `.end` (`goals:read`), `channel.poll.begin` · `.progress` · `.end` (`polls:read`) |

Um aviso de presente, de seguidor ou de assinatura **só sai para o seu app se o
streamer autorizou o app com a permissão do tipo** (veja **Autorização de apps**).
Sem autorização, nada sai; e quando ele tira o acesso em Conexões, param os novos
e também as tentativas de um aviso que já estava na fila. O mesmo webhook pode
ouvir as lives de todos os streamers que autorizaram o app. Um webhook aceita até
**32** tipos na lista.

### Verificar a assinatura

Cada entrega traz:

| Cabeçalho | Valor |
|---|---|
| `Gamerfy-Webhook-Id` | o `id` do evento |
| `Gamerfy-Webhook-Timestamp` | segundos Unix do envio desta tentativa |
| `Gamerfy-Signature` | `sha256=<hex>`: HMAC-SHA256 com o `secret`, sobre `"{id}.{timestamp}.{corpo bruto}"` |

O corpo bruto é o JSON tal como chegou, byte a byte: **não** faça `JSON.parse`
e serialize de novo antes de verificar. Recuse um `timestamp` com mais de
alguns minutos de diferença, para que um envio capturado não possa ser
reaproveitado.

```js
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

const SECRET = process.env.GAMERFY_WEBHOOK_SECRET; // o `secret` do registro
const MAX_SKEW_S = 300;

const app = express();

// `express.raw`: o corpo BRUTO é o que foi assinado.
app.post('/gamerfy', express.raw({ type: 'application/json' }), (req, res) => {
  const id = req.get('Gamerfy-Webhook-Id') ?? '';
  const timestamp = req.get('Gamerfy-Webhook-Timestamp') ?? '';
  const signature = req.get('Gamerfy-Signature') ?? '';
  const rawBody = req.body.toString('utf8');

  const expected =
    'sha256=' + createHmac('sha256', SECRET).update(`${id}.${timestamp}.${rawBody}`).digest('hex');
  const given = Buffer.from(signature);
  const wanted = Buffer.from(expected);
  if (given.length !== wanted.length || !timingSafeEqual(given, wanted)) {
    return res.status(401).end();
  }
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > MAX_SKEW_S) {
    return res.status(401).end();
  }

  const event = JSON.parse(rawBody); // { id, type, created_at, data }
  // …guarde event.id para descartar repetições e trate event.type/event.data
  res.status(204).end();
});

app.listen(8080);
```

### Entrega, tentativas e desligamento

- Responda **2xx em até 10 segundos**. Qualquer outra coisa (outro status, um
  redirecionamento, timeout, recusa de conexão) conta como falha. Processe fora
  do ciclo do pedido se o trabalho for longo.
- Uma entrega que falha é repetida **5 vezes**, com espera exponencial a partir
  de 60 s (1, 2, 4, 8 e 16 minutos).
- O `id` do evento é o mesmo em todas as tentativas e em todos os webhooks que
  o recebem: use-o para descartar duplicatas.
- Falhas **seguidas** somam em `failures` (a primeira resposta 2xx zera). Na
  **20.ª** o webhook é desligado: `disabled_at` deixa de ser `null`, nada mais é
  enviado e o que estava na fila é descartado. Para voltar, apague e registre
  de novo.
- A ordem de chegada não é garantida entre eventos diferentes; o `created_at`
  do evento diz a ordem real.
- Um evento cujas 5 tentativas falharam é perdido: se a sua URL ficou fora do
  ar, confira `/v1/streams` e `/v1/videos` ao voltar.

Regras da URL: **`https://`** obrigatório, sem usuário e senha, sem fragmento
(`#`), host público (nada de `localhost`, `10.x`, `192.168.x`, `127.x`…) e até
2.048 caracteres.

## Erros e limites

Todo erro tem o mesmo formato:

```json
{ "error": "invalid_token", "message": "token de app ausente, inválido ou expirado" }
```

`error` é o código estável: compare com ele, nunca com a mensagem. `message` é
para humanos, em pt-BR, e pode mudar. `details` aparece só em erros de
validação.

| Status | `error` | Quando |
|---|---|---|
| 400 | `invalid_body` | corpo malformado, campo faltando, URL de webhook fora das regras ou evento desconhecido |
| 400 | `invalid_query` | parâmetro recusado: nenhum filtro, mais de 100 valores, `first` fora de 1..100, id não numérico, data fora do ISO 8601 |
| 400 | `unsupported_grant_type` | `grant_type` diferente de `client_credentials`, `authorization_code` e `refresh_token` |
| 400 | `invalid_request` · `invalid_grant` | *(autorização)* pedido incompleto; código vencido, já usado, de outro app, com outro `redirect_uri` ou com `code_verifier` que não confere; `refresh_token` já trocado ou vencido — com `error_description` |
| 400 | `invalid_cursor` | `after` corrompido |
| 400 | `bot_account` | *(bots)* mensagem privada para uma conta de bot — o próprio inclusive |
| 401 | `invalid_client` | `client_id` ou `client_secret` errados — ou, na autorização, app com a autorização desligada pelo Gamerfy |
| 401 | `invalid_token` | bearer ausente, com formato errado, desconhecido ou expirado — ou, nas rotas de bot, o mesmo com o token `Bot gfb_…` |
| 401 | `bot_token_expired` | *(bots)* token de bot com 90 dias sem uso; gere outro na aba Desenvolvedor |
| 403 | `missing_scope` | a autorização do streamer não inclui a permissão que a rota pede; `details.scope` diz qual |
| 403 | `missing_permission` | *(bots)* falta uma permissão do bot naquele servidor ou sala; `details.permission` nomeia qual |
| 403 | `no_shared_guild` · `bot_dms_disabled` · `blocked` | *(bots)* mensagem privada para quem não está em nenhum servidor com o bot, desligou as mensagens privadas de bots, ou tem bloqueio com ele |
| 404 | `not_found` | rota que não existe |
| 404 | `webhook_not_found` | webhook que não é do seu app |
| 404 | `channel_not_found` | *(bots)* id que não é sala, sala que não existe, conversa direta, sala de tipo errado para a rota, ou sala de servidor onde o bot não está — todas respondem igual, de propósito: um 404 não conta a um estranho o que existe |
| 404 | `voice_session_not_found` | *(bots)* sessão de voz que já acabou, ou de outro bot |
| 404 | `user_not_found` | *(bots)* mensagem privada para um id que não é de conta nenhuma |
| 409 | `webhook_limit_reached` | sexto webhook do mesmo app |
| 413 | `payload_too_large` | corpo acima de 64 KB (a oferta WHIP inclusive) |
| 415 | `unsupported_media_type` | *(bots)* corpo da entrada de voz que não é `application/sdp`, ou está vazio |
| 429 | `rate_limited` | mais de 800 pedidos no minuto — ou, nos bots, mais de 5 mensagens em 5 s no mesmo canal, mais de 20 mensagens privadas por minuto, ou mais de 6 entradas de voz por minuto na mesma sala; `details.retry_after_ms` diz quanto esperar |
| 502 · 503 | `voice_unavailable` | *(bots)* o servidor de mídia recusou a entrada (502) ou não respondeu (503) |
| 503 | `bots_unavailable` | *(bots)* este nó do backend subiu sem a API de bots; é do servidor e não do seu pedido — repita (em produção todo nó liga os bots) |
| 503 | (página de manutenção) | durante uma atualização; `Retry-After: 600` |

**Rate limit:** 800 pedidos por minuto por app, em janela fixa. Os tokens de um
mesmo app dividem o limite. Toda resposta autenticada traz `Ratelimit-Limit`,
`Ratelimit-Remaining` e `Ratelimit-Reset` (segundos até a janela virar). Ao
receber 429, espere `Ratelimit-Reset` segundos.

**Retenção:** cada replay fica **14 dias** a partir do fim da live. Depois disso
some de `/v1/videos` e a página responde 404. O streamer pode apagar um replay
antes do prazo.

## Bots

Um bot é uma conta que o seu app opera: entra em servidores, escreve, modera e
fala numa sala de voz. É a outra porta desta API — token próprio
(`Authorization: Bot gfb_…`), rotas próprias (na referência, sob a tag
**Bots**) e um gateway WebSocket que entrega o que acontece na hora.

A conta de bot não é uma pessoa: não aparece na busca, não tem página de perfil,
não segue ninguém, não recebe mensagem direta e não entra nas métricas de
pessoas. Ela existe dentro dos servidores onde foi adicionada — e pode **mandar**
uma mensagem privada a quem está num desses servidores (veja **Mensagem
privada** abaixo).

### Criar um bot

No Gamerfy, em **Configurações → Desenvolvedor**, no seu app:

1. **Ligar bot** cria a conta. O `@usuário` sai do nome do app (minúsculas,
   espaços viram `_`, sem acento, com sufixo `_2`, `_3`… em conflito); o nome
   visível e o avatar são o nome e o ícone do app. Renomear o app muda o nome
   visível do bot, nunca o `@usuário`.
2. O **token de bot** aparece **uma única vez** (o Gamerfy guarda só o
   SHA-256). **Gerar novo token** invalida o anterior na hora e fecha a conexão
   do gateway com o código 4003.
3. O link de adicionar só liga com o **perfil completo**: bot ligado, nome,
   ícone, descrição e política de privacidade. A partir daí o endereço a
   divulgar é `https://gamerfy.gg/bot/<client_id>` — quem abrir escolhe o
   servidor e marca as permissões.

Um bot entra em até **100 servidores** por enquanto. O 101.º é recusado na tela
de quem está adicionando (`409 bot_guild_limit`), e não numa rota de `/v1`: quem
adiciona é uma pessoa, não o bot. Cada conta pode ter até 10 apps, e cada app um
bot.

| Credencial | Formato | Onde guardar |
|---|---|---|
| token de bot | `gfb_` + 40 caracteres `[A-Za-z0-9]` | segredo; vence com 90 dias sem uso |

**O token vence por inatividade.** Cada conexão do gateway e cada chamada da
API renova o prazo; 90 dias sem nenhuma das duas e ele deixa de valer: as rotas
respondem `401 bot_token_expired` e o gateway fecha com 4002. A aba
Desenvolvedor mostra "Token expirado por inatividade" com o botão de gerar
outro. Um bot que fica conectado nunca expira.

**Apagar o app aposenta o bot:** ele sai de todos os servidores, o gateway
fecha com 4005 e a conta vira "Bot removido". As mensagens que ele escreveu
ficam nos canais com esse autor, e quem tem Gerenciar mensagens ainda pode
apagá-las.

### Autenticação

```bash
curl https://api.gamerfy.gg/v1/bot/@me \
  -H 'Authorization: Bot gfb_…'
```

O esquema é **`Bot`**, não `Bearer`: o token de app (`gfa_`) não abre as rotas
de bot, e o token de bot não abre as de app. Ausente, malformado ou revogado é
`401 invalid_token`; vencido por inatividade é `401 bot_token_expired`.

Quatro limites, os quatro com o código `rate_limited` e a espera em
`details.retry_after_ms` (também em `Retry-After`, em segundos):

| Limite | Escopo | Onde ler |
|---|---|---|
| 800 pedidos por minuto | por app, em janela fixa; contagem própria, à parte da do token `gfa_` | `Ratelimit-Limit`, `Ratelimit-Remaining` e `Ratelimit-Reset` em toda resposta |
| 5 mensagens a cada 5 segundos | por bot **e canal**: responder em dez canais não atrasa nenhum | a mensagem do 429 |
| 20 mensagens privadas por minuto | por bot, **somando todas as pessoas** | a mensagem do 429 |
| 6 entradas de voz por minuto | por bot **e sala**: tocar em dez salas não atrasa nenhuma | a mensagem do 429 |

Uma mensagem **recusada não gasta vaga**: um corpo inválido é barrado antes do
contador, e a recusa por permissão devolve a vaga que tomou. As cinco são cinco
mensagens, não cinco tentativas. Só o erro 500 fica com a vaga, porque ninguém
pode prometer que a mensagem não entrou.

Na voz, a recusa **na porta** também não gasta vaga: corpo que não é oferta SDP
(415), sala inexistente ou de servidor sem o bot (404) e falta de Ver canais,
Conectar ou Falar (403). Mas **toda entrada que passa da porta gasta vaga**,
aceita ou recusada pelo servidor de mídia (502, 503), porque o que custa é a
própria entrada — a conexão com o servidor de mídia e o entra-e-sai na sala das
pessoas que estão lá.

### Permissões

O bot tem uma concessão **própria** em cada servidor, dada por quem o adicionou
e mudável depois em "Permissões do bot", no cartão do membro. Ela não vem de
cargos: `@everyone` e os cargos do servidor não somam nada para um bot, que
recebe só o cargo **Bot** — criado e mantido pelo sistema, com permissão zero.

As permissões numa sala, em ordem:

1. base: a concessão do bot naquele servidor;
2. **Administrador** na base: tudo, e as sobrescritas não são lidas;
3. sobrescrita do `@everyone` na sala: só o que ela **nega** vale (uma sala
   fechada fecha para o bot; um `allow` para todos não abre o que o admin não
   deu);
4. sobrescritas dos cargos **que o bot tem** (o cargo Bot, ou outro que o
   admin lhe deu): valem nos dois sentidos, somadas como as de uma pessoa (um
   `allow` de um cargo vence o `deny` de outro); as de cargos que o bot não tem
   não são lidas;
5. sobrescrita **do próprio bot**: vale nos dois sentidos e decide. Um bot entra
   numa **sala privada** de dois jeitos — a lista de membros da sala escreve
   essa sobrescrita, ou o admin libera a sala ao cargo Bot.

`permissions` é sempre uma string em decimal, no servidor
(`GET /v1/bot/guilds`) e em cada sala (`GET /v1/guilds/{guild_id}/channels`,
`ready`, `guild.permissions_updated`). Teste bits, nunca compare o valor
inteiro:

| Permissão | Bit | Decimal | O que abre para o bot |
|---|---|---|---|
| Ver canais (`VIEW_CHANNEL`) | 0 | 1 | enxergar a sala; sem ela a sala não aparece em lugar nenhum |
| Enviar mensagens (`SEND_MESSAGES`) | 1 | 2 | `POST /v1/channels/{channel_id}/messages` |
| Gerenciar mensagens (`MANAGE_MESSAGES`) | 2 | 4 | apagar mensagem de outra pessoa (a própria dispensa) |
| Expulsar membros (`KICK_MEMBERS`) | 6 | 64 | expulsar e banir |
| Conectar (`CONNECT`) | 8 | 256 | entrar na sala de voz |
| Falar (`SPEAK`) | 9 | 512 | publicar áudio (sem ela o bot não entra na sala; tirada no meio da chamada, corta o microfone na hora) |
| Ler mensagens (`READ_MESSAGE_CONTENT`) | 13 | 8192 | receber o **texto** das mensagens dos canais que enxerga |
| Administrador (`ADMINISTRATOR`) | 30 | 1073741824 | todas, em todas as salas |

O nome entre parênteses é a constante que um `403 missing_permission` devolve em
`details.permission` — é por ela que se volta desta tabela para a permissão que
faltou. O rótulo em português é o que a pessoa que adiciona o bot vê na tela.

Exemplo: `"permissions": "8963"` é Ver canais + Enviar mensagens + Conectar +
Falar + Ler mensagens.

**Ler mensagens** só existe para bot (uma pessoa lê com Ver canais) e nunca vai
num cargo nem numa sobrescrita. Sem ela, `message.created` e `message.updated`
chegam com `content: null` e `attachments` vazio. O filtro roda no servidor — o
texto não sai daqui — e libera em quatro casos: o bot tem Ler mensagens na sala
(ou Administrador), **a mensagem é um comando (o texto começa com `/`)**, o bot
está entre as menções, ou o bot é o autor.

Comando e menção sempre chegam inteiros: um bot que só toma `/comandos` ou
responde quando é chamado **não precisa** de Ler mensagens. `mentions` também
vem sempre como a mensagem o escreveu, então um bot que recebe `content: null`
ainda sabe que não era com ele. A permissão total só faz falta a um bot que
precisa ler TUDO que passa no canal (um moderador, por exemplo).

Os outros bits do servidor (Gerenciar canais, Gerenciar servidor, Gerenciar
cargos, Criar convites, Gerenciar apelidos, Silenciar membros) podem ser
concedidos ao bot, mas nenhuma rota desta fase os exige. Transmitir tela e câmera
deixa um bot que ouve a sala compartilhar a tela (ver "Ouvir a sala").

### Gateway em tempo real

```
wss://api.gamerfy.gg/v1/gateway
```

Um WebSocket comum, quadros JSON, sem compressão e sem intents: o bot recebe o
que as permissões dele deixam ver. A conversa:

```
wss://api.gamerfy.gg/v1/gateway?resume=1
← {"op":"hello","d":{"heartbeat_interval_ms":30000,"version":1}}
→ {"op":"identify","d":{"token":"gfb_…"}}
← {"op":"dispatch","t":"ready","id":"1834021894356992001","d":{"bot":{…},"guilds":[…],"session_id":"k3Jm9qLx0Zt2.1834021894356992001"},"seq":0}
→ {"op":"heartbeat"}
← {"op":"heartbeat_ack"}
← {"op":"dispatch","t":"message.created","id":"1834021894356992002","d":{…},"seq":1}
```

- O `identify` tem **10 segundos** a partir do `hello`; depois dele, um
  `heartbeat` a cada `heartbeat_interval_ms` (30 s hoje — leia do `hello`, não
  fixe no código).
- Todo evento vem em `dispatch`, com `t` (o nome), `id` (um snowflake deste
  quadro) e `d` (o corpo).
- **Uma conexão por bot.** Conectar de novo derruba a anterior com 4004; não
  abra duas.
- Quadro binário ou fora do protocolo: 4000. Mais de 60 quadros por minuto:
  4008. Silêncio de 75 s: 4009. Quadro acima de **4 KB** é recusado pelo próprio
  WebSocket, com o 1009 do padrão (nenhum quadro deste protocolo chega perto
  disso).
- Conectar marca o bot **online** (a bolinha na lista de membros do servidor).
- **Retomada.** Com `?resume=1` no endereço, o `ready` traz `session_id` e todo
  `dispatch` traz `seq` (o `ready` é 0; depois 1, 2, 3…). Caiu? Reconecte e, depois
  do `hello`, mande `{"op":"resume","d":{"token":"gfb_…","session_id":"…","seq":<último
  recebido>}}`: o gateway guarda a sessão por **2 minutos** e até **1.000 quadros ou 1 MB**, repõe
  os `dispatch` com `seq` maior que o seu e fecha a reposição com
  `{"op":"dispatch","t":"resumed","d":{"replayed":N}}`. Presença: offline enquanto
  a sessão está parada, online de novo no `resumed`. Se a sessão não existe mais
  (venceu, estourou, o servidor reiniciou, outro processo seu fez `identify`, o bot
  pediu um `seq` que a sessão não cobre — uma retomada que ficou para trás —, ou o nó
  atingiu o teto de 500 sessões estacionadas e a mais antiga caiu), o
  fechamento é **4006** — mande `identify` e trate o `ready` como sempre. Sem
  `?resume=1` nada disso aparece e o fio é o de sempre.

| Evento | `d` |
|---|---|
| `ready` | `bot` (a conta, com `app_id`) e `guilds`: cada servidor com as salas visíveis, as permissões do bot em cada uma e os estados de voz dessas salas |
| `guild.joined` | o servidor inteiro, no formato do `ready` |
| `guild.left` | `guild_id` — expulso, banido, ou o servidor apagado; os eventos daquele servidor param |
| `guild.permissions_updated` | `guild_id`, `permissions` no servidor e `channels` (`id` e `permissions` de cada sala visível) |
| `channel.created` / `channel.updated` | `guild_id` e `channel` (`id`, `guild_id`, `type`, `name`, `position`, `permissions`) — só salas visíveis |
| `channel.deleted` | `guild_id` e `channel_id` |
| `member.joined` | `guild_id` e `member` (o usuário com `bot`, `nickname`, `joined_at`) |
| `member.left` | `guild_id` e `user_id` |
| `message.created` / `message.updated` | a mensagem: `id`, `guild_id`, `channel_id`, `author`, `content`, `mentions`, `attachments`, `created_at`, `edited_at` |
| `message.deleted` | `id`, `guild_id` e `channel_id` |
| `voice_state.updated` | `guild_id`, `user_id` e `channel_id` (ou `null` ao sair) — só salas visíveis |

Uma sala que passa a ser **visível** para o bot (porque a permissão dele mudou)
chega como `guild.permissions_updated`, e não como `channel.created` — esse
corpo traz id e permissões, não o nome. Para o nome, releia
`GET /v1/guilds/{guild_id}/channels`.

Os `voice_states` do `ready` e os `voice_state.updated` vêm do espelho de voz do
backend, alimentado pelos webhooks do LiveKit — que são melhor-esforço. Para agir
sobre a sala de alguém (um `/play` que entra na sala de quem pediu), leia o
servidor de novo com **`GET /v1/guilds/{guild_id}`**: a resposta tem a mesma forma
do `ready` por servidor (concessão, salas visíveis e `voice_states`), e o backend
confere quem está nas salas contra o próprio LiveKit antes de responder (no máximo
uma vez a cada 5 s por servidor). No SDK é `bot.fetchGuild(guildId)`, que refaz o
cache com a resposta.

| Código | Motivo | Reconectar? |
|---|---|---|
| 4000 | quadro que esta versão não aceita (op desconhecido, forma errada, binário, segundo `identify`) | corrija o cliente |
| 4001 | passou de 10 s sem `identify`, ou mandou `heartbeat` antes dele | sim |
| 4002 | token inválido ou vencido por inatividade | **não**: gere outro na aba Desenvolvedor |
| 4003 | o token foi trocado | **não**: use o novo |
| 4004 | outra conexão do mesmo bot assumiu | **não**: é o seu outro processo |
| 4005 | app apagado ou bot desligado | **não** |
| 4006 | a sessão do `resume` não existe mais | sim, com `identify` |
| 4008 | quadros demais | sim, com calma |
| 4009 | 75 s sem `heartbeat` | sim |
| 1001 | estamos saindo do ar (um deploy) | sim |
| 1011 | falha do nosso lado no meio de um quadro bom | sim |

Ao reconectar, espere 1, 2, 4, 8, 16 e até 30 segundos, com uma variação
aleatória — é o que o SDK faz. Códigos novos podem aparecer: trate qualquer
desconhecido como "tentar de novo", nunca como fatal.

### Voz

O bot fala pelo **WHIP** (RFC 9725), o mesmo padrão que o app usa para mandar
live. O áudio vai do bot direto ao servidor de mídia; o Gamerfy é o portão, não
o cano.

```bash
curl -X POST 'https://api.gamerfy.gg/v1/channels/1834021894356992010/voice' \
  -H 'Authorization: Bot gfb_…' \
  -H 'Content-Type: application/sdp' \
  --data-binary @offer.sdp
```

- A oferta é de um WebRTC **só de envio**, com os candidatos ICE **já juntos**:
  não existe `PATCH` (ICE trickle) nesta fase. Até 64 kB.
- Exige Ver canais + **Conectar** + **Falar** na sala (`403 missing_permission`,
  com o bit em `details.permission`). Sala inexistente, que não é de voz, ou de
  servidor onde o bot não está: `404 channel_not_found`.
- **Falar é seguida durante a chamada.** Perder *Falar* com o bot numa sala corta
  o microfone **na hora** — o servidor de mídia derruba a faixa; o bot fica na
  sala, mudo (o SDK interrompe a faixa e `play()` rejeita `missing_permission`).
  Recuperar *Falar* devolve o microfone na hora: o SDK refaz a sessão WHIP
  sozinho; quem fala WHIP direto refaz a oferta (um `POST` novo — a sessão
  antiga pode ser encerrada pelo `DELETE` dela).
- O 201 traz a resposta SDP em `application/sdp` e três cabeçalhos:
  `Location` com a sessão (`/v1/voice/sessions/<uuid>`), `Link` com os
  servidores ICE e `ETag` da sessão ICE.
- **`Location` é relativo à base da API**, não à origem do pedido: resolva
  contra a sua `apiUrl` (com prefixo de caminho, se houver) e faça `DELETE`
  nesse endereço para sair — 204, e 404 se a sessão já acabou ou é de outro bot.
- **`Link` traz os servidores ICE do servidor de mídia** (`stun:`, `turn:` e `turns:`,
  com `username`/`credential` nos TURN; o TURN/TLS vem na porta que o Gamerfy publica,
  5349). Pode trazer vários valores numa linha, e um endereço TURN tem vírgula dentro
  das aspas: use um parser de `Link`, nunca `split(',')`. Atrás de NAT restritivo ou
  numa rede sem UDP até o servidor de mídia, a primeira oferta não conecta — os
  servidores só chegam na resposta: refaça a entrada (`DELETE` na sessão e um `POST`
  novo com uma oferta gerada só com esses servidores, em modo relay). O SDK faz isso
  sozinho (`voice.transport`); cada `POST` conta no limite de 6 entradas por minuto.
- Por esta entrada o bot **só fala**: ele não recebe o áudio da sala. Para ouvir,
  veja **Ouvir a sala** abaixo.
- **Uma sala por servidor, uma em cada servidor.** Entrar em outra sala do mesmo
  servidor tira o bot da primeira; entrar numa sala de outro servidor não mexe em
  nada, e cada sessão se desliga pelo seu próprio `DELETE`.
- Servidor de mídia fora do ar: `503 voice_unavailable`; entrada recusada por
  ele: `502 voice_unavailable`. O resto do bot continua funcionando.
- Na sala, a conta do bot passa pelos mesmos caminhos de uma pessoa: o espelho
  de voz, a lista de quem está na sala, o registro de tempo em voz e a checagem
  de admissão. Na fileira de rostos da sala o bot é um rosto como os outros, com
  o anel de quem fala — essa fileira não desenha selo nenhum, de propósito; o
  selo BOT aparece na lista de membros, no cartão e nas mensagens. Expulsar
  tira da sala na hora (o servidor de mídia é cortado antes de qualquer aviso).
  Se o programa do bot cair sem se despedir, quem o tira da sala é o tempo de
  espera do próprio servidor de mídia — não é uma garantia da Gamerfy.

### Ouvir a sala

Um bot com a permissão **Ouvir a sala** — que só um administrador do servidor
concede, e que pode ser tirada sala por sala — entra ouvindo:

```bash
curl -X POST 'https://api.gamerfy.gg/v1/channels/1834021894356992010/voice/listen' \
  -H 'Authorization: Bot gfb_…'
```

- O 201 traz `{ session_id, url, token }` e o mesmo `Location` da entrada WHIP.
  O `url` e o `token` são do SDK: com eles o `voice.listen()` conecta, publica o
  microfone do bot e recebe **a voz de cada pessoa separada**, já com o id da
  conta de quem fala. O áudio da tela (jogo, vídeo) nunca é entregue.
- Exige Ver canais, Conectar, Falar e Ouvir a sala (`403 missing_permission`). A
  permissão de Administrador **não** inclui ouvir: só vale se for marcada.
- **O bot aparece marcado como "ouvindo"**, e quem está na sala é avisado por
  escrito. A marca vem do Gamerfy, não do bot.
- Perder Ouvir a sala no meio da chamada corta a escuta **na hora**; o bot fica
  na sala, falando.
- Mesmas regras da entrada WHIP: uma sala por servidor, 6 entradas por minuto
  na mesma sala (somando as duas entradas) e `DELETE` no `Location` para sair.
- O que o bot faz com o que ouve — transcrever, guardar — é dele; o Gamerfy não
  recebe nem guarda nada da conversa.
- **Compartilhar a tela** (desde 06/11/2026): com a permissão **Transmitir tela e
  câmera** — a mesma das pessoas —, o bot que entrou ouvindo também pode mostrar
  uma imagem como tela compartilhada (`room.shareScreen()` no SDK). A sala recebe
  como recebe a tela de qualquer pessoa: um convite para assistir, e só vê quem
  abrir. Câmera e áudio da tela continuam fora para bots. Sem a permissão, o
  servidor de mídia recusa a tela e o bot continua na sala, ouvindo e falando.

### O cargo escolhido para o bot

Um bot pode pedir um cargo ao ser adicionado (o campo **Pergunta do cargo** na
aba Desenvolvedor, como "Quem pode chamar o Troia?"). Quem adiciona escolhe um
dos cargos do servidor — o @everyone vale para todos — e pode trocar depois nas
permissões do bot. O bot pergunta na hora de decidir:

```bash
curl 'https://api.gamerfy.gg/v1/guilds/1834021894356992000/members/1834021894356992111' \
  -H 'Authorization: Bot gfb_…'
```

- Responde `{ data: [membro] }` com `role_ids` e `holds_chosen_role`: `true` ou
  `false`, e `null` quando o bot não pede cargo. `@me` no lugar do id lê o
  próprio bot.
- Servidor sem o bot: `404 guild_not_found`; conta que não é membro:
  `404 member_not_found`.

### Mensagem privada

Um bot pode escrever para **uma pessoa**, numa conversa privada — para entregar
o que só ela deve ver, como um link de uso único:

```bash
curl -X POST 'https://api.gamerfy.gg/v1/users/1834021894356992111/messages' \
  -H 'Authorization: Bot gfb_…' \
  -H 'Content-Type: application/json' \
  -d '{"content":"Seu link: https://exemplo.com/mcp/abc"}'
# → 201 { "data": [ { "id": "…", "channel_id": "…", "recipient_id": "1834021894356992111",
#                     "author": { …, "bot": true }, "content": "Seu link: …", "created_at": "…" } ] }
```

- A conversa abre na primeira mensagem e é a mesma nas seguintes (`channel_id`).
  A pessoa a vê na caixa de mensagens dela, com o selo BOT, contada como não lida.
- **Só para quem divide um servidor com o bot**: o bot precisa estar em pelo menos
  um servidor em que a pessoa também está — senão `403 no_shared_guild`, checado
  antes de tudo o que é da pessoa.
- **A pessoa pode desligar**: "Mensagens privadas de bots", em Configurações →
  Conta e perfil → Privacidade, vem ligado; desligado, `403 bot_dms_disabled`.
- Bloqueio em qualquer sentido: `403 blocked`. Conta de bot (o próprio inclusive):
  `400 bot_account`. Id de ninguém: `404 user_not_found`.
- **É de mão única.** A pessoa lê, mas não responde — no lugar da caixa de
  escrever, o app mostra "Este bot não lê respostas." —, e o bot não recebe nada
  da conversa: nenhum evento do gateway fala dela.
- O corpo é o de `POST /v1/channels/{channel_id}/messages`: só `content`, até 2000
  caracteres, sem anexos.
- Até **20 mensagens privadas por minuto**, somando todas as pessoas. Uma recusa
  não gasta vaga. No SDK é `bot.users.send(userId, content)`.

### SDK

O `@gamerfy/bot` fala tudo isso por você em Node 22 (mensagens, memória dos
servidores, reconexão, WHIP, Ogg Opus e `ffmpeg`), e valida com zod tudo o que
chega antes de qualquer callback seu. **Ainda não está publicado no npm**; hoje
ele mora em `packages/bot-sdk` no repositório do Gamerfy, com o guia completo no
`README.md` de lá.

O DJ — o exemplo `examples/dj.ts`, aqui sem o tratamento de erro que o arquivo
traz:

```ts
import { Bot } from '@gamerfy/bot';

const bot = new Bot({ token: process.env.GAMERFY_BOT_TOKEN! });

bot.on('message', async (message) => {
  // `content` is null when the bot has no Ler mensagens and was not mentioned.
  if (!message.mentionsMe || !message.content?.includes('tocar')) return;
  const channelId = bot.voiceChannelOf(message.guildId, message.author.id);
  if (channelId === null) {
    await bot.messages.send(message.channelId, 'Entre numa sala de voz primeiro.');
    return;
  }
  await bot.messages.send(message.channelId, 'Tocando lofi');
  const voice = await bot.voice.join(channelId);
  try {
    await voice.play('./lofi.ogg');
  } finally {
    await voice.leave();
  }
});

bot.on('error', (error) => console.error('[dj]', error.message));
await bot.connect();
```

Na mão, o mesmo bot é: nenhum `POST /oauth2/token` (o token de bot já é o
token), um WebSocket com `identify` e `heartbeat`, e a oferta WHIP acima.

## Vindo da Twitch

| Na Twitch (Helix) | No Gamerfy | Diferença |
|---|---|---|
| `POST id.twitch.tv/oauth2/token` | `POST api.gamerfy.gg/oauth2/token` | mesmo corpo; token `gfa_…` de 60 dias |
| `GET helix/users?login=` | `GET /v1/users?login=` | `links[]` já traz o `account_id` do PUBG |
| `GET helix/streams?game_id=493057` | `GET /v1/streams?game_id=pubg` | `game_id` é slug; `recording_id` aponta para o replay futuro |
| `GET helix/videos?user_id=&type=archive` | `GET /v1/videos?user_id=&type=archive` | `created_at` é o primeiro quadro, sem recuo; há `duration_seconds` |
| `GET helix/games?id=` | `GET /v1/games?id=pubg` | devolve `twitch_category_id` para cruzar |
| `GET helix/clips?broadcaster_id=` | `GET /v1/clips?broadcaster_id=` | `duration` em segundos, `vod_offset` sempre presente |
| Player embed `?t=1h2m3s` | `gamerfy.gg/videos/{id}?t=1h2m3s` | mesmo formato de `t`; sem embed por iframe ainda |
| EventSub | `POST /v1/webhooks` | sem *challenge*; assinatura HMAC no molde `id.timestamp.body` |
| `id.twitch.tv/oauth2/authorize` (código) | `gamerfy.gg/autorizar` | PKCE `S256` obrigatório; `gfp_…` de 4 h e `gfr_…` que troca a cada renovação |
| `POST`, `PATCH` e `GET helix/polls` | `POST`, `PATCH` e `GET /v1/polls` | com a chave do streamer (`gfp_…`, `polls:manage`/`polls:read`); `choices[].title` igual, `duration` em segundos; a resposta é a enquete dos eventos (`poll_id`, `choices[].votes`, `close_reason`), não o `status` da Helix |
| `POST` e `DELETE helix/raids` | `POST` e `DELETE /v1/raids` | com a chave do streamer (`gfp_…`, `raids:manage`); o corpo é JSON (`from_broadcaster_id`, `to_broadcaster_id`), não query; a resposta traz `ends_at` e `waiting_answer` em vez de `is_mature` |
| EventSub por WebSocket | `wss://api.gamerfy.gg/v1/events` | o protocolo do gateway dos bots, com retomada; os tipos seguem os nomes da EventSub (`channel.follow`, `channel.subscribe`, `channel.chat.message`…) |
| `Ratelimit-*` (800/min) | os mesmos três cabeçalhos, 800/min por app | janela fixa de 60 s |
| `view_count` (total de reproduções) | `view_count` em `/v1/videos` e `/v1/clips` | **pessoas distintas por dia, somadas dia a dia**: a mesma pessoa conta no máximo uma vez por dia em cada item, e reproduções repetidas no mesmo dia não somam. Não é o total de reproduções da Twitch. Atualizado a cada 10 minutos |

## O que ainda não existe

- **Challenge na inscrição do webhook**: a assinatura de cada entrega é a única
  verificação. Também não há rota para disparar um evento de teste.
- **Criar clipes pela API**: só pelo app; a API lista.
- **Lives de canal de servidor**: só lives pessoais entram em `/v1/streams`,
  são gravadas e disparam webhooks.
- **Embed por iframe** das páginas de replay e de clipe (`embed_url` é `null`).
- **`before` na paginação** e busca por título.
- **Nos bots:** retomada entre nós do backend, comandos com barra, resposta a uma
  mensagem privada de bot (a pessoa só lê), ICE trickle na entrada de voz, bot que **ouve** a sala, e SDK em outra linguagem além do
  TypeScript.

## English summary

For the pubg.report team.

Base URL: `https://api.gamerfy.gg`. Create an app in the Gamerfy app
(Configurações → Desenvolvedor) for a `client_id` + `client_secret` (shown once);
`POST /oauth2/token` with `grant_type=client_credentials` returns a `gfa_…` bearer
token (60 days). Twitch-shaped endpoints (`{ data, pagination: { cursor } }`,
`snake_case`, ISO 8601 UTC): `GET /v1/users`, `/v1/streams` (lives on air),
`/v1/videos` (replays, with `thumbnail_url`), `/v1/clips` (by `id`,
`broadcaster_id` or `video_id`) and `/v1/games`. Public pages:
`gamerfy.gg/videos/{id}?t=1h2m3s` and `gamerfy.gg/clips/{id}`. Match event → replay:
`offset = event_time_utc − video.created_at`, no lead correction (`created_at` is the
first recorded frame, ms precision). Webhooks: `POST /v1/webhooks` (up to 5 per app) for
`stream.online`, `stream.offline`, `video.ready`, `clip.ready`, `channel.update`, HMAC-SHA256
signed; the live's own events (`channel.follow`, `channel.subscribe`, `channel.gift`,
`channel.goal.*`, `channel.poll.*`…) reach an app only from the streamers who authorized it; a gift
carries its `message` only once it passed the filter (`message_state`), and `channel.gift.message_update`
says when the streamer let a held one through or refused it.
User authorization: OAuth 2 authorization code with PKCE (S256 required) at
`gamerfy.gg/autorizar`, exchanged at `POST /oauth2/token` for a `gfp_…` (4 h) and a rotating
`gfr_…` (60 days); scopes like `chat:read`, `gifts:read`. Event channel:
`wss://api.gamerfy.gg/v1/events`, the bots' gateway protocol with a `gfp_…` key — chat,
follows, subscriptions, gifts and title changes as they happen, with resume. Overlays for OBS are
plain web pages on that channel: the streamer's overlay key (`gfo_…`, read-only, kept after the `#` of
the page's address) and `identify` with `overlay: "alerts"` (or `chat`, `gift_qr`, `goal`, `poll`,
`gift_rank`) bring the saved look in `snapshot.settings`; `overlay.alert` plays what the streamer's
alert queue decided, and `overlay.settings` changes the look live.
Polls: with the streamer's `gfp_…` and `polls:manage`, `POST /v1/polls` (`broadcaster_id`, `title`,
`choices[{title}]`, `duration` 15–1800 s, optional `eligibility`) opens one on the air, `PATCH /v1/polls`
(`status: "TERMINATED"`) ends it, and `GET /v1/polls?broadcaster_id=` (`polls:read`) reads them —
`{ data: [...] }`, each item the same poll the `channel.poll.*` events carry; viewers vote at the top of
the chat, one vote per account.
Chat: with `chat:write`, `POST /v1/chat/messages` (`broadcaster_id`, `message` up to 500 chars) writes a line in
the live's chat as the app's own bot account (turn the bot on in the developer console; `409 app_has_no_bot`
otherwise), 20 lines per 30 s per app and chat; with `chat:moderate`,
`DELETE /v1/chat/messages?broadcaster_id=&message_id=` deletes one line of that chat (204). The app's lines come
back on the event channel as `channel.chat.message` with `badges: ["bot"]`.
Limits: 800 requests/min per app, 100 ids per call; replays kept 14 days.
OpenAPI 3.1: `https://api.gamerfy.gg/openapi.json`.
