API pública do Gamerfy
English summary (for the pubg.report team). Base URL:
https://api.gamerfy.gg. Create an app in the desktop app (Configurações → Desenvolvedor) to get aclient_idand aclient_secret(shown once).POST /oauth2/tokenwithgrant_type=client_credentialsreturns agfa_…bearer token valid for 60 days; send it asAuthorization: Bearer …. Four endpoints, all Twitch-shaped ({ data: [...], pagination: { cursor } },snake_case, ISO 8601 UTC dates):GET /v1/users(byidorlogin),GET /v1/streams(personal lives on air),GET /v1/videos(replays,type=archive) andGET /v1/games. Every replay has a public page:https://gamerfy.gg/videos/{id}?t=1h2m3s(sametas Twitch, plain seconds also accepted). To link a match event to the replay:offset = event_time_utc − video.created_at, with no lead correction —created_atis the wall clock of the first frame of the recording, millisecond precision. Limits: 800 requests per minute per app, up to 100 ids per call. Replays are kept for 14 days.
Este documento é o guia da API pública: como criar um app, obter o token e
consultar usuários, lives, replays e jogos. O primeiro consumidor é o
pubg.report, que hoje cruza a telemetria das partidas de PUBG com os VODs da
Twitch; a API foi desenhada para que o adaptador dele seja quase o mesmo. A
spec da rodada está em
docs/superpowers/specs/2026-09-08-replays-public-api-apps-design.md.
1. Endereços
| Ambiente | Base | Observação |
|---|---|---|
| Produção e test | https://api.gamerfy.gg |
vhost do Caddy que reescreve /{uri} para /api/public{uri} no backend |
| Desenvolvimento local | http://localhost:4500/api/public |
o mesmo backend, sem o vhost |
Os exemplos abaixo usam a base de produção. Em dev, troque a base e mantenha o resto do caminho.
2. Criar um app
- No app desktop, abra Configurações → Desenvolvedor.
- Clique em Criar app e dê um nome (até 60 caracteres).
- Copie o client secret na hora. Ele é mostrado uma única vez; o backend 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.
- O client id fica visível na lista e pode ser copiado quando quiser.
| Credencial | Formato | Onde vive |
|---|---|---|
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. Os apps pertencem a um usuário do Gamerfy; não há OAuth de usuário nesta versão (ver §9).
3. Obter o token
POST /oauth2/token com grant_type=client_credentials. O corpo pode ser
JSON ou application/x-www-form-urlencoded (o formato que a Twitch aceita).
JSON:
curl -X POST https://api.gamerfy.gg/oauth2/token \
-H 'Content-Type: application/json' \
-d '{"client_id":"abc123…","client_secret":"gfs_…","grant_type":"client_credentials"}'
Form-urlencoded:
curl -X POST https://api.gamerfy.gg/oauth2/token \
-d 'client_id=abc123…' \
-d 'client_secret=gfs_…' \
-d 'grant_type=client_credentials'
Resposta:
{
"access_token": "gfa_4kQ9…",
"expires_in": 5184000,
"token_type": "bearer"
}
expires_in é em segundos (60 dias). O token é opaco: não há nada para
decodificar dentro dele. Guarde e reutilize até expirar; pedir um token novo a
cada chamada é desperdício e conta no limite.
Regras:
client_idouclient_secreterrados:401 invalid_client, com a mesma mensagem nos dois casos.grant_typediferente declient_credentials:400 unsupported_grant_type.- Corpo malformado ou campo faltando (inclusive
grant_typeausente):400 invalid_body.
4. Validar o token
GET /oauth2/validate diz de quem é o token e quanto tempo falta.
curl https://api.gamerfy.gg/oauth2/validate \
-H 'Authorization: Bearer gfa_4kQ9…'
{
"client_id": "abc123…",
"app_name": "pubg.report",
"expires_in": 5180000
}
Token ausente, com formato errado, desconhecido ou expirado: 401 invalid_token.
5. Formato geral
Todas as rotas /v1/* exigem Authorization: Bearer gfa_….
- Resposta de lista:
{ "data": [ … ], "pagination": { "cursor": "…" } }nas rotas paginadas (/v1/streams,/v1/videos);paginationvem vazio ({}) quando não há mais páginas./v1/userse/v1/gamesrespondem só{ "data": [ … ] }. - 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 caibam em
Numberdo JavaScript sem perda). - Parâmetros repetíveis aceitam
?id=1&id=2(até 100 por chamada; acima disso400 invalid_query). firsté o tamanho da página (1 a 100; padrão 20) eafteré o cursor da página seguinte (ver §7).- Qualquer parâmetro rejeitado é
400 invalid_query, comdetailslistando{ path, message }por campo.
6. Endpoints
6.1 GET /v1/users
Usuários por id ou por login (o username do Gamerfy, sem @). Pelo menos um
dos dois; os dois juntos somam até 100.
| Parâmetro | Tipo | Observação |
|---|---|---|
id |
string, repetível | id do usuário |
login |
string, repetível | username, comparação sem distinguir maiúsculas |
curl 'https://api.gamerfy.gg/v1/users?login=henrique&login=fulano' \
-H 'Authorization: Bearer gfa_4kQ9…'
{
"data": [
{
"id": "1834021894356992001",
"login": "henrique",
"display_name": "Henrique",
"profile_image_url": "https://files.gamerfy.gg/avatars/…",
"created_at": "2026-09-01T12:00:00.000Z",
"links": [
{
"game_id": "pubg",
"account_id": "account.c0e530e9b7244b358def282782f893de",
"account_name": "HenriqueTJ"
}
]
}
]
}
links traz as contas de jogo que o usuário vinculou e deixou visíveis no
perfil. Para o PUBG, account_id é o accountId da API oficial da KRAFTON
(account.…) e account_name é o nome do jogador na época do vínculo. É o
campo que dispensa o pubg.report de adivinhar o jogador pelo nome do canal:
o usuário já disse qual conta é a dele. Usuário sem vínculo visível vem com
links: []. Um id ou login que não existe simplesmente não aparece em data.
A lista sai da conta mais antiga para a mais nova; um id que não é numérico
é 400 invalid_query. profile_image_url é null para quem não tem foto.
6.2 GET /v1/streams
Lives pessoais no ar agora. Sem filtro, lista todas, das mais recentes para as mais antigas.
| Parâmetro | Tipo | Observação |
|---|---|---|
user_id |
string, repetível | até 100 |
user_login |
string, repetível | até 100 |
game_id |
string, repetível | slug do jogo (pubg, cs2, …) |
first |
int | 1 a 100, padrão 20 |
after |
string | cursor |
curl 'https://api.gamerfy.gg/v1/streams?game_id=pubg&first=50' \
-H 'Authorization: Bearer gfa_4kQ9…'
{
"data": [
{
"id": "1834100000000000042",
"user_id": "1834021894356992001",
"user_login": "henrique",
"user_name": "Henrique",
"game_id": "pubg",
"title": "Ranked de terça",
"viewer_count": 37,
"started_at": "2026-09-08T21:30:00.123Z",
"type": "live",
"recording_id": "1834100000000000042"
}
],
"pagination": { "cursor": "eyJzdGFydGVkX2F0Ijoi…" }
}
recording_id é o id que a gravação em curso vai ter quando virar replay em
/v1/videos: é o mesmo id da stream. Quando a gravação não está ligada
(conta sem gravação, ou storage desligada no ambiente), recording_id é
null e id é "{user_id}-{epoch em segundos de started_at}".
6.3 GET /v1/videos
Replays das lives pessoais. Só saem os ready, públicos e dentro da
retenção (14 dias). Pelo menos um filtro entre id, user_id e
game_id; mais de um restringe (E, não OU).
| Parâmetro | Tipo | Observação |
|---|---|---|
id |
string, repetível | até 100 |
user_id |
string | um só |
game_id |
string | slug do jogo |
type |
string | archive (padrão) ou all, que hoje significa o mesmo; qualquer outro valor é 400 invalid_query |
first |
int | 1 a 100, padrão 20 |
after |
string | cursor |
curl 'https://api.gamerfy.gg/v1/videos?user_id=1834021894356992001&type=archive' \
-H 'Authorization: Bearer gfa_4kQ9…'
{
"data": [
{
"id": "1834100000000000042",
"user_id": "1834021894356992001",
"user_login": "henrique",
"user_name": "Henrique",
"title": "Ranked de terça",
"created_at": "2026-09-08T21:30:00.123Z",
"published_at": "2026-09-08T23:44:09.870Z",
"duration": "2h14m9s",
"duration_seconds": 8049,
"url": "https://gamerfy.gg/videos/1834100000000000042",
"game_id": "pubg",
"type": "archive",
"viewable": "public",
"thumbnail_url": null
}
],
"pagination": { "cursor": "eyJzdGFydGVkX2F0Ijoi…" }
}
| Campo | O que é |
|---|---|
created_at |
o relógio de parede do primeiro quadro gravado (EXT-X-PROGRAM-DATE-TIME da trilha de vídeo), em UTC com milissegundos. É a âncora do offset (§8) |
published_at |
quando a live terminou (ended_at); é created_at + duration a menos da latência do fechamento |
duration |
no formato da Twitch: 1h2m3s, 12m5s, 45s |
duration_seconds |
a mesma duração em segundos inteiros |
url |
a página pública do replay; aceita ?t= (§8) |
viewable |
sempre public (replays privados não saem na API) |
thumbnail_url |
sempre null nesta versão (dívida) |
Ordem: created_at decrescente, desempate por id decrescente. Um replay
truncado por falta de disco ainda aparece; a duração é a do que foi gravado.
6.4 GET /v1/games
Os jogos que o Gamerfy conhece, com os ids da Twitch e da Steam para cruzar catálogos. Sem parâmetros, lista todos.
| Parâmetro | Tipo | Observação |
|---|---|---|
id |
string, repetível | slug (pubg) |
name |
string, repetível | nome exato |
curl 'https://api.gamerfy.gg/v1/games?id=pubg' \
-H 'Authorization: Bearer gfa_4kQ9…'
{
"data": [
{
"id": "pubg",
"name": "PUBG: Battlegrounds",
"twitch_category_id": "493057",
"steam_app_id": 578080
}
]
}
game_id nas outras rotas é sempre esse id (o slug), nunca o número da
Twitch. id e name não distinguem maiúsculas; os dois juntos somam (OU).
7. Paginação
/v1/streams e /v1/videos paginam por keyset. A resposta traz
pagination.cursor enquanto houver mais; repita a chamada com os mesmos
filtros e after=<cursor>.
curl 'https://api.gamerfy.gg/v1/videos?game_id=pubg&first=100&after=eyJzdGFydGVkX2F0Ijoi…' \
-H 'Authorization: Bearer gfa_4kQ9…'
O cursor é opaco (base64url de started_at|id): não monte nem edite. Um
cursor corrompido ou de outra rota é 400 invalid_cursor. Não existe
before: a lista é sempre do mais novo para o mais velho.
Uma página que veio cheia (data.length === first) 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.
/v1/users e /v1/games não paginam e não trazem pagination.
8. Ligar um evento da partida ao replay
É o caso do pubg.report: a telemetria diz que uma morte aconteceu em
event_time_utc; 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 nem
a hora do webhook. É o EXT-X-PROGRAM-DATE-TIME do primeiro segmento gravado,
o relógio do próprio servidor de mídia no momento do 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, e o relógio da máquina do jogador, que a
telemetria do PUBG não usa (o _D vem do servidor do jogo). 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 (gravação ligada só quando a
stream apareceu) ou ter sido truncado.
A página https://gamerfy.gg/videos/{id}?t=… aceita 1h2m3s, 2m3s,
45s e 45 (segundos). Um t inválido abre o vídeo do começo. Um replay
apagado, privado ou expirado responde "esse replay não está mais
disponível".
9. O que o pubg.report faz na Twitch e o equivalente aqui
| Na Twitch (Helix) | No Gamerfy | Diferença |
|---|---|---|
POST id.twitch.tv/oauth2/token (client credentials) |
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 |
Player embed ?t=1h2m3s |
gamerfy.gg/videos/{id}?t=1h2m3s |
mesmo formato de t; sem embed por iframe ainda |
Ratelimit-Limit/Remaining/Reset (800/min) |
os mesmos três cabeçalhos, 800/min por app | janela fixa de 60 s |
| Clips, EventSub, OAuth de usuário | não existem | ver §11 |
10. Erros e limites
Todo erro tem o formato do backend:
{ "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 (invalid_body, invalid_query), como uma lista de
{ "path": "first", "message": "…" }.
| Status | error |
Quando |
|---|---|---|
| 400 | invalid_body |
corpo de POST /oauth2/token malformado ou com campo faltando |
| 400 | invalid_query |
parâmetro de consulta rejeitado: nenhum filtro, mais de 100 valores, first fora de 1..100, type desconhecido, id não numérico; details diz qual |
| 400 | unsupported_grant_type |
grant_type diferente de client_credentials |
| 400 | invalid_cursor |
after corrompido ou de outra rota |
| 401 | invalid_client |
client_id ou client_secret errados (mesma mensagem nos dois casos) |
| 401 | invalid_token |
bearer ausente, com formato errado, desconhecido ou expirado |
| 404 | not_found |
rota que não existe |
| 413 | payload_too_large |
corpo acima de 64 KB (o vhost corta antes) |
| 429 | rate_limited |
mais de 800 pedidos no minuto |
| 503 | (página de manutenção) | durante o deploy; Retry-After: 600 |
Rate limit: 800 pedidos por minuto por app, contados pelo token, em janela fixa. Cada resposta das rotas autenticadas traz:
| Cabeçalho | Significado |
|---|---|
Ratelimit-Limit |
800 |
Ratelimit-Remaining |
quantos ainda cabem nesta janela |
Ratelimit-Reset |
segundos até a janela virar |
Ao receber 429, espere Ratelimit-Reset segundos. Os tokens de um mesmo app
compartilham o limite; criar vários apps para multiplicar o limite é o motivo
do teto de 10 apps por conta.
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. Não há como pedir para guardar mais tempo nesta versão.
11. O que ainda não existe
- Clipes (
helix/clips): nenhuma rota; o replay inteiro é a unidade. - Webhooks / EventSub: não há notificação de "live começou" ou "replay
pronto"; é preciso consultar
/v1/streamse/v1/videos. - OAuth de usuário (autorizar um app em nome de alguém): os apps só têm
client credentials.
redirect_urisjá existe na tabela, vazio, para quando os plugins precisarem. - Thumbnails:
thumbnail_urlé semprenull. - Lives de canal de servidor: só lives pessoais entram em
/v1/streamse são gravadas. - Embed por iframe da página de replay.
beforena paginação e busca por título.