# Express 4 with CurrencyCore

> A shared fetch helper, timeout, error middleware, and in-memory cache for a Node.js Express 4 route.

Canonical: https://currency-core.com/docs/guides/node-express/
Updated: 2026-11-11
Published: 2026-11-11

---
An Express route should not know how to call CurrencyCore. Put the base URL, key, timeout, and error translation in one helper, then let routes ask for a snapshot. Errors thrown by that helper reach a single middleware that returns the upstream status and the API error envelope unchanged.

The example targets Express 4 on Node.js 18 or later, which provides global `fetch` and `AbortSignal.timeout`. Free includes 1,000 requests per month at 60 per minute. Test keys use the same reference data and quota and are capped at 60 per minute. Read the key from `CURRENCYCORE_API_KEY`.

```js
// server.js

const BASE = "https://api.currency-core.com/v1";
const KEY = process.env.CURRENCYCORE_API_KEY;
if (!KEY) throw new Error("Set CURRENCYCORE_API_KEY");

class UpstreamError extends Error {
  constructor(status, code, message, details) {
    super(message);
    this.status = status;
    this.code = code;
    this.details = details;
  }
}

async function ccGet(path, params = {}) {
  const url = new URL(BASE + path);
  for (const [name, value] of Object.entries(params)) {
    if (value !== undefined && value !== "") {
      url.searchParams.set(name, String(value));
    }
  }

  let res;
  try {
    res = await fetch(url, {
      headers: { Authorization: `Bearer ${KEY}`, Accept: "application/json" },
      signal: AbortSignal.timeout(10_000),
    });
  } catch {
    throw new UpstreamError(502, "internal_error", "Upstream request failed");
  }

  let body;
  try {
    body = await res.json();
  } catch {
    throw new UpstreamError(502, "internal_error", `Invalid JSON (HTTP ${res.status})`);
  }

  if (!res.ok) {
    const err = body?.error ?? {};
    throw new UpstreamError(
      res.status,
      err.code ?? "internal_error",
      err.message ?? `HTTP ${res.status}`,
      err.details,
    );
  }
  return body;
}

const cache = new Map();
const today = () => new Date().toISOString().slice(0, 10);

async function getSnapshot(base, date) {
  const id = `${base}:${date || "latest"}`;
  const hit = cache.get(id);
  if (hit && hit.expiresAt > Date.now()) return hit.body;

  const body = await ccGet(`/rates/${encodeURIComponent(base)}`, date ? { date } : {});
  const ttl = date && date < today() ? 86_400_000 : 300_000;
  cache.set(id, { body, expiresAt: Date.now() + ttl });
  return body;
}

const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);

const app = express();

app.get("/api/rates/:base", wrap(async (req, res) => {
  const base = req.params.base.toUpperCase();
  const date = String(req.query.date ?? "");
  if (!/^[A-Z]{3}$/.test(base) || (date && !/^\d{4}-\d{2}-\d{2}$/.test(date))) {
    throw new UpstreamError(400, "invalid_input", "Invalid base or date");
  }
  res.json(await getSnapshot(base, date));
}));

app.use((err, req, res, next) => {
  if (!(err instanceof UpstreamError)) {
    console.error(err);
    return res.status(500).json({
      error: { code: "internal_error", message: "Unexpected server error" },
    });
  }
  const error = { code: err.code, message: err.message };
  if (err.details) error.details = err.details;
  res.status(err.status).json({ error });
});

app.listen(3000);
```

A successful call returns the dated map:

```json
{
  "base": "EUR",
  "date": "2026-11-10",
  "rates": { "EUR": 1, "USD": 1.0861, "INR": 91.62 }
}
```

A `403` for a date outside the Free 30-day window is forwarded as `403` with `error.code` set to `forbidden`, so the caller can branch without parsing prose.

Express 5 handles rejected promises from async handlers automatically and forwards them to error middleware. Express 4 does not, which is why the route uses `wrap`. A bare `async` handler that throws will otherwise leave the request hanging.

## Production notes

- The `Map` is per process. With several instances or a restart, each one keeps its own copy. Use Redis when the cache must be shared.
- Historical dates are stable for normal caching, but the `latest` key changes. Keep its TTL short.
- Bound the cache or use an LRU package. An unbounded map is a memory leak on a public route.
- Do not log the `Authorization` header or echo the key in error responses.

## Next steps

- [API reference](/docs/api/) and [authentication](/docs/authentication/)
- [Error codes](/docs/errors/) and [rate limits](/docs/rate-limits/)
- [Caching guide](/docs/guides/caching/) for dated snapshots
- [All developer guides](/docs/guides/)
