The official BCV exchange rate (USD/EUR), as JSON that is always up to date and never goes down with bcv.org.ve.
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.
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. |
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
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.
| Endpoint | Returns |
|---|---|
/ |
Both currencies + freshness metadata |
/usd |
USD only, dolarapi-compatible shape |
/eur |
EUR only, same shape |
| 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) |
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.
/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:
fechaActualizacioncarriesfecha_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.
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 advertisedmax-age=300.?_=${Date.now()}stops BCV's own cache from serving a stale page. This forcesx-drupal-cache: MISSand 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).
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.
- 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.
- 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.
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_SECRETComma-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"
}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.
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.devhostname — 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.
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 deployThe 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.
| 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 |
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.
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_usdLa 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.
| Endpoint | Devuelve |
|---|---|
/ |
Ambas monedas + metadatos de frescura |
/usd |
Solo dólar, formato compatible con dolarapi |
/eur |
Solo euro, mismo formato |
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: falsesignifica estamos leyendo bien al BCV.- No significa que el BCV ya publicó la tasa de hoy. Eso lo dice
fecha_valor.
| 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 |
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.
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.
Not currently licensed — open an issue if you'd like one added.