Skip to content
ExponaBLOG
ENGINEERING CRAFT · DAY 076 / 100

Integrating third-party APIs safely in Engineering Craft

Lessons from Amadeus, Twilio and Meta integrations.

Expona Admin8 min read

I've watched production incidents cascade because a single third-party API call wasn't designed carefully enough. Integrating third-party APIs safely isn't just about making the request work once—it's about making it work reliably under load, failure, and edge cases. In this article, I'll walk through real lessons from production integrations with Amadeus (flight search), Twilio (SMS), and Meta (social login), plus the patterns that saved us from late-night pages.

Read the limits — Rate limits, quotas and token expiry shape your design

Every third-party API ships with constraints. Amadeus allows 10 requests per second for flight search; Twilio caps concurrent SMS at your account tier; Meta access tokens expire in 60 days. Most engineers acknowledge these limits exist, but few design their systems around them from day one.

I learned this the hard way. We built a flight search feature that queried Amadeus for every user search in real time. At 10 RPS, we hit the limit within an hour during peak traffic. The API stopped responding, users saw timeouts, and our error rate spiked. We'd never tested against the actual limit.

Here's what I now do: document the constraints in a visible place—comments in your code, or better yet, a config file that your team reads before building.

typescript
// amadeus.config.ts
export const AMADEUS_LIMITS = {
  requestsPerSecond: 10,
  maxConcurrentRequests: 5,
  tokenExpiryDays: 730,
  searchCacheTtlSeconds: 300, // 5 min, per docs
};

export const TWILIO_LIMITS = {
  concurrentSms: 100, // your account tier
  requestsPerMinute: 1000,
  tokenExpiryDays: 1, // access tokens
};

export const META_LIMITS = {
  accessTokenExpirySeconds: 5184000, // 60 days
  userAccessTokenExpiryDays: 60,
  graphApiRateLimit: 'app-scoped', // check dashboard
};

Next, instrument your API clients to track usage. Don't just log; alert when you're approaching the limit. I've added middleware that tracks cumulative requests per minute and per second, and raises an alarm at 80% of the quota.

typescript
// amadeus-client.ts
class AmadeusClient {
  private requestCount = 0;
  private lastResetTime = Date.now();
  private readonly maxRequestsPerSecond = 10;

  async search(params: SearchParams): Promise {
    this.checkRate();
    this.requestCount++;

    if (this.requestCount > this.maxRequestsPerSecond * 0.8) {
      logger.warn('Amadeus approaching rate limit', {
        count: this.requestCount,
        limit: this.maxRequestsPerSecond,
      });
    }

    try {
      return await this.makeRequest('/v2/shopping/flight-offers', params);
    } catch (error) {
      if (error.status === 429) {
        logger.error('Rate limit exceeded', { retryAfter: error.headers['retry-after'] });
      }
      throw error;
    }
  }

  private checkRate() {
    const now = Date.now();
    if (now - this.lastResetTime > 1000) {
      this.requestCount = 0;
      this.lastResetTime = now;
    }
  }
}

For token expiry, especially with long-lived tokens like Meta's, automate rotation. Don't rely on your team to remember to refresh manually. In our Meta social login integration, I store the token expiry timestamp and check it before every API call. If it's within 7 days of expiry, I refresh proactively.

Timeouts and retries — External APIs fail. Plan for slow and failed responses

External services are outside your control. Network hiccups, server maintenance, DNS flakes—they all happen. The question is: does your system degrade gracefully?

I've learned to set aggressive timeouts and retry with exponential backoff. For Twilio SMS delivery, a 30-second timeout is reasonable; Amadeus flight search should timeout after 15 seconds. But the retry strategy matters more than the timeout itself.

typescript
// retry-with-backoff.ts
async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  maxRetries: number = 3,
  baseDelayMs: number = 500,
): Promise<T> {
  let lastError: Error | null = null;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error as Error;

      // Don't retry 4xx errors (invalid request)
      if (error.status && error.status < 500) {
        throw error;
      }

      // Don't retry rate limits the same way
      if (error.status === 429) {
        const retryAfter = parseInt(error.headers['retry-after'] || '60', 10);
        await delay(retryAfter * 1000);
        return await fn(); // single retry for 429
      }

      if (attempt < maxRetries - 1) {
        const delayMs = baseDelayMs * Math.pow(2, attempt);
        logger.warn('Retry attempt', {
          attempt: attempt + 1,
          delayMs,
          error: error.message,
        });
        await delay(delayMs);
      }
    }
  }

  throw lastError;
}

Use this for all external calls. But be selective: retry on 5xx, timeouts, and connection errors. Don't retry 4xx errors (malformed request) or 401/403 (auth issues). For rate limit (429), wait the time specified in Retry-After header.

For Twilio, I also wrap the client in a circuit breaker. If Twilio is returning 5xx for 10 consecutive requests, stop hammering it and fail fast with a clear error. Customers see "SMS temporarily unavailable" instead of waiting 45 seconds for a retry.

Cache where allowed — Flight searches and reference data can be cached briefly

Not every API call needs to hit the external service. Amadeus flight searches are idempotent; two identical searches within seconds will return the same results. Caching this for 5 minutes (their stated limit) reduces API calls by 80% in typical user patterns.

Reference data—airport codes, countries, payment methods from Meta—change infrequently. Cache these for hours or days. Twilio account info (your balance, rate limits) can be cached for 30 minutes; it's not time-critical.

The key is knowing what the API owner allows. Read the docs. Amadeus explicitly permits caching search results briefly; Twilio doesn't explicitly forbid caching account data, but it's safer to refresh hourly. Meta's user info should not be cached beyond their token lifetime.

typescript
// amadeus-with-cache.ts
import NodeCache from 'node-cache';

const cache = new NodeCache({ stdTTL: 300 }); // 5 minutes

class AmadeusCachedClient {
  async search(params: SearchParams): Promise<FlightResult[]> {
    const cacheKey = `flight:${JSON.stringify(params)}`;
    
    const cached = cache.get<FlightResult[]>(cacheKey);
    if (cached) {
      logger.debug('Cache hit', { cacheKey });
      return cached;
    }

    const results = await retryWithBackoff(() =>
      this.makeRequest('/v2/shopping/flight-offers', params),
    );

    cache.set(cacheKey, results, 300); // explicit 5-min TTL per Amadeus docs
    return results;
  }
}

In our travel platform, this caching layer reduced Amadeus API costs by 35% without affecting freshness. We also tagged cache entries with their source and expiry reason, so debugging cache misses was straightforward.

Monitor the integration — Alert when error rates rise or tokens are about to expire

Your integration is live. Now what? Monitoring is the difference between a graceful degradation and an outage.

I track four key metrics for every third-party API integration:

  1. Error rate: % of requests failing. Alert if it exceeds 5% for more than 2 minutes.
  2. Latency: p50, p95, p99 response times. Alert if p95 exceeds 10 seconds.
  3. Token expiry countdown: Days until access token expires. Alert at 7 days for Twilio, 14 days for Meta.
  4. Rate limit consumption: Requests used vs. quota. Alert at 80%.
typescript
// monitoring.ts
import { StatsD } from 'node-statsd';

const statsd = new StatsD();

async function monitoredApiCall<T>(
  service: string,
  fn: () => Promise<T>,
): Promise<T> {
  const startMs = Date.now();

  try {
    const result = await fn();
    const elapsedMs = Date.now() - startMs;

    statsd.timing(`${service}.latency`, elapsedMs);
    statsd.increment(`${service}.success`);

    return result;
  } catch (error) {
    statsd.increment(`${service}.error`);
    statsd.increment(`${service}.error.${error.status || 'unknown'}`);

    logger.error('API call failed', {
      service,
      error: error.message,
      status: error.status,
    });

    throw error;
  }
}

// Usage
const results = await monitoredApiCall('amadeus', () =>
  amadeusClient.search(params),
);

Set up dashboards in your monitoring tool (Datadog, New Relic, CloudWatch). Create alerts that page you when error rate spikes or tokens are expiring soon. I learned this lesson after Meta access tokens expired silently and took down social login for 8 hours before anyone noticed.

Common mistakes

  • Ignoring rate limits until production. Test your rate limiting in staging. Load test against the actual limits you'll face.
  • Not handling 429 (Too Many Requests) specially. It's not a transient error like 5xx. Respect the Retry-After header.
  • Caching without TTL. Cached data that never expires is stale data. Always set an explicit TTL aligned with the API's data freshness guarantee.
  • Relying on the API client's built-in retry. Many SDKs retry, but not all do it safely. Wrap them in your own retry logic with clear visibility.
  • Forgetting to rotate long-lived tokens. Mark calendar reminders or automate token refresh 7-14 days before expiry.
  • Not logging the external request and response. When something breaks, you need to see what was sent and what came back. Log it (scrub sensitive data).

Key takeaways

  • Know your API's limits (rate, quota, token expiry) before you build. Document them in code.
  • Set timeouts and retry transient failures (5xx, timeouts) with exponential backoff. Don't retry 4xx.
  • Cache aggressively where allowed. Read the API's cache policy. Brief caches (5–30 minutes) reduce costs and latency without violating terms.
  • Monitor error rate, latency, token expiry, and rate limit consumption. Alert early and often.
  • Test rate limiting in staging. Load test against the real constraints. Don't learn this in production.
  • Scrub sensitive data (API keys, tokens, user data) from logs. But log enough to debug API interactions.
  • Graceful degradation beats hard failures. If an external API is slow or failing, degrade to cached data or a fallback, not a 500 error.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles