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/itemspara navegar ou sincronizar, eGET /v1/searchpara pesquisar. - Manter um stream aberto:
GET /v1/streamenvia 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:
/v1/keyA chave que faz o pedido, os seus scopes e os seus limites. Não precisa de scope.
curl "https://api.osnodata.com/v1/key" \
-H "Authorization: Bearer $OSNODATA_KEY"{
"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:
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
}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_clipsedaily_clip_limit. TrazRetry-After. 503- Pesquisa semântica sem vectores disponíveis de momento.
{
"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.
/v1/itemsitems:readUma página de itens, mais recentes primeiro.
Parâmetros
sourcestring- Um id de fonte, ou vários separados por vírgulas.
kindstringarticle,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
nonepara 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:
includejunta-os,onlyresponde 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_cursorda página anterior. since_seqinteger- Sincronizar a partir deste seq (ver abaixo).
curl "https://api.osnodata.com/v1/items?kind=article&q=lisboa&limit=20" \
-H "Authorization: Bearer $OSNODATA_KEY"{
"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_seqehas_more. Repete comsince_seq=<next_since_seq>enquantohas_morefortrue. - Guarda o último
next_since_seqe continua a partir dele da próxima vez. Um item alterado volta a aparecer com um seq novo: deduplica peloid.
curl "https://api.osnodata.com/v1/items?since_seq=1800&limit=200" \
-H "Authorization: Bearer $OSNODATA_KEY"{
"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
}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.
kindstringarticle,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
nonepara 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:
includejunta-os,onlyresponde 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
/v1/items/{id}items:readUm item, com a mesma forma da lista.
curl "https://api.osnodata.com/v1/items/Q3W5k3hgVJCc" \
-H "Authorization: Bearer $OSNODATA_KEY"{
"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. Oscoreé ots_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. Oscoreé a semelhança.hybrid(por omissão): as duas listas fundidas por Reciprocal Rank Fusion. Oscoreé 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)).
/v1/searchitems:readOs resultados, melhores primeiro.
Parâmetros
qstring- Obrigatório. A pergunta, até 500 caracteres.
modestringhybrid(por omissão),semanticoukeyword.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.
curl "https://api.osnodata.com/v1/search?q=greve%20dos%20professores&mode=hybrid&limit=10" \
-H "Authorization: Bearer $OSNODATA_KEY"{
"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"
}
}
]
}"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).
/v1/sourcessources:readTodas 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.
curl "https://api.osnodata.com/v1/sources?enabled=true&country=PT" \
-H "Authorization: Bearer $OSNODATA_KEY"{
"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"
}
]
}/v1/sources/{id}sources:readUma fonte, com a mesma forma.
curl "https://api.osnodata.com/v1/sources/M4Viu41U6RY7" \
-H "Authorization: Bearer $OSNODATA_KEY"{
"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>.
/v1/items/{item_id}/mediamedia:readA 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.
curl "https://api.osnodata.com/v1/items/VqXKLaXkkn8I/media" \
-H "Authorization: Bearer $OSNODATA_KEY"{
"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"
}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}/clipcomstarteendem segundos do episódio responde202com o clip empending. Um pedido igual a um clip ainda guardado responde200com esse clip.- Numa peça, sem
startnemend, 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é ostatusserdone, com ourlassinado, oufailed, com oerror. - O
urldura 15 minutos; pede o clip outra vez para um novo. Os clips ficam guardados 7 dias.
429 too_many_open_clips, daily_clip_limit). Se a média ainda não foi descarregada, a resposta é 409 media_not_ready./v1/items/{item_id}/clipmedia:readPede 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.
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}'{
"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
}/v1/clips/{id}media:readUm clip; quando está pronto, com o URL assinado.
curl "https://api.osnodata.com/v1/clips/c7Lq2VnX9aBd" \
-H "Authorization: Bearer $OSNODATA_KEY"{
"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"
}/v1/clipsmedia:readOs clips pedidos com esta chave, mais recentes primeiro.
Parâmetros
limitinteger- De 1 a 200, 50 por omissão.
curl "https://api.osnodata.com/v1/clips?limit=20" \
-H "Authorization: Bearer $OSNODATA_KEY"{
"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.
/v1/streamstream:readUm 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.
curl "https://api.osnodata.com/v1/stream?kind=article" \
-H "Authorization: Bearer $OSNODATA_KEY" \
-N \
-H "Accept: 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
iddo último evento. Ao voltar a ligar, manda-o emLast-Event-ID(ou emsince_seq) e o stream continua daí sem perder nada, incluindo o que chegou enquanto estavas desligado. Os clientesEventSourcefazem-no sozinhos. - O servidor fecha o stream ao fim de 30 minutos. É normal: volta a ligar ao fim de 3 segundos (o
retry: 3000do 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
EventSourcedos browsers não envia cabeçalhos, e uma chave não deve ir parar a um browser: lê o stream num servidor, comfetchou com uma biblioteca de SSE que aceite cabeçalhos.
// 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_streamscomRetry-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.
/v1/webhookswebhooks:writeCria 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.createde/ouitem.updated; por omissão, os dois.filtersobjectsource,kind,parent,q,published_after...since_seqinteger- Começar depois deste seq.
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"}}'{
"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..."
}/v1/webhookswebhooks:writeOs webhooks desta chave.
curl "https://api.osnodata.com/v1/webhooks" \
-H "Authorization: Bearer $OSNODATA_KEY"{
"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"
}
]
}/v1/webhooks/{id}webhooks:writeUm webhook. last_seq é o seq até onde as entregas foram aceites.
curl "https://api.osnodata.com/v1/webhooks/wh3Kd9sLq2Xa" \
-H "Authorization: Bearer $OSNODATA_KEY"{
"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"
}/v1/webhooks/{id}webhooks:writeAltera url, description, events, filters ou enabled. enabled: false pára as entregas; enabled: true reactiva um webhook desactivado a partir de onde parou.
curl -X PATCH "https://api.osnodata.com/v1/webhooks/wh3Kd9sLq2Xa" \
-H "Authorization: Bearer $OSNODATA_KEY" \
-H "Content-Type: application/json" \
-d '{"enabled":true}'{
"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",
"...": "..."
}/v1/webhooks/{id}/rotate_secretwebhooks:writeTroca o segredo. O anterior deixa logo de assinar: actualiza o receptor antes.
curl -X POST "https://api.osnodata.com/v1/webhooks/wh3Kd9sLq2Xa/rotate_secret" \
-H "Authorization: Bearer $OSNODATA_KEY"{
"id": "wh3Kd9sLq2Xa",
"...": "...",
"secret": "whsec_Xy7..."
}/v1/webhooks/{id}webhooks:writeApaga o webhook e o seu registo de entregas.
curl -X DELETE "https://api.osnodata.com/v1/webhooks/wh3Kd9sLq2Xa" \
-H "Authorization: Bearer $OSNODATA_KEY"HTTP/1.1 204 No ContentEntregas 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-Eventitemsouping.X-OSNOdata-Delivery- O id da entrega.
X-OSNOdata-Webhook- O id do webhook.
X-OSNOdata-Signaturet=<unix>,v1=<hex>, ver abaixo.User-AgentOSNOdata-Webhooks/1
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"
}
]
}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, comdisabled_atedisabled_reason) e o dono da chave é avisado por email. Corrigido o receptor, reactiva-o comPATCH {"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.
/v1/webhooks/{id}/deliverieswebhooks:writeO 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_cursorda página anterior.
curl "https://api.osnodata.com/v1/webhooks/wh3Kd9sLq2Xa/deliveries?status=failed,retrying" \
-H "Authorization: Bearer $OSNODATA_KEY"{
"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
}/v1/webhooks/{id}/pingwebhooks:writeEnvia 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.
curl -X POST "https://api.osnodata.com/v1/webhooks/wh3Kd9sLq2Xa/ping" \
-H "Authorization: Bearer $OSNODATA_KEY"{
"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.
# 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.jsO 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.
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.iteratepercorre as páginas com o cursor;items.syncsincroniza por seq.- Os erros da API são
OSNOdataError, comstatus,code,param,retryAftererateLimit. webhooks.createdevolve osecretuma única vez, tal como a API.
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.