AWS LambdaServerlessIP GeolocationNode.jsTutorial

IP Geolocation in AWS Lambda with Node.js: Country Detection Guide

ApogeoAPI6 min read

Getting the caller's IP in AWS Lambda

The IP extraction method depends on how your Lambda is triggered:

// For API Gateway (REST or HTTP API)
function getIP(event: APIGatewayProxyEvent): string {
  // API Gateway HTTP API v2
  if ('requestContext' in event && 'http' in (event as any).requestContext) {
    return (event as any).requestContext.http.sourceIp;
  }
  // API Gateway REST API v1
  return event.requestContext?.identity?.sourceIp
    ?? event.headers?.['X-Forwarded-For']?.split(',')[0].trim()
    ?? '127.0.0.1';
}

// For Application Load Balancer
function getIPFromALB(event: ALBEvent): string {
  return event.headers?.['x-forwarded-for']?.split(',')[0].trim()
    ?? '127.0.0.1';
}

// For Lambda URL (direct invocation)
function getIPFromFunctionURL(event: APIGatewayProxyEventV2): string {
  return event.requestContext.http.sourceIp;
}

Basic geolocation handler

// handler.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';

const APOGEO_KEY = process.env.APOGEO_KEY!;
const BASE_URL = 'https://api.apogeoapi.com/v1';

interface GeoData {
  countryCode: string;
  countryName: string;
  city: string;
  regionName: string;
  timezone: string;
  currencyCode: string;
  currencySymbol: string;
}

// Module-level cache — persists across Lambda invocations in the same container
const geoCache = new Map<string, { data: GeoData; expires: number }>();

async function geolocate(ip: string): Promise<GeoData> {
  const subnet = ip.split('.').slice(0, 3).join('.') + '.0';
  const cached = geoCache.get(subnet);

  if (cached && cached.expires > Date.now()) {
    return cached.data;
  }

  const res = await fetch(
    `${BASE_URL}/geo/${ip}?apikey=${APOGEO_KEY}`,
    { signal: AbortSignal.timeout(2000) }
  );

  if (!res.ok) {
    return { countryCode: 'US', countryName: 'United States', city: '',
             regionName: '', timezone: 'America/New_York',
             currencyCode: 'USD', currencySymbol: '$' };
  }

  const data: GeoData = await res.json();
  geoCache.set(subnet, { data, expires: Date.now() + 3_600_000 });
  return data;
}

export const handler = async (
  event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
  const ip = event.requestContext?.identity?.sourceIp
    ?? event.headers?.['X-Forwarded-For']?.split(',')[0].trim()
    ?? '127.0.0.1';

  const geo = await geolocate(ip);

  return {
    statusCode: 200,
    headers: {
      'Content-Type': 'application/json',
      'Cache-Control': 'private, max-age=3600',
    },
    body: JSON.stringify({ ip, ...geo }),
  };
};

Lambda environment variable setup

# Using AWS CLI
aws lambda update-function-configuration \
  --function-name my-geo-function \
  --environment "Variables={APOGEO_KEY=your_api_key}"

# Using SAM template.yml
Environment:
  Variables:
    APOGEO_KEY: !Sub "{{resolve:ssm:/app/apogeo-key}}"

# Using Serverless Framework (serverless.yml)
environment:
  APOGEO_KEY: ${ssm:/app/apogeo-key}

Localized pricing Lambda

// pricing-handler.ts
const PLANS = {
  basic: 19,
  starter: 29,
  professional: 79,
};

const PPP_FACTORS: Record<string, number> = {
  IN: 0.35, BR: 0.55, MX: 0.60, AR: 0.40,
};

export const handler = async (event: APIGatewayProxyEvent) => {
  const ip = event.requestContext?.identity?.sourceIp ?? '127.0.0.1';
  const geo = await geolocate(ip);

  const ppp = PPP_FACTORS[geo.countryCode] ?? 1;

  // Fetch live exchange rate from ApogeoAPI
  const rateRes = await fetch(
    `https://api.apogeoapi.com/v1/rates/USD?apikey=${APOGEO_KEY}`,
    { signal: AbortSignal.timeout(2000) }
  );
  const rateData = await rateRes.json();
  const rate = rateData.rates?.[geo.currencyCode] ?? 1;

  const prices = Object.fromEntries(
    Object.entries(PLANS).map(([plan, usd]) => [
      plan,
      {
        usd,
        local: Math.round(usd * ppp * rate),
        currency: geo.currencyCode,
        symbol: geo.currencySymbol,
        hasPPP: ppp !== 1,
      },
    ])
  );

  return {
    statusCode: 200,
    headers: {
      'Content-Type': 'application/json',
      // Cache at CloudFront by country — each edge node caches for 1h
      'Cache-Control': 'public, max-age=3600',
      'Vary': 'CloudFront-Viewer-Country',
    },
    body: JSON.stringify({ country: geo.countryCode, prices }),
  };
};

Lambda@Edge for CloudFront

Lambda@Edge runs at CloudFront edge nodes — it receives geo headers CloudFront injects automatically:

// cloudfront-geo-handler.ts (origin-request trigger)
import { CloudFrontRequestEvent, CloudFrontRequestResult } from 'aws-lambda';

export const handler = async (
  event: CloudFrontRequestEvent
): Promise<CloudFrontRequestResult> => {
  const request = event.Records[0].cf.request;
  const headers = request.headers;

  // CloudFront injects these automatically (no API call needed)
  const country = headers['cloudfront-viewer-country']?.[0]?.value ?? 'US';
  const city = headers['cloudfront-viewer-city']?.[0]?.value ?? '';

  // Add geo context to the origin request
  request.headers['x-viewer-country'] = [{ key: 'X-Viewer-Country', value: country }];
  request.headers['x-viewer-city'] = [{ key: 'X-Viewer-City', value: city }];

  // If we need currency data, fetch from ApogeoAPI (cache result in-memory)
  // Lambda@Edge has a 1MB package limit — no node_modules, use fetch directly

  return request; // forward to origin
};

Caching strategy for Lambda

Lambda containers are reused across invocations. Module-level variables persist between invocations in the same container:

  • Warm container: cache hit, 0ms added latency
  • Cold start: new container, cache miss, one API call (~20-50ms)
  • Cache key: /24 subnet (8.8.8.0) so one office network = 1 lookup ever

For even better performance, use ElastiCache Redis as a shared cache across all Lambda instances in your VPC.

SAM template example

# template.yml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Globals:
  Function:
    Timeout: 10
    MemorySize: 256
    Runtime: nodejs20.x
    Environment:
      Variables:
        APOGEO_KEY: !Sub "{{resolve:ssm:/prod/apogeo-key}}"

Resources:
  PricingFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: dist/pricing-handler.handler
      Events:
        Api:
          Type: HttpApi
          Properties:
            Path: /api/pricing
            Method: GET

Full API docs: api.apogeoapi.com/api/docs. Get a free API key: app.apogeoapi.com/register.

Try ApogeoAPI free

1,000 requests/month forever. 14-day full-access trial. No credit card.

Get your free API key