Documentação da API

OSNOdata API

Notícias, média transcrita e eventos, recolhidos e servidos por API, por pedido, em tempo real ou por webhook.

Introdução

A API do OSNOdata serve notícias, média transcrita (televisão, rádio, podcasts) e eventos, recolhidos de centenas de fontes.

Todos os pedidos vão para https://api.osnodata.com, respondem em JSON e levam a chave do cliente no cabeçalho Authorization. Há três formas de receber os dados, que se podem combinar:

  • Pedir: GET /v1/items para navegar ou sincronizar, e GET /v1/search para pesquisar.
  • Manter um stream aberto: GET /v1/stream envia cada item à medida que chega, por Server-Sent Events.
  • Receber em casa: um webhook recebe os itens novos num POST assinado para um endereço teu.

Um item é um article (notícia, publicação, email), um media (episódio ou peça de um programa) ou um event (evento com data e local). Cada item tem um id de 12 letras e algarismos e um seq, um número que cresce sempre que um item é guardado ou alterado e que serve para sincronizar sem perder nada.

Nos exemplos, a chave está na variável de ambiente OSNODATA_KEY:

export OSNODATA_KEY="osk_live_..."

Autenticação e scopes

Uma chave por cliente, criada no dashboard em API keys. O token completo só é mostrado uma vez, quando a chave é criada.

A chave (osk_live_...) vai em todos os pedidos como Authorization: Bearer <chave>. Uma chave pode expirar e ser revogada a qualquer momento; a partir daí os pedidos com ela respondem 401. As chaves só abrem os endpoints de dados em /v1: não servem para gerir a conta nem para criar outras chaves.

Cada chave tem os scopes que lhe foram dados:

Scopes

items:read
Itens, um item e a pesquisa.
sources:read
As fontes e o estado da recolha.
media:read
Transcrições, peças, ficheiros de média e clips.
stream:read
O stream de itens em tempo real (SSE).
webhooks:write
Criar e gerir webhooks que recebem os itens novos.

Um pedido sem o scope necessário responde 403 {"error": "insufficient_scope", "scope": "media:read"}. Para confirmar a configuração de um cliente, pergunta-se à API que chave é esta:

GET/v1/key

A chave que faz o pedido, os seus scopes e os seus limites. Não precisa de scope.

Pedido
curl "https://api.osnodata.com/v1/key" \
  -H "Authorization: Bearer $OSNODATA_KEY"
Resposta
{
  "id": 7,
  "name": "omelhorsite",
  "prefix": "osk_live_a1B2c3",
  "scopes": [
    "items:read",
    "stream:read"
  ],
  "status": "active",
  "last_used_at": "2026-09-29T10:14:58Z",
  "expires_at": null,
  "revoked_at": null,
  "created_at": "2026-09-01T09:00:00Z",
  "limits": {
    "requests_per_minute": 120,
    "requests_per_day": 50000
  },
  "owner": {
    "id": 1,
    "name": "Afonso"
  }
}

Limites de pedidos

Cada chave tem um limite por minuto e outro por dia (dia UTC). Por omissão, 120 pedidos por minuto e 50 000 por dia; o dono da chave pode baixá-los no dashboard, e só um admin os sobe.

Todos os pedidos com chave contam, incluindo os recusados. Cada resposta diz onde a chave está:

Cabeçalhos

X-RateLimit-Limit
Pedidos por minuto desta chave.
X-RateLimit-Remaining
Pedidos que restam neste minuto.
X-RateLimit-Reset
Quando o minuto recomeça (Unix, segundos).
X-RateLimit-Limit-Day
Pedidos por dia (UTC) desta chave.
X-RateLimit-Remaining-Day
Pedidos que restam hoje.
X-RateLimit-Reset-Day
Quando o dia recomeça (Unix, segundos).

Passado um limite, a API responde 429 com o cabeçalho Retry-After (em segundos) e diz qual das janelas se esgotou:

429 Too Many Requests
HTTP/1.1 429 Too Many Requests
Retry-After: 17
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1790676960

{
  "error": "rate_limited",
  "window": "minute",
  "retry_after": 17
}
Espera os segundos de Retry-After antes de repetir. Para ler muito, prefere o stream ou os webhooks a perguntar em ciclo: um stream aberto conta como um único pedido.

Erros

Um erro é sempre um JSON com error, às vezes com mais contexto.

Códigos

400 invalid_param
Um parâmetro com um valor inválido, nomeado em param: {"error": "invalid_param", "param": "published_after"}
401 invalid_api_key
Chave desconhecida, revogada ou expirada (ou nenhuma).
403 insufficient_scope
A chave não tem o scope, indicado em scope.
403 api_key_not_allowed
O endpoint não aceita chaves (gestão da conta).
404 not_found
O recurso não existe.
409 media_not_ready
A média do episódio ainda não foi descarregada (clips).
422 invalid
Dados inválidos ao criar ou alterar; os campos com problema vêm em details.
429 rate_limited
Limite de pedidos; também too_many_streams, too_many_open_clips e daily_clip_limit. Traz Retry-After.
503
Pesquisa semântica sem vectores disponíveis de momento.
422 Unprocessable Entity
{
  "error": "invalid",
  "details": {
    "url": [
      "Url tem de ser https"
    ]
  }
}

Uma resposta 5xx ou uma ligação cortada são temporárias (por exemplo durante uma actualização do servidor): repete com uma espera crescente.

Itens

GET /v1/items lê os itens de duas maneiras, com os mesmos filtros: a navegar (mais recentes primeiro) ou a sincronizar (por seq, mais antigos primeiro). Precisa de items:read.

Navegar com cursor

Sem since_seq, a resposta traz os itens mais recentes e um next_cursor. Para a página seguinte, repete o pedido com cursor=<next_cursor>; quando next_cursor é null, não há mais. O cursor é estável: os itens que chegam entretanto não deslocam as páginas.

GET/v1/itemsitems:read

Uma página de itens, mais recentes primeiro.

Parâmetros

sourcestring
Um id de fonte, ou vários separados por vírgulas.
kindstring
article, media, event (vírgulas para vários).
parentstring
Um id de item (os seus filhos, por exemplo as peças de um episódio), ou none para só os itens de topo.
qstring
Texto completo em português sobre o título e o conteúdo, com a sintaxe de pesquisa web: "frase exacta", -palavra, or. Até 500 caracteres.
published_after, published_beforestring
ISO 8601 (2026-09-28T10:00:00Z) ou uma data (2026-09-28 é o início desse dia em Lisboa).
starts_after, starts_beforestring
O mesmo, sobre o início dos eventos.
blocksstring
Os blocos de um minuto dos canais em directo ficam de fora: include junta-os, only responde só esses.
country, language, categorystring
O país (PT), a língua (pt) e a categoria (regional-news) da fonte do item; vírgulas para vários.
limitinteger
De 1 a 200, 50 por omissão.
cursorstring
O next_cursor da página anterior.
since_seqinteger
Sincronizar a partir deste seq (ver abaixo).
Pedido
curl "https://api.osnodata.com/v1/items?kind=article&q=lisboa&limit=20" \
  -H "Authorization: Bearer $OSNODATA_KEY"
Resposta
{
  "items": [
    {
      "id": "Q3W5k3hgVJCc",
      "seq": 1834,
      "kind": "article",
      "source_id": "M4Viu41U6RY7",
      "title": "Câmara de Lisboa aprova novo plano de mobilidade",
      "url": "https://24noticias.sapo.pt/...",
      "published_at": "2026-09-28T21:36:18.000Z"
    }
  ],
  "next_cursor": "1833"
}

Sincronizar com since_seq

Para manter uma cópia local, pede-se tudo o que foi guardado ou alterado depois de um seq, do mais antigo para o mais recente:

  • Começa com since_seq=0 (ou com o último seq que guardaste).
  • A resposta traz next_since_seq e has_more. Repete com since_seq=<next_since_seq> enquanto has_more for true.
  • Guarda o último next_since_seq e continua a partir dele da próxima vez. Um item alterado volta a aparecer com um seq novo: deduplica pelo id.
Pedido
curl "https://api.osnodata.com/v1/items?since_seq=1800&limit=200" \
  -H "Authorization: Bearer $OSNODATA_KEY"
Resposta
{
  "items": [
    {
      "id": "Q3W5k3hgVJCc",
      "seq": 1834,
      "kind": "article",
      "source_id": "M4Viu41U6RY7",
      "title": "Câmara de Lisboa aprova novo plano de mobilidade",
      "url": "https://24noticias.sapo.pt/...",
      "published_at": "2026-09-28T21:36:18.000Z"
    }
  ],
  "next_since_seq": 1834,
  "has_more": false
}
Os itens escritos nos últimos 5 segundos ficam para o pedido seguinte, para nenhum item ficar para trás de um seq que já passaste.

Filtros

Os filtros são os mesmos em /v1/items, /v1/search, /v1/stream e nos webhooks. Um valor inválido responde 400 invalid_param.

Parâmetros

sourcestring
Um id de fonte, ou vários separados por vírgulas.
kindstring
article, media, event (vírgulas para vários).
parentstring
Um id de item (os seus filhos, por exemplo as peças de um episódio), ou none para só os itens de topo.
qstring
Texto completo em português sobre o título e o conteúdo, com a sintaxe de pesquisa web: "frase exacta", -palavra, or. Até 500 caracteres.
published_after, published_beforestring
ISO 8601 (2026-09-28T10:00:00Z) ou uma data (2026-09-28 é o início desse dia em Lisboa).
starts_after, starts_beforestring
O mesmo, sobre o início dos eventos.
blocksstring
Os blocos de um minuto dos canais em directo ficam de fora: include junta-os, only responde só esses.
country, language, categorystring
O país (PT), a língua (pt) e a categoria (regional-news) da fonte do item; vírgulas para vários.

Os eventos trazem starts_at, ends_at e location; um evento conhecido só pela data ocupa o dia inteiro em Lisboa e diz "all_day": true em metadata. Num episódio de média, media diz onde está o processamento (status, downloaded, transcribed, segmented, duration_s, summary...) e o content é a transcrição em linhas [m:ss] texto; numa peça, media diz onde fica no episódio (start_s, end_s).

Um item

GET/v1/items/{id}items:read

Um item, com a mesma forma da lista.

Pedido
curl "https://api.osnodata.com/v1/items/Q3W5k3hgVJCc" \
  -H "Authorization: Bearer $OSNODATA_KEY"
Resposta
{
  "id": "Q3W5k3hgVJCc",
  "seq": 1834,
  "kind": "article",
  "source_id": "M4Viu41U6RY7",
  "parent_id": null,
  "external_id": "https://24noticias.sapo.pt/?p=674780",
  "title": "Câmara de Lisboa aprova novo plano de mobilidade",
  "content": "A Câmara Municipal de Lisboa aprovou esta segunda-feira...",
  "url": "https://24noticias.sapo.pt/...",
  "author": "David Pacheco",
  "language": "pt",
  "published_at": "2026-09-28T21:36:18.000Z",
  "fetched_at": "2026-09-28T21:40:02.114Z",
  "starts_at": null,
  "ends_at": null,
  "location": null,
  "metadata": {},
  "media": null,
  "created_at": "2026-09-28T21:40:02.114Z",
  "updated_at": "2026-09-28T21:40:02.114Z"
}

Pesquisa

Por palavras, por significado ou pelas duas, com o excerto que bateu e as palavras encontradas.

  • keyword: texto completo em português, com a sintaxe de pesquisa web. O score é o ts_rank_cd.
  • semantic: vectores bge-m3 (1024 dimensões), pela semelhança de cosseno com a pergunta. Encontra textos sobre o mesmo assunto sem as mesmas palavras. O score é a semelhança.
  • hybrid (por omissão): as duas listas fundidas por Reciprocal Rank Fusion. O score é o RRF, e cada resultado diz a posição que teve em cada lista (keyword.rank, semantic.rank).

Cada resultado traz um excerpt e, em highlights, as posições das palavras encontradas nesse excerto: pares [início, fim] em caracteres, com o fim exclusivo (excerpt.slice(início, fim)).

GET/v1/searchitems:read

Os resultados, melhores primeiro.

Parâmetros

qstring
Obrigatório. A pergunta, até 500 caracteres.
modestring
hybrid (por omissão), semantic ou keyword.
limitinteger
De 1 a 50, 20 por omissão.
offsetinteger
De 0 a 150, para as páginas seguintes.
source, kind, parent, ...
Os filtros de /v1/items.
Pedido
curl "https://api.osnodata.com/v1/search?q=greve%20dos%20professores&mode=hybrid&limit=10" \
  -H "Authorization: Bearer $OSNODATA_KEY"
Resposta
{
  "query": "greve dos professores",
  "mode": "hybrid",
  "degraded": null,
  "results": [
    {
      "score": 0.0325,
      "keyword": {
        "rank": 1,
        "score": 0.41
      },
      "semantic": {
        "rank": 2,
        "similarity": 0.71
      },
      "excerpt": "...os sindicatos convocaram uma greve dos professores para...",
      "highlights": [
        [
          32,
          37
        ],
        [
          42,
          53
        ]
      ],
      "item": {
        "id": "Q3W5k3hgVJCc",
        "seq": 1834,
        "kind": "article",
        "source_id": "M4Viu41U6RY7",
        "title": "Câmara de Lisboa aprova novo plano de mobilidade",
        "url": "https://24noticias.sapo.pt/...",
        "published_at": "2026-09-28T21:36:18.000Z"
      }
    }
  ]
}
Se os vectores não estiverem disponíveis, o modo híbrido responde só pelo texto e diz "degraded": "semantic_unavailable"; o modo semântico responde 503.

Fontes

De onde vêm os itens e como está a recolha de cada fonte. A configuração das fontes não é mostrada aos clientes.

health é unknown (nunca correu), ok, error (a última recolha falhou) ou failing (três ou mais falhas seguidas). mechanism é poll (pedida com intervalo), stream (canal contínuo) ou push (enviada para cá, como os emails).

GET/v1/sourcessources:read

Todas as fontes.

Parâmetros

kindstring
O tipo de fonte (rss, youtube, live_tv...).
enabledboolean
Só as ligadas, ou só as desligadas.
country, language, categorystring
O país (PT, ES), a língua (pt, en) e a categoria (regional-news, podcast-news...) da fonte; vírgulas para vários.
Pedido
curl "https://api.osnodata.com/v1/sources?enabled=true&country=PT" \
  -H "Authorization: Bearer $OSNODATA_KEY"
Resposta
{
  "sources": [
    {
      "id": "M4Viu41U6RY7",
      "kind": "rss",
      "mechanism": "poll",
      "item_kind": "article",
      "name": "SAPO 24",
      "country": "PT",
      "language": "pt",
      "category": "national-news",
      "enabled": true,
      "health": "ok",
      "poll_interval_minutes": 10,
      "last_run_at": "2026-09-29T10:10:00Z",
      "last_success_at": "2026-09-29T10:10:00Z",
      "created_at": "2026-09-01T09:00:00Z"
    }
  ]
}
GET/v1/sources/{id}sources:read

Uma fonte, com a mesma forma.

Pedido
curl "https://api.osnodata.com/v1/sources/M4Viu41U6RY7" \
  -H "Authorization: Bearer $OSNODATA_KEY"
Resposta
{
  "id": "M4Viu41U6RY7",
  "kind": "rss",
  "name": "SAPO 24",
  "enabled": true,
  "health": "ok",
  "...": "..."
}

Média

Os episódios de televisão, rádio e podcasts são descarregados, transcritos e partidos em peças (uma por notícia), que são itens filhos do episódio.

Um episódio passa por pending, downloading, downloaded, transcribing, transcribed, segmenting e done (ou failed); um programa em directo passa antes por recording e recorded. As peças encontram-se com GET /v1/items?parent=<id do episódio>.

GET/v1/items/{item_id}/mediamedia:read

A transcrição com tempos, as peças e URLs assinados do áudio, do vídeo e das imagens. Para uma peça, responde o do seu episódio, com a peça em piece.

Pedido
curl "https://api.osnodata.com/v1/items/VqXKLaXkkn8I/media" \
  -H "Authorization: Bearer $OSNODATA_KEY"
Resposta
{
  "item_id": "VqXKLaXkkn8I",
  "status": "done",
  "downloaded": true,
  "transcribed": true,
  "segmented": true,
  "duration_s": 3524.3,
  "summary": "O Jornal da Noite abriu com...",
  "transcript": [
    {
      "start": 0,
      "end": 4.5,
      "text": "Boa noite."
    }
  ],
  "pieces": [
    {
      "id": "Hs8dK2pQm1Zx",
      "title": "Greve dos professores",
      "summary": "...",
      "start_s": 75,
      "end_s": 120,
      "url": "https://..."
    }
  ],
  "audio": {
    "url": "https://minio.omelhorsite.pt/osnodata-archive/...",
    "content_type": "audio/ogg",
    "bytes": 21104512,
    "kept_until": "2026-10-02T21:00:00Z"
  },
  "video": {
    "url": "https://...",
    "content_type": "video/mp4",
    "bytes": 412000000,
    "width": 854,
    "height": 480,
    "kept_until": "2026-10-02T21:00:00Z"
  },
  "frames": [
    {
      "t": 0,
      "url": "https://..."
    }
  ],
  "urls_expire_at": "2026-09-29T10:30:00Z"
}
Os URLs são assinados e duram 15 minutos (urls_expire_at): pede outra vez para URLs novos. O áudio e o vídeo ficam alguns dias (kept_until); depois disso audio e video desaparecem da resposta, e a transcrição, as peças e as imagens ficam.

Clips

Um excerto de um episódio, cortado a pedido, em mp4 (ou áudio mp4 num programa só com áudio).

O corte corre em segundo plano, por isso o fluxo é assíncrono:

  • POST /v1/items/{item_id}/clip com start e end em segundos do episódio responde 202 com o clip em pending. Um pedido igual a um clip ainda guardado responde 200 com esse clip.
  • Numa peça, sem start nem end, o clip é a peça inteira: de 3 segundos antes do início até ao fim dela, cortada do episódio (ou dos blocos do canal).
  • Pergunta-se por ele em GET /v1/clips/{id} (a cada poucos segundos) até o status ser done, com o url assinado, ou failed, com o error.
  • O url dura 15 minutos; pede o clip outra vez para um novo. Os clips ficam guardados 7 dias.
Um clip dura entre 1 e 600 segundos, dentro do episódio (uma peça responde pelo seu episódio). No máximo 3 clips em curso e 100 por dia por chave (429 too_many_open_clips, daily_clip_limit). Se a média ainda não foi descarregada, a resposta é 409 media_not_ready.
POST/v1/items/{item_id}/clipmedia:read

Pede um clip entre dois instantes do episódio, ou uma peça inteira.

Corpo (JSON)

startnumber
Segundos do episódio, a partir de 0. Opcional numa peça.
endnumber
Segundos do episódio, depois de start. Opcional numa peça.
Pedido
curl -X POST "https://api.osnodata.com/v1/items/VqXKLaXkkn8I/clip" \
  -H "Authorization: Bearer $OSNODATA_KEY" \
  -H "Content-Type: application/json" \
  -d '{"start":75,"end":120}'
Resposta (202)
{
  "id": "c7Lq2VnX9aBd",
  "item_id": "VqXKLaXkkn8I",
  "piece_id": null,
  "start": 75,
  "end": 120,
  "status": "pending",
  "error": null,
  "url": null,
  "url_expires_at": null,
  "content_type": null,
  "bytes": null,
  "width": null,
  "height": null,
  "origin": null,
  "created_at": "2026-09-29T10:15:00Z",
  "finished_at": null,
  "expires_at": null
}
GET/v1/clips/{id}media:read

Um clip; quando está pronto, com o URL assinado.

Pedido
curl "https://api.osnodata.com/v1/clips/c7Lq2VnX9aBd" \
  -H "Authorization: Bearer $OSNODATA_KEY"
Resposta
{
  "id": "c7Lq2VnX9aBd",
  "item_id": "VqXKLaXkkn8I",
  "piece_id": null,
  "start": 75,
  "end": 120,
  "status": "done",
  "error": null,
  "url": "https://minio.omelhorsite.pt/osnodata-archive/clips/...",
  "url_expires_at": "2026-09-29T10:31:00Z",
  "content_type": "video/mp4",
  "bytes": 5210442,
  "width": 854,
  "height": 480,
  "origin": "video",
  "created_at": "2026-09-29T10:15:00Z",
  "finished_at": "2026-09-29T10:15:21Z",
  "expires_at": "2026-10-06T10:15:00Z"
}
GET/v1/clipsmedia:read

Os clips pedidos com esta chave, mais recentes primeiro.

Parâmetros

limitinteger
De 1 a 200, 50 por omissão.
Pedido
curl "https://api.osnodata.com/v1/clips?limit=20" \
  -H "Authorization: Bearer $OSNODATA_KEY"
Resposta
{
  "clips": [
    {
      "id": "c7Lq2VnX9aBd",
      "item_id": "VqXKLaXkkn8I",
      "piece_id": null,
      "start": 75,
      "end": 120,
      "status": "processing",
      "error": null,
      "url": null,
      "url_expires_at": null,
      "content_type": null,
      "bytes": null,
      "width": null,
      "height": null,
      "origin": null,
      "created_at": "2026-09-29T10:15:00Z",
      "finished_at": null,
      "expires_at": null
    }
  ]
}

Stream em tempo real

GET /v1/stream mantém uma ligação aberta e envia cada item à medida que é guardado ou alterado, por Server-Sent Events.

Cada item é um evento item.created ou item.updated cujo id é o seq do item e cujo data é o item, na mesma forma de /v1/items. O primeiro evento é ready, com o seq de partida; a cada 15 segundos sem itens chega um heartbeat com o seq a que o stream chegou.

GET/v1/streamstream:read

Um stream text/event-stream com os itens novos.

Parâmetros

Last-Event-IDcabeçalho
Retomar depois deste seq (o id do último evento recebido).
since_seqinteger
O mesmo, como parâmetro. Sem nenhum dos dois, começa no item mais recente.
source, kind, parent, q
Os filtros de /v1/items.
Pedido
curl "https://api.osnodata.com/v1/stream?kind=article" \
  -H "Authorization: Bearer $OSNODATA_KEY" \
  -N \
  -H "Accept: text/event-stream"
Resposta (text/event-stream)
retry: 3000
id: 1834
event: ready
data: {"seq":1834}

id: 1835
event: item.created
data: {"id":"Q3W5k3hgVJCc","seq":1835,"kind":"article", ...}

id: 1835
event: heartbeat
data: {"seq":1835,"at":"2026-09-29T10:15:00Z"}

Retomar e reconectar

  • Guarda o id do último evento. Ao voltar a ligar, manda-o em Last-Event-ID (ou em since_seq) e o stream continua daí sem perder nada, incluindo o que chegou enquanto estavas desligado. Os clientes EventSource fazem-no sozinhos.
  • O servidor fecha o stream ao fim de 30 minutos. É normal: volta a ligar ao fim de 3 segundos (o retry: 3000 do início) com o último id.
  • Se passarem mais de 45 segundos sem nenhum evento nem heartbeat, a ligação morreu: fecha-a e volta a ligar.
  • O EventSource dos browsers não envia cabeçalhos, e uma chave não deve ir parar a um browser: lê o stream num servidor, com fetch ou com uma biblioteca de SSE que aceite cabeçalhos.
TypeScript, sem dependências
// Node 18+ ou qualquer runtime com fetch; guarda lastSeq para retomar.
let lastSeq: string | undefined;

function handle(item: { id: string; seq: number; title: string | null }) {
  console.log(item.seq, item.title);
}

async function listen() {
  const response = await fetch("https://api.osnodata.com/v1/stream?kind=article", {
    headers: {
      Authorization: `Bearer ${process.env.OSNODATA_KEY}`,
      Accept: "text/event-stream",
      ...(lastSeq ? { "Last-Event-ID": lastSeq } : {}),
    },
  });
  if (!response.ok || !response.body) throw new Error(`stream ${response.status}`);

  const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = "";
  for (;;) {
    const { value, done } = await reader.read();
    if (done) return;
    buffer += value;
    let end: number;
    while ((end = buffer.indexOf("\n\n")) >= 0) {
      const block = buffer.slice(0, end);
      buffer = buffer.slice(end + 2);
      const field = (name: string) =>
        block.split("\n").find((line) => line.startsWith(`${name}: `))?.slice(name.length + 2);
      const event = field("event");
      if (field("id")) lastSeq = field("id");
      if (event === "item.created" || event === "item.updated")
        handle(JSON.parse(field("data")!));
    }
  }
}

// O servidor fecha ao fim de 30 minutos: volta-se a ligar, com o último seq.
for (;;) {
  try {
    await listen();
  } catch (error) {
    console.warn(error);
  }
  await new Promise((resolve) => setTimeout(resolve, 3000));
}

Limites

  • No máximo 2 streams abertos por chave; o terceiro responde 429 too_many_streams com Retry-After: 30.
  • O stream atravessa o Cloudflare, que corta ligações paradas: os heartbeats de 15 segundos mantêm-na viva. Não uses proxies que guardem a resposta em buffer.
  • Cada ligação conta como um pedido nos limites da chave; os bytes enviados contam no uso quando o stream fecha.

Webhooks

Um webhook recebe os itens novos num POST assinado para um endereço teu, sem teres de manter uma ligação aberta. Todos os endpoints precisam de webhooks:write.

Criar e gerir

Cada chave tem até 10 webhooks. Um webhook começa no item mais recente, ou em since_seq se o indicares, e escolhe os eventos e os filtros que quer (os mesmos de /v1/items, como texto). O endereço tem de ser https e público; os redireccionamentos não são seguidos.

POST/v1/webhookswebhooks:write

Cria um webhook. A resposta traz o secret que assina as entregas, uma única vez: guarda-o.

Corpo (JSON)

urlstring
Obrigatório. https.
descriptionstring
Até 200 caracteres.
eventsstring[]
item.created e/ou item.updated; por omissão, os dois.
filtersobject
source, kind, parent, q, published_after...
since_seqinteger
Começar depois deste seq.
Pedido
curl -X POST "https://api.osnodata.com/v1/webhooks" \
  -H "Authorization: Bearer $OSNODATA_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://exemplo.pt/osnodata","description":"Notícias de Lisboa","events":["item.created"],"filters":{"kind":"article","q":"lisboa"}}'
Resposta (201)
{
  "id": "wh3Kd9sLq2Xa",
  "url": "https://exemplo.pt/osnodata",
  "description": "Notícias de Lisboa",
  "events": [
    "item.created"
  ],
  "filters": {
    "kind": "article",
    "q": "lisboa"
  },
  "enabled": true,
  "last_seq": 1834,
  "consecutive_failures": 0,
  "disabled_at": null,
  "disabled_reason": null,
  "last_delivery_at": "2026-09-29T10:14:02Z",
  "last_success_at": "2026-09-29T10:14:02Z",
  "created_at": "2026-09-20T09:00:00Z",
  "updated_at": "2026-09-29T10:14:02Z",
  "secret": "whsec_9fK2..."
}
GET/v1/webhookswebhooks:write

Os webhooks desta chave.

Pedido
curl "https://api.osnodata.com/v1/webhooks" \
  -H "Authorization: Bearer $OSNODATA_KEY"
Resposta
{
  "webhooks": [
    {
      "id": "wh3Kd9sLq2Xa",
      "url": "https://exemplo.pt/osnodata",
      "description": "Notícias de Lisboa",
      "events": [
        "item.created",
        "item.updated"
      ],
      "filters": {
        "kind": "article",
        "q": "lisboa"
      },
      "enabled": true,
      "last_seq": 1834,
      "consecutive_failures": 0,
      "disabled_at": null,
      "disabled_reason": null,
      "last_delivery_at": "2026-09-29T10:14:02Z",
      "last_success_at": "2026-09-29T10:14:02Z",
      "created_at": "2026-09-20T09:00:00Z",
      "updated_at": "2026-09-29T10:14:02Z"
    }
  ]
}
GET/v1/webhooks/{id}webhooks:write

Um webhook. last_seq é o seq até onde as entregas foram aceites.

Pedido
curl "https://api.osnodata.com/v1/webhooks/wh3Kd9sLq2Xa" \
  -H "Authorization: Bearer $OSNODATA_KEY"
Resposta
{
  "id": "wh3Kd9sLq2Xa",
  "url": "https://exemplo.pt/osnodata",
  "description": "Notícias de Lisboa",
  "events": [
    "item.created",
    "item.updated"
  ],
  "filters": {
    "kind": "article",
    "q": "lisboa"
  },
  "enabled": true,
  "last_seq": 1834,
  "consecutive_failures": 0,
  "disabled_at": null,
  "disabled_reason": null,
  "last_delivery_at": "2026-09-29T10:14:02Z",
  "last_success_at": "2026-09-29T10:14:02Z",
  "created_at": "2026-09-20T09:00:00Z",
  "updated_at": "2026-09-29T10:14:02Z"
}
PATCH/v1/webhooks/{id}webhooks:write

Altera url, description, events, filters ou enabled. enabled: false pára as entregas; enabled: true reactiva um webhook desactivado a partir de onde parou.

Pedido
curl -X PATCH "https://api.osnodata.com/v1/webhooks/wh3Kd9sLq2Xa" \
  -H "Authorization: Bearer $OSNODATA_KEY" \
  -H "Content-Type: application/json" \
  -d '{"enabled":true}'
Resposta
{
  "id": "wh3Kd9sLq2Xa",
  "url": "https://exemplo.pt/osnodata",
  "description": "Notícias de Lisboa",
  "events": [
    "item.created",
    "item.updated"
  ],
  "filters": {
    "kind": "article",
    "q": "lisboa"
  },
  "enabled": true,
  "last_seq": 1834,
  "consecutive_failures": 0,
  "disabled_at": null,
  "disabled_reason": null,
  "last_delivery_at": "2026-09-29T10:14:02Z",
  "last_success_at": "2026-09-29T10:14:02Z",
  "created_at": "2026-09-20T09:00:00Z",
  "updated_at": "2026-09-29T10:14:02Z",
  "...": "..."
}
POST/v1/webhooks/{id}/rotate_secretwebhooks:write

Troca o segredo. O anterior deixa logo de assinar: actualiza o receptor antes.

Pedido
curl -X POST "https://api.osnodata.com/v1/webhooks/wh3Kd9sLq2Xa/rotate_secret" \
  -H "Authorization: Bearer $OSNODATA_KEY"
Resposta
{
  "id": "wh3Kd9sLq2Xa",
  "...": "...",
  "secret": "whsec_Xy7..."
}
DELETE/v1/webhooks/{id}webhooks:write

Apaga o webhook e o seu registo de entregas.

Pedido
curl -X DELETE "https://api.osnodata.com/v1/webhooks/wh3Kd9sLq2Xa" \
  -H "Authorization: Bearer $OSNODATA_KEY"
Resposta
HTTP/1.1 204 No Content

Entregas e payload

Cada entrega é um POST JSON com até 20 itens, pela ordem do seq, cada um com o seu event. O id do corpo é o da entrega; o next_since_seq é o seq até onde esta entrega chega.

Cabeçalhos

X-OSNOdata-Event
items ou ping.
X-OSNOdata-Delivery
O id da entrega.
X-OSNOdata-Webhook
O id do webhook.
X-OSNOdata-Signature
t=<unix>,v1=<hex>, ver abaixo.
User-Agent
OSNOdata-Webhooks/1
POST https://exemplo.pt/osnodata
Content-Type: application/json
X-OSNOdata-Event: items
X-OSNOdata-Delivery: dl8Pz1Qw4Rt6
X-OSNOdata-Webhook: wh3Kd9sLq2Xa
X-OSNOdata-Signature: t=1790676842,v1=5f1c0e...

{
  "id": "dl8Pz1Qw4Rt6",
  "type": "items",
  "webhook_id": "wh3Kd9sLq2Xa",
  "created_at": "2026-09-29T10:14:01Z",
  "next_since_seq": 1834,
  "items": [
    {
      "event": "item.created",
      "id": "Q3W5k3hgVJCc",
      "seq": 1834,
      "kind": "article",
      "source_id": "M4Viu41U6RY7",
      "parent_id": null,
      "external_id": "https://24noticias.sapo.pt/?p=674780",
      "title": "Câmara de Lisboa aprova novo plano de mobilidade",
      "content": "A Câmara Municipal de Lisboa aprovou esta segunda-feira...",
      "url": "https://24noticias.sapo.pt/...",
      "author": "David Pacheco",
      "language": "pt",
      "published_at": "2026-09-28T21:36:18.000Z",
      "fetched_at": "2026-09-28T21:40:02.114Z",
      "starts_at": null,
      "ends_at": null,
      "location": null,
      "metadata": {},
      "media": null,
      "created_at": "2026-09-28T21:40:02.114Z",
      "updated_at": "2026-09-28T21:40:02.114Z"
    }
  ]
}
Qualquer resposta 2xx é uma entrega aceite. Responde depressa (em menos de 10 segundos) e trata os itens depois. O mesmo item pode chegar mais de uma vez, quando muda ou numa repetição: deduplica pelo id do item.

Verificar a assinatura

v1 é o HMAC-SHA256, com o segredo do webhook, de <t>.<corpo>: o t do cabeçalho, um ponto e o corpo tal como chegou, byte a byte. Calcula-o, compara em tempo constante e recusa um t com mais de 5 minutos, para que uma entrega interceptada não possa ser repetida.

import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const SECRET = process.env.OSNODATA_WEBHOOK_SECRET!; // whsec_...
const TOLERANCE_S = 300;

export function verifySignature(rawBody: string, header: string, secret: string) {
  const parts = Object.fromEntries(
    header.split(",").map((part) => {
      const at = part.indexOf("=");
      return [part.slice(0, at), part.slice(at + 1)];
    }),
  );
  const t = parts.t ?? "";
  if (!/^\d+$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_S)
    return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  const given = Buffer.from(parts.v1 ?? "", "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}

// O corpo tem de ser lido tal como chegou, antes de qualquer JSON.parse.
createServer(async (request, response) => {
  const chunks: Buffer[] = [];
  for await (const chunk of request) chunks.push(chunk as Buffer);
  const rawBody = Buffer.concat(chunks).toString("utf8");
  const header = String(request.headers["x-osnodata-signature"] ?? "");

  if (!verifySignature(rawBody, header, SECRET)) {
    response.writeHead(401).end();
    return;
  }
  const delivery = JSON.parse(rawBody);
  for (const item of delivery.items ?? []) {
    // deduplicar pelo item.id: o mesmo item pode chegar mais de uma vez
  }
  response.writeHead(204).end();
}).listen(8080);

Repetições e desactivação

  • Uma resposta que não seja 2xx, ou 10 segundos sem resposta, é uma falha. A entrega é repetida ao fim de 30 s, 2 min, 10 min, 30 min, 1 h, 3 h e 6 h.
  • As entregas são em série: uma que falhou segura as seguintes, por isso os itens chegam por ordem e nada se perde.
  • Depois da 8.ª falha seguida, o webhook é desactivado (enabled: false, com disabled_at e disabled_reason) e o dono da chave é avisado por email. Corrigido o receptor, reactiva-o com PATCH {"enabled": true}: as entregas continuam de onde pararam.

Registo e ping

Cada entrega fica registada durante 30 dias, com o estado (pending, retrying, delivered, failed), as tentativas, o código e os primeiros 500 bytes da tua resposta.

GET/v1/webhooks/{id}/deliverieswebhooks:write

O registo de entregas, mais recentes primeiro.

Parâmetros

statusstring
Um ou mais, separados por vírgulas (failed,retrying).
limitinteger
De 1 a 200, 50 por omissão.
cursorstring
O next_cursor da página anterior.
Pedido
curl "https://api.osnodata.com/v1/webhooks/wh3Kd9sLq2Xa/deliveries?status=failed,retrying" \
  -H "Authorization: Bearer $OSNODATA_KEY"
Resposta
{
  "deliveries": [
    {
      "id": "dl8Pz1Qw4Rt6",
      "event": "items",
      "status": "retrying",
      "seq_from": 1830,
      "seq_to": 1834,
      "item_count": 3,
      "item_ids": [
        "Q3W5k3hgVJCc",
        "..."
      ],
      "attempts": 2,
      "response_status": 502,
      "response_body": "Bad Gateway",
      "error": null,
      "duration_ms": 182,
      "next_attempt_at": "2026-09-29T10:24:02Z",
      "last_attempt_at": "2026-09-29T10:14:02Z",
      "delivered_at": null,
      "created_at": "2026-09-29T10:14:01Z"
    }
  ],
  "next_cursor": null
}
POST/v1/webhooks/{id}/pingwebhooks:write

Envia já uma entrega ping assinada e responde como correu. Serve para testar o receptor: não mexe no seq do webhook nem conta como falha.

Pedido
curl -X POST "https://api.osnodata.com/v1/webhooks/wh3Kd9sLq2Xa/ping" \
  -H "Authorization: Bearer $OSNODATA_KEY"
Resposta
{
  "id": "dlP1ng0000Ab",
  "event": "ping",
  "status": "delivered",
  "seq_from": null,
  "seq_to": null,
  "item_count": 0,
  "item_ids": [],
  "attempts": 1,
  "response_status": 204,
  "response_body": "",
  "error": null,
  "duration_ms": 182,
  "next_attempt_at": null,
  "last_attempt_at": "2026-09-29T10:14:02Z",
  "delivered_at": "2026-09-29T10:14:02Z",
  "created_at": "2026-09-29T10:14:01Z"
}

Cliente TypeScript

Um cliente sem dependências vive no repositório, em clients/typescript/ (@osnodata/client). Usa só fetch e APIs web standard, por isso corre em Node 20+, Bun, Deno e Cloudflare Workers.

O pacote não está publicado no npm. Usa-se a partir de um clone do repositório, como dependência local, ou copiando o bundle gerado.
Instalar
# a partir de um clone do repositório do OSNOdata
bun add file:../osnodata/clients/typescript

# ou gerar um bundle ESM para copiar para outro projecto
cd clients/typescript && bun run build   # escreve dist/index.js

O pacote aponta para o código TypeScript (src/index.ts), o que serve directamente em Bun, Deno e bundlers como o do Next.js. Em Node sem passo de compilação, usa o dist/index.js do bun run build.

Usar
import { OSNOdata, OSNOdataError } from "@osnodata/client";

const api = new OSNOdata({ apiKey: process.env.OSNODATA_KEY! });

const page = await api.items.list({ kind: "article", q: "lisboa", limit: 20 });
console.log(api.lastRateLimit); // os cabeçalhos X-RateLimit-* do último pedido

// Sincronizar: guarda sync.lastSeq e retoma daí da próxima vez.
const sync = api.items.sync(lastSeq, { kind: "media" });
for await (const item of sync) await store(item);
lastSeq = sync.lastSeq;

// Pesquisa e clips
const results = await api.search("orçamento do estado", { mode: "hybrid" });
const pending = await api.items.clip(itemId, { start: 120, end: 150 }); // ou clip(pieceId), a peça inteira
const clip = await api.clips.wait(pending.id, { intervalMs: 2000, timeoutMs: 120_000 });

// Stream: volta a ligar sozinho com Last-Event-ID
for await (const event of api.stream({ filters: { kind: "article" } })) {
  if (event.event === "item.created") console.log(event.data.title);
}
  • items.iterate percorre as páginas com o cursor; items.sync sincroniza por seq.
  • Os erros da API são OSNOdataError, com status, code, param, retryAfter e rateLimit.
  • webhooks.create devolve o secret uma única vez, tal como a API.
Receber um webhook
import { parseWebhook } from "@osnodata/client";

// Verifica a assinatura e a idade (300 s) e devolve o corpo já lido.
const payload = await parseWebhook({
  secret: process.env.OSNODATA_WEBHOOK_SECRET!,
  body: await request.text(),
  header: request.headers.get("X-OSNOdata-Signature"),
});

O README.md de clients/typescript/ descreve todos os métodos e opções.

Especificação OpenAPI

Tudo o que está nesta página, em OpenAPI 3.1, para gerar clientes noutras linguagens ou importar no Postman, Insomnia ou Bruno.

A especificação descreve também o webhook de entrega (em webhooks.items) e os esquemas de cada resposta.