Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

bcv-api

The official BCV exchange rate (USD/EUR), as JSON that is always up to date and never goes down with bcv.org.ve.

tests runtime cost

curl https://bcv-api.umbrabadge.workers.dev/
{
  "bcv_usd": 771.0714,
  "bcv_eur": 889.45399204,
  "fecha_valor": "2026-08-14",
  "scraped_at": "2026-08-14T19:39:24.515Z",
  "changed_at": "2026-08-14T19:18:25.182Z",
  "age_seconds": 133,
  "stale": false,
  "source": "https://www.bcv.org.ve/"
}

Full 8-decimal precision, exactly as BCV publishes it. ~200 ms globally. CORS open.


Why this exists

bcv.org.ve is a PHP 5.6 Drupal site that goes down for hours, serves a broken TLS chain, and has a page cache that will happily hand you an hour-old copy of the homepage. Scraping it naively gives you an API that is wrong or down exactly when it matters.

This is ~250 lines that deal with all of it. Every claim below was measured against the live site, not assumed.

What everyone assumes What's actually true
Poll more often → fresher data Their cache served 69-minute-old pages, alternating between two backends. Polling faster returns the same stale copy. You must bust the cache.
If-None-Match saves bandwidth Ignored. Every poll returns a full 200 + 151 KB.
There's a feed to subscribe to /rss.xml exists and is empty. No webhook, no API. Polling is the only option.
Their HTTPS is fine Incomplete cert chain — plain Node.js cannot connect at all.
http:// is a fallback if TLS breaks It 301s to https. Not an escape hatch.
The rate is "today's" fecha_valor is the next banking day. On Friday afternoon it jumps to Monday.

How it works

The cron writes; the request path only reads. That split is the whole design — BCV's downtime becomes "slightly stale", never an outage of your API.

flowchart LR
  CRON["Cron · every minute"] --> DEC{"pollDecision()"}
  DEC -->|"idle"| SKIP["skip · no fetch"]
  DEC -->|"burst / heartbeat"| BCV["bcv.org.ve<br/>?_= cache-buster"]
  BCV --> P["parse + validate"]
  P -->|"changed or heartbeat"| KV[("KV · last-known-good")]
  P -->|"changed"| HOOK["webhook POST<br/>HMAC-SHA256"]
  APP["your app"] --> FETCH["fetch handler"]
  FETCH -->|"read only"| KV
Loading

If a scrape fails or the page can't be parsed, nothing is written and the previous value keeps serving. There is deliberately no path that clears the rate — a stale number is a small pricing error, a missing one breaks checkout.


API

Endpoint Returns
/ Both currencies + freshness metadata
/usd USD only, dolarapi-compatible shape
/eur EUR only, same shape

Fields

Field Meaning
bcv_usd / bcv_eur Rate in Bs, 8 decimals, as published
fecha_valor The banking day the rate applies to. The real freshness signal
scraped_at Last successful read of BCV (advances on the heartbeat)
changed_at When a value last actually changed — use this to dedupe
age_seconds Age of scraped_at
stale true after 70 min with no successful scrape (~3 missed heartbeats)

Read fecha_valor, not the clock

BCV publishes in the afternoon (roughly 2–6 pm Caracas, often later) and the rate they publish is for the next banking day. So fecha_valor runs ahead of today's date after each publish, and on Friday afternoon it jumps to Monday.

stale: false means we are talking to BCV fine. It does not mean BCV has published today yet — that's what fecha_valor tells you.

Per-currency endpoints

/usd and /eur mirror the shape ve.dolarapi.com serves, so an existing dolarapi client can switch sources by changing a URL — no parser changes:

{
  "moneda": "USD",
  "fuente": "oficial",
  "promedio": 771.0714,
  "fechaActualizacion": "2026-08-14",
  "fecha_valor": "2026-08-14",
  "changed_at": "2026-08-14T19:18:25.182Z",
  "stale": false
}

promedio and fechaActualizacion are the compatibility contract — don't rename them. fuente: "oficial" marks this as the BCV rate; no parallel-market rate is ever served here, so unlike dolarapi's collection endpoints there is nothing to pick wrong.

Note: fechaActualizacion carries fecha_valor, not the scrape time — on a Monday morning that correctly reads "today" where a publish timestamp would read "last Friday" and look stale. It can be a future date after an afternoon publish, so clamp it before storing if your schema orders rows by effective date.


Freshness

A new rate appears within ~1–2 minutes of BCV publishing it.

Lag source Worst case
BCV's Drupal page cache eliminated by the ?_= cache-buster
Poll interval in the window + Cloudflare jitter (~30 s observed) ~1.5 min
KV read cacheTtl: 60 1 min
Our cache-control: max-age=60, if your client honors it 1 min

Two mitigations in scrape() are load-bearing — don't remove either:

  • cache: "no-store" stops Cloudflare caching the subrequest for BCV's advertised max-age=300.
  • ?_=${Date.now()} stops BCV's own cache from serving a stale page. This forces x-drupal-cache: MISS and a real origin render (~4.5 s vs ~1 s, which is why it lives on the cron path and never on the request path).

Adaptive polling

The cron ticks every minute, but pollDecision() decides whether to actually hit BCV — because we know exactly what we're waiting for:

  • In the publish window (12:00–22:00 Caracas, Mon–Fri) and today's rate not yet seen → poll every minute.
  • Otherwise → poll every 20 min as a liveness heartbeat.

"Not yet seen" is just fecha_valor <= today. After publishing, fecha_valor jumps to the next banking day, so it sorts after today and the burst stops itself. That one comparison also makes weekends and holidays self-handling: Friday's publish sets fecha_valor to Monday, so it idles all weekend with no calendar logic anywhere.

Result: ~350 fetches/day instead of 1440, minute-level detection exactly when it matters, entirely on the free tier.

KV is written only when a value actually changed or the heartbeat is due — ~75 writes/day against a free-tier limit of 1000/day. That write suppression is what makes minute-level polling affordable at all.

Why not poll every second?

  1. It wouldn't help. 24 polls of the plain URL over 6 minutes returned only two distinct cached copies (generated 17:53 and 18:05), alternating at random, neither ever refreshing. You cannot out-poll a stale cache.
  2. It would get you blocked. Every cache-busted request is a full PHP 5.6 render on a central bank's origin. 86,400/day is indistinguishable from a DoS, and Cloudflare's egress IPs are shared — a ban takes out everything.

Sub-minute would also need Durable Object alarms (Cloudflare's cron floor is one minute): Workers Paid plus ~$20/mo of duration billing, to track a number that changes once a day.


Webhook push

Detection is ~1–2 min, but if your app polls this API you add your own interval on top. Webhooks remove that — the moment a new rate is stored, it's POSTed to you.

printf '%s' "https://your.app/bcv-hook" | npx wrangler secret put WEBHOOK_URLS
printf '%s' "$(openssl rand -hex 32)"   | npx wrangler secret put WEBHOOK_SECRET

Comma-separate for multiple endpoints. Leave WEBHOOK_URLS unset and the feature is completely inert.

{
  "event": "rate.changed",
  "bcv_usd": 771.0714,
  "bcv_eur": 889.45399204,
  "fecha_valor": "2026-08-17",
  "changed_at": "2026-08-14T20:14:31.902Z"
}

Delivery guarantees

At-least-once, deduped by value. The internal marker only advances after every configured endpoint returns 2xx. If any endpoint fails, times out, or the fanout throws, the marker stays put and the next cron tick retries.

Verified against the live deployment — a failing endpoint retried every minute and never advanced the marker; a healthy one was delivered to exactly once and then went silent:

19:21:21 CRON | webhook failed: ... -> HTTP 404     ← retry
19:22:21 CRON | webhook failed: ... -> HTTP 404     ← retry
19:25:21 CRON | (delivered)                         ← marker advances
19:26:21 CRON | (silent — deduped, no resend)

Your receiver must be idempotent. A retry can redeliver a rate you already processed — dedupe on changed_at. Delivery fires on any change to bcv_usd, bcv_eur, or fecha_valor, so same-day corrections are delivered too.

Verifying the signature

With WEBHOOK_SECRET set, requests carry x-bcv-signature: sha256=<hex> — a standard HMAC-SHA256 over the raw body. Verify it: this is financial data, and an unauthenticated endpoint lets anyone who guesses the URL move your prices.

import crypto from "node:crypto";

app.post("/bcv-hook", express.raw({ type: "application/json" }), (req, res) => {
  const expected = "sha256=" + crypto
    .createHmac("sha256", process.env.WEBHOOK_SECRET)
    .update(req.body)          // the RAW bytes, before JSON.parse
    .digest("hex");
  const got = req.get("x-bcv-signature") || "";
  const a = Buffer.from(got), b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);

  const rate = JSON.parse(req.body);
  // ... idempotent handling, keyed on rate.changed_at
  res.sendStatus(200);         // non-2xx triggers a retry
});

Two things that will bite you: sign the raw body (re-serializing JSON changes the bytes), and return 2xx or you'll be retried forever.

Gotcha: a Worker cannot fetch a *.workers.dev hostname — the subrequest returns 404 even though the same URL returns 200 from curl. Reproduced twice for 8+ minutes while building this. If your receiver is another Worker, give it a custom domain or a service binding. Ordinary hosts are unaffected.


Deploy your own

Recommended if you depend on this — the public URL above is a personal free-tier deployment with no uptime guarantee.

npm install
npx wrangler kv namespace create RATES   # paste the printed id into wrangler.toml
npm run deploy

The first request after deploy scrapes inline to bootstrap the empty KV, so the API is live immediately instead of blank until the first cron fires. (Delete the KV key at any time and it self-heals on the next request — verified.)

The kv_namespaces.id in wrangler.toml is not a credential — it's inert without an API token — but it points at my namespace, so replace it.

Scripts

Command What it does
npm test 38 tests — parser, timezone, poll decisions, webhook delivery
npm run check Canary: runs the real scraper against the live site
npm run deploy Deploy to Cloudflare
npm run tail Live logs

Maintenance

Both failure modes surface as stale: true.

1. BCV changes their HTML. Run npm run check. If it fails, fix the regexes in src/index.js. Current selectors: <div id="dolar"> / <div id="euro"> → the <strong class="strong-tb"> inside; fecha_valor from span.date-display-single's content attribute. Values use Spanish number formatting (. thousands, , decimal) — the parser handles both.

The scraper refuses to write a partial parse, so a markup change freezes the last good value rather than publishing garbage.

2. Their TLS breaks. BCV serves an incomplete certificate chain: the leaf is issued by Sectigo Public Server Authentication CA DV R36, but the intermediate they send is the unrelated legacy Sectigo RSA Domain Validation Secure Server CA. Clients that do AIA fetching (curl, browsers) paper over it; plain Node.js refuses to connect.

That's why npm run check pins the real intermediate via NODE_EXTRA_CA_CERTS=scripts/sectigo-r36.pem. Cloudflare's edge was verified to tolerate the broken chain, so the Worker is unaffected — but that's a property of BCV's misconfiguration, not something to rely on. The leaf expires 2026-11-20, and BCV has let certs lapse for days before.

Watch failed cron runs in the Cloudflare dashboard, or npm run tail.


Español

La tasa oficial del BCV (USD/EUR) en JSON, siempre actualizada — y que no se cae cuando se cae bcv.org.ve.

curl https://bcv-api.umbrabadge.workers.dev/
curl -s https://bcv-api.umbrabadge.workers.dev/ | jq -r .bcv_usd

Por qué existe

La página del BCV se cae por horas, tiene un certificado TLS mal configurado, y su caché puede entregarte una copia de hace más de una hora. Si tu API consulta al BCV en el momento en que alguien te pregunta, sus caídas se vuelven tus caídas.

Aquí un cron consulta al BCV y guarda el último valor bueno en KV; la API solo lee de KV. Si el BCV falla, la respuesta se pone un poco vieja (stale: true) pero nunca falla.

Endpoints

Endpoint Devuelve
/ Ambas monedas + metadatos de frescura
/usd Solo dólar, formato compatible con dolarapi
/eur Solo euro, mismo formato

Lo más importante: fecha_valor

El BCV publica en la tarde (más o menos 2–6 pm, a veces más tarde) y la tasa que publica es para el siguiente día bancario. Por eso fecha_valor va por delante de la fecha de hoy: un viernes en la tarde salta directo al lunes.

No calcules la frescura con el reloj — compara fecha_valor.

Ojo con la diferencia:

  • stale: false significa estamos leyendo bien al BCV.
  • No significa que el BCV ya publicó la tasa de hoy. Eso lo dice fecha_valor.

Campos

Campo Significado
bcv_usd / bcv_eur Tasa en Bs, 8 decimales, tal cual la publica el BCV
fecha_valor El día bancario al que aplica la tasa
scraped_at Última lectura exitosa del BCV
changed_at Cuándo cambió el valor por última vez (úsalo para deduplicar)
stale true tras 70 min sin poder leer al BCV

¿Es instantáneo?

No, y no puede serlo: el BCV no ofrece webhook, ni API, ni feed (su /rss.xml está vacío) y además ignora las peticiones condicionales. Alguien tiene que consultar periódicamente.

Lo que sí se puede es consultar con cabeza. Durante la ventana de publicación consultamos cada minuto, y paramos solos apenas fecha_valor avanza. El resto del tiempo, cada 20 minutos. Una tasa nueva aparece en ~1–2 minutos.

Consultar cada segundo no ayudaría: en una prueba de 6 minutos, 24 consultas devolvieron solo dos copias distintas en caché (de 17:53 y 18:05), ninguna actualizada. No se le puede ganar a un caché viejo consultando más rápido — hay que romperlo (?_=). Y a ese ritmo el BCV terminaría bloqueándote.

Webhooks

Si no quieres consultar tú, configura un webhook y te llega un POST firmado con HMAC-SHA256 apenas cambia la tasa. Ver Webhook push.

⚠️ Esta tasa es la oficial del BCV. Aquí nunca se sirve tasa paralela.


License

Not currently licensed — open an issue if you'd like one added.

About

Always-current BCV (Venezuela) USD/EUR reference rates as JSON. Cloudflare Worker + KV, adaptive polling, signed webhook push.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages