Integrations · Express

IPAPI.GR for Express

A small middleware adds req.geo to every request: country, region, city, timezone, ASN and ISP, plus the risk flags on paid plans. Answers are cached in memory, lookups time out after 1.5 seconds, and errors never block a visitor.

Written for
Express 4 and 5, Node.js 18+
Runs in
App middleware
Client IP from
req.ip with trust proxy

01 · Setup

3 steps, copy and paste.

Every lookup runs on your server or edge, so the key never reaches a browser. Each example times out quickly, caches answers per IP and lets the visitor through if the lookup fails.

  1. Set the key

    Read it from the environment so it stays out of source control. Node.js 18 and later have fetch built in, so there is nothing to install.

    ShellShell
    # Keep the key out of source control
    export IPAPI_KEY="your_api_key_here"
    node app.js
  2. Add the middleware

    It skips private and loopback addresses, caches up to 10.000 answers for an hour, and converts "No Data" to null.

    ipapi.jsJavaScript
    // ipapi.js: Express middleware that adds req.geo for the visitor's IP.
    // Node.js 18+ (global fetch). Set IPAPI_KEY in the environment.
    const { BlockList, isIP } = require('node:net');
    
    const LOOKUP = 'https://ipapi.gr/api/lookup/';
    const TTL_MS = 60 * 60 * 1000; // keep answers for an hour
    const MAX_ENTRIES = 10000;
    const cache = new Map();
    
    // Private, loopback and link-local addresses have no public location: skip them.
    const local = new BlockList();
    for (const [net, bits] of [['10.0.0.0', 8], ['172.16.0.0', 12], ['192.168.0.0', 16], ['127.0.0.0', 8], ['169.254.0.0', 16], ['100.64.0.0', 10]]) local.addSubnet(net, bits, 'ipv4');
    for (const [net, bits] of [['::1', 128], ['fc00::', 7], ['fe80::', 10]]) local.addSubnet(net, bits, 'ipv6');
    
    // Missing values come back as the string "No Data".
    const known = (v) => (v === undefined || v === null || v === '' || v === 'No Data' ? null : v);
    
    async function lookup(ip) {
      const hit = cache.get(ip);
      if (hit && hit.expires > Date.now()) return hit.data;
    
      const url = LOOKUP + encodeURIComponent(ip) + '?key=' + encodeURIComponent(process.env.IPAPI_KEY);
      const res = await fetch(url, { signal: AbortSignal.timeout(1500) });
      if (!res.ok) return null; // 4xx/5xx: carry on without data
      const body = await res.json();
    
      const data = {
        ip: body.ip,
        country: known(body.country),
        countryCode: known(body.country_code),
        region: known(body.region),
        city: known(body.city),
        timezone: known(body.timezone),
        asn: known(body.asn),
        isp: known(body.isp),
        risk: body.risk || null // { is_ai, is_crawler, is_scanner } on paid plans only
      };
      if (cache.size >= MAX_ENTRIES) cache.delete(cache.keys().next().value); // drop the oldest
      cache.set(ip, { data, expires: Date.now() + TTL_MS });
      return data;
    }
    
    function ipapiGeo() {
      return async (req, res, next) => {
        const ip = String(req.ip || '').replace(/^::ffff:/, ''); // IPv4-mapped IPv6 to plain IPv4
        const family = isIP(ip);
        req.geo = null;
        if (family && !local.check(ip, family === 6 ? 'ipv6' : 'ipv4')) {
          try {
            req.geo = await lookup(ip);
          } catch {
            // Timeout or network error: fail open, never block a visitor because of a lookup.
          }
        }
        next();
      };
    }
    
    module.exports = { ipapiGeo, lookup };
  3. Use it in your app

    Set trust proxy to match your setup so req.ip is the visitor and not your load balancer. The second middleware shows the risk-flag pattern for paid plans.

    app.jsJavaScript
    // app.js
    const express = require('express');
    const { ipapiGeo } = require('./ipapi');
    
    const app = express();
    app.set('trust proxy', 1); // exactly one reverse proxy (e.g. nginx) in front of the app
    app.use(ipapiGeo());
    
    // Paid plans: refuse requests that claim to be an AI crawler but don't come from a known AI crawler address.
    const AI_CLAIM = /GPTBot|OAI-SearchBot|ChatGPT-User|ClaudeBot|Claude-SearchBot|Claude-User|PerplexityBot|Perplexity-User/i;
    app.use((req, res, next) => {
      const risk = req.geo && req.geo.risk;
      if (risk && AI_CLAIM.test(req.get('user-agent') || '') && !risk.is_ai) {
        return res.status(403).send('Unverified crawler');
      }
      next();
    });
    
    app.get('/', (req, res) => {
      res.json({ country: req.geo ? req.geo.countryCode : null, timezone: req.geo ? req.geo.timezone : null });
    });
    
    app.listen(process.env.PORT || 3000);

02 · The response

What you get back.

Free keys return location and network data: country, country_code, region, city, zipcode, latitude, longitude, timezone, isp, organization and asn.

Keys on any paid plan add risk.is_ai, risk.is_crawler and risk.is_scanner, which tell you when an address belongs to a known AI crawler, search crawler or internet scanner. The examples use is_ai to refuse requests whose user agent claims an AI crawler while the address isn't one. false means "not a known bot", not "human".

03 · Pitfalls

What usually goes wrong.

trust proxy decides what req.ip is
Without it, behind nginx or a load balancer, every request looks like it comes from the proxy. Set it to the number of proxies in front of you, not to true, or clients can spoof X-Forwarded-For.
One cache per process
In cluster mode each worker keeps its own cache. That is fine for saving requests; use a shared store if you run many workers.
Localhost has no location
In development the client is 127.0.0.1 or ::1. The snippets skip private and loopback addresses instead of spending a request on them.
Website restrictions block server calls
A key restricted to "Allowed websites" needs an Origin or Referer header, which server-side code does not send, so the call is refused. For a server key, leave websites empty and restrict by your server's outgoing IP address instead.
"No Data" is a string, not null
When no value is known for a field, the field is still present and set to "No Data". Every snippet converts it to null before you use it.

04 · Questions

Good to know.

More in the docs and the FAQ.

Does the lookup slow down every request?

Only the first request from each IP waits for the API, and never longer than the 1.5-second timeout. Later requests from the same IP are answered from the in-memory cache.

Can I use it on selected routes only?

Yes. Mount ipapiGeo() on a router or a single route instead of app.use, for example only on sign-up or checkout.

Do I need a paid plan?

No. Location and network fields work with a free key. req.geo.risk is null on free keys, so the crawler check is skipped.

Ship it today.

Free: 1.000 requests a day for location and network data. Bot flags and higher limits on every paid plan, from 9,00 € a month (VAT included).