Back to Blog
Integrations June 26, 2026 · 12 min read

Building Reliable API Integrations: Retries, Idempotency, and Error Handling

Integrations that work perfectly in staging routinely fail in production. The failure modes are predictable. Most engineers just don't design for them upfront — and that's what this guide is for.

M

Multivak Labs

Engineering Team

Staging environments lie. Your third-party payment provider responds in 200ms with a perfectly formed JSON response every time. Your CRM webhook fires on schedule. The external inventory API never returns a 503. You ship to production, and within two weeks you have an incident caused by a partial payment confirmation, a webhook that fired twice, and a timeout that your code didn't know how to handle. None of these are exotic failure modes. They're the ordinary physics of distributed systems — and designing for them is the job.

This guide is a comprehensive reference for building API integrations that survive production. It covers the full stack: failure taxonomy, circuit breakers, retry logic, idempotency, timeouts, error classification, observability, webhook reliability, and testing resilience. By the end you'll have a mental model and a reference architecture you can apply to any integration you build.

The Taxonomy of Integration Failures

Before you can design for failure, you need to name the failures. Every integration failure falls into one of six categories:

Network timeouts. Your request left your server but never received a response — or received one too slowly. This is ambiguous: you don't know whether the upstream processed your request or not. A payment might have gone through. A record might have been created. You can't know without checking.

Rate limit errors (HTTP 429). You've exceeded the upstream API's call quota for a given time window. The request was rejected before processing. Safe to retry — but only after backing off for the duration the provider specifies in the Retry-After header.

Authentication expiry. OAuth tokens expire. API keys get rotated. JWT signatures become invalid. If your integration doesn't handle token refresh automatically, it will silently fail when credentials expire — usually at the worst possible moment, like right after a weekend when no one is watching the logs.

Malformed responses. The upstream returned a 200 but the body doesn't match the schema you're parsing against. Maybe they deployed a breaking change. Maybe you're hitting a different version of their API than you expect. Maybe there's a proxy in the middle that returned an HTML error page instead of JSON. Your parser crashes and nothing downstream runs.

Upstream downtime. Connection refused, 502 Bad Gateway, 503 Service Unavailable. The upstream is simply not available. These are temporary — but temporary can mean five minutes or five hours. Your system needs to queue work rather than drop it.

Partial success. The most dangerous category. You called an API that creates a record and a notification. The record was created but the notification failed. Your integration got a 200. Nothing looks wrong. But your user never got their confirmation email and your CRM has an orphaned record. Partial success requires careful idempotency and rollback design to handle safely.

Designing for Failure: The Circuit Breaker Pattern

A circuit breaker is a proxy in front of an upstream call that tracks failure rates and stops making calls when the upstream appears to be down. The analogy is electrical: if something's going wrong, you don't want current to keep flowing and cause more damage — you trip the breaker.

The circuit breaker has three states:

Closed (normal operation): requests pass through to the upstream. The breaker tracks the failure rate. If failures exceed a threshold — say, 50% of requests in the last 60 seconds — the breaker trips to open.

Open (tripped): all requests are immediately rejected without contacting the upstream. Your code gets a fast failure rather than a slow timeout. This protects your thread pool and stops your system from cascading failures to your own users while the upstream is unavailable. After a configured timeout — typically 30 to 60 seconds — the breaker transitions to half-open.

Half-open: a small number of test requests are allowed through. If they succeed, the breaker closes and normal operation resumes. If they fail, the breaker reopens and the timeout resets.

Circuit breakers are available as libraries in every major language — Resilience4j in Java, Polly in .NET, opossum in Node.js, tenacity in Python. Use one rather than implementing your own. The edge cases around state transitions are subtle.

When should you use a circuit breaker? Any time you're making synchronous calls to an external service in the hot path of a user-facing request. Without one, a slow or unavailable upstream causes your own response times to climb until your connection pool exhausts and you stop serving requests entirely.

Retry Logic That Doesn't Make Things Worse

Retries are the first instinct when a request fails. The problem is naive retries — immediately retrying on failure with no delay — can make an already-struggling upstream worse. If a service is slow because it's overloaded, and every client immediately retries their failed request, you've just doubled the load on an already-struggling system. This is called a "thundering herd" and it can turn a brief slowdown into a full outage.

The solution is exponential backoff with jitter. Instead of retrying immediately, you wait an exponentially growing delay — 1 second, 2 seconds, 4 seconds, 8 seconds — and add a random jitter factor to each delay. The jitter prevents multiple clients from synchronising their retries even if they all hit the same failure at the same time.

A practical formula: delay = min(base * 2^attempt + random(0, base), max_delay). With a base of 1 second and a cap of 60 seconds, your retries spread out gracefully rather than hammering the upstream.

Set a maximum retry count. Three to five retries is usually sufficient. Beyond that, you're likely dealing with a sustained outage rather than a transient fault, and continued retrying just consumes resources.

Critically: not all errors should be retried. The rule is simple — retry transient errors, not permanent ones:

  • Retry: 429 (rate limit), 500, 502, 503, 504, network timeouts, connection resets
  • Do not retry: 400 (bad request — retrying won't help), 401 (auth failure — fix credentials first), 403 (forbidden — same), 404 (resource not found), 422 (validation error)

4xx errors are your fault. Fix the request before retrying. 5xx errors are the upstream's fault. Retrying is appropriate.

Idempotency — The Property That Makes Retries Safe

An operation is idempotent if performing it multiple times has the same effect as performing it once. GET requests are naturally idempotent — reading data doesn't change state. DELETE is usually idempotent — deleting a resource that's already been deleted returns 404 but causes no harm. POST requests — creating resources, triggering payments, sending emails — are not naturally idempotent.

The problem: if you retry a POST request that timed out, you don't know whether the original request succeeded. You might create a duplicate charge, send a duplicate email, or create a duplicate record. Retrying without idempotency guarantees is dangerous.

The standard solution is an idempotency key: a unique string (typically a UUID or hash) that you generate client-side and include in the request, usually as a header (Idempotency-Key: <uuid>). The server stores the key and the response. If it receives the same key again, it returns the stored response without re-executing the operation.

On the client side: generate a fresh idempotency key for each new operation, then reuse that same key for all retries of that operation. Don't generate a new key per retry — that defeats the purpose.

On the server side: if you're building an API that will be called by external systems, implement idempotency key support. Store the key with a TTL (24 hours is typical), check for it on every state-mutating request, and return the cached response if it already exists.

Before blindly implementing retries against an upstream, check their documentation: do they support idempotency keys? Stripe does, and their documentation is excellent on this topic. Many payment processors do. Many CRMs and internal tools do not. If the upstream doesn't support idempotency keys, you need to implement a check-then-act pattern instead: query whether the operation already occurred before attempting it again.

Timeout Strategy

Every outbound call needs a timeout. Without one, a hanging upstream connection holds a thread indefinitely — and threads are finite. A handful of slow requests can exhaust your thread pool and make your service unresponsive to all other requests.

There are two timeout types to set separately:

Connection timeout: how long to wait for the TCP connection to be established. This is typically low — 2 to 5 seconds. If you can't establish a connection in that time, the host is probably down or unreachable.

Read timeout: how long to wait for a response after the connection is established. This depends on what the endpoint is doing. A simple data fetch might warrant a 5-second read timeout. A report generation endpoint that can take a moment to process data might warrant 30 seconds. Know your upstream's expected response time and set the timeout at roughly 2-3x the typical p99 latency.

Aggressive timeouts (sub-second) cause false failures. The upstream was going to respond fine, but you gave up before it did. Loose timeouts (minutes) cause cascades. Find the middle ground based on observed upstream performance data, not guesses.

One common mistake: setting a single global timeout for all external calls. Different endpoints have wildly different latency profiles. Configure timeouts per integration, per endpoint category, not globally.

Error Classification and Handling Strategy

When an integration fails, you need to make a decision: do you surface the error to the user, silently retry, or queue for later? The answer depends on the error type and where it occurs in your call stack.

Transient errors (network timeout, 503, rate limit): retry with backoff. If retries are exhausted, queue for async retry rather than failing the user's request if the operation is non-critical. If it's critical (payment), surface a clear error to the user.

Permanent client errors (400, 422): log with full request details, alert the engineering team, do not retry. These require a code change.

Auth errors (401, 403): trigger credential refresh logic if available. If refresh fails, alert immediately — the integration is dead until credentials are fixed.

Upstream errors (500): these are usually transient but occasionally indicate a permanent upstream bug. Retry with backoff. Alert if failure rate exceeds threshold.

Think carefully about what to surface to end users. Users should never see raw API error messages from third-party systems — that's an information leak and a terrible user experience. Translate errors into user-appropriate messages: "Payment processing is temporarily unavailable. Please try again in a moment." Keep the raw upstream error in your logs.

Set alerting thresholds on error rates, not absolute counts. Five errors per minute might be fine at 10,000 requests per minute (0.05% error rate) but catastrophic at 50 requests per minute (10% error rate). Alert on error rate relative to request volume.

Observability for Integrations

You cannot debug an integration you cannot observe. "It's not working" tells you nothing. You need the full picture: what was sent, what was returned, how long it took, and what happened downstream.

Structured logging is the foundation. Every outbound request should produce a log entry with: the target service and endpoint, the HTTP method, the response status code, the latency in milliseconds, whether it was a retry, the retry attempt number, the idempotency key if applicable, and a correlation ID that ties the log entry to the originating request.

Integration-specific metrics give you the aggregate picture. Track: request rate per upstream, error rate per upstream, latency percentiles (p50, p95, p99) per upstream, retry rate, circuit breaker state transitions. These should be visible in a dashboard, not buried in log files.

Distributed tracing with correlation IDs is essential for multi-step integrations. When a user action triggers a chain of API calls — your server calls CRM, CRM data triggers a webhook, webhook calls payment provider — you need a single correlation ID threaded through every hop. Without it, matching a user complaint ("my payment failed") to the relevant log entries across three systems is a manual nightmare.

Generate a correlation ID at the entry point (the user's request) and propagate it in every downstream request header, every log entry, and every queue message. When something fails, you can search your log aggregation system for that one ID and see the complete chain of events.

Webhook Handler Reliability

Webhooks introduce a different reliability challenge: you're the server, not the client. You need to handle delivery correctly, process events safely, and never lose a message.

Signature verification must happen first. Every well-designed webhook provider (Stripe, GitHub, Twilio) signs their payloads with a secret key and includes the signature in a request header. Verify this before doing anything else. Reject unsigned or incorrectly signed requests immediately with a 401. This prevents malicious actors who discover your endpoint URL from injecting fake events.

Respond immediately, process asynchronously. Your webhook endpoint should do two things only: verify the signature, and push the raw payload to a queue. Then return 200 immediately. Do not process the event synchronously inside the HTTP handler. Webhook providers have short timeout windows (often 5-30 seconds) before they consider the delivery failed and retry. If your processing involves multiple downstream calls, database writes, or anything that might be slow, you will timeout and trigger duplicate deliveries. Async processing via a queue eliminates this entirely.

Design for at-least-once delivery. All major webhook providers guarantee at-least-once delivery, not exactly-once. This means the same event may arrive more than once — during retries, during provider-side incidents, or due to bugs. Your handler must be idempotent. Track processed event IDs in your database and skip any event you've already handled. This is not optional; duplicate event processing causes real-world harm (double charges, duplicate records, double emails).

Dead-letter queues for failed events. If event processing fails even after retries — because of a bug in your processing code, a downstream service being unavailable, or an unexpected payload shape — route the event to a dead-letter queue. Never silently discard failed events. Your dead-letter queue should trigger an alert and provide a mechanism to inspect and replay events once the underlying issue is fixed.

Testing Integration Resilience

Unit tests and happy-path integration tests don't verify resilience. You need tests that explicitly exercise failure modes.

Contract testing with Pact verifies that your integration assumptions about the upstream API haven't been broken by a provider change. Pact records the consumer's expectations (the shape of requests and responses it makes) and verifies them against the provider's actual behaviour without requiring a live upstream. This catches breaking API changes before they reach production.

Mock servers for fault injection. Use a mock HTTP server (WireMock, Nock, msw) that you fully control to simulate upstream failure modes: connection refused, 500 errors, response delays, malformed JSON, empty responses. Write explicit tests that put your integration code through each failure mode and verify the correct behaviour — retry with backoff, circuit breaker tripping, error classification, graceful degradation.

Chaos engineering for integrations. In staging, periodically inject random failures into your upstream calls — using a proxy like Toxiproxy that can add latency, drop connections, and throttle bandwidth. Run your integration traffic through it and verify that your system behaves gracefully: circuit breakers trip, retries occur, users see appropriate error messages, nothing crashes permanently.

Sandbox vs. mock environments. Many providers offer sandbox environments that simulate real behaviour including failure modes. Use these for integration tests that verify the full round-trip. Use mock servers for unit tests where you need precise control over every response. Both are necessary; neither replaces the other.

A Reference Architecture for a Production Integration

Putting it all together, here is the reference architecture for a production-grade webhook-driven integration:

Inbound webhook → signature verification (reject if invalid) → immediate 200 response → raw payload queued in a durable message queue (SQS, RabbitMQ, Redis Streams).

Queue worker (separate process) pulls events → checks event ID against processed set (skip if duplicate) → runs business logic → wraps all upstream calls in circuit breaker + retry with exponential backoff + idempotency keys → logs every step with correlation ID → writes result to database → marks event as processed.

Failure path: if processing fails after max retries → move to dead-letter queue → fire alert → event is held for manual inspection and replay.

Observability layer: structured logs with correlation IDs to log aggregation (Datadog, Papertrail, CloudWatch) → metrics per upstream → dashboard with error rate, latency, circuit breaker state → alerts on error rate threshold and dead-letter queue depth.

This architecture handles every failure mode in the taxonomy: network timeouts (handled by retry), rate limits (exponential backoff), auth expiry (credential refresh + alert), malformed responses (validation in worker, dead-letter on parse failure), upstream downtime (circuit breaker + queue backpressure), partial success (idempotency keys + database transactions). Nothing is silently dropped. Everything is observable.


The patterns here are not exotic — they're the standard toolkit for production integrations. The gap between teams that have production incidents caused by integrations and teams that don't is almost always in whether these patterns were applied from the start, or bolted on reactively after the first incident. Design for failure upfront, and the incidents largely don't happen.

If you're building an integration and want to validate your approach — or you've inherited a fragile integration and want to harden it — talk to our team. We've built integration layers across payments, CRMs, communication platforms, and data pipelines, and we can help you get from fragile to production-grade.

API Integration Reliability Idempotency Error Handling Retries

Need a production-grade integration?

We build API integrations designed for reliability — retries, observability, and graceful degradation from day one.

Explore API Integration Services