Resources / Tutorial
Consuming an API end to end (so it survives production)
The five steps past your first 200: auth, paranoid response handling, pagination, retries, and mapping the response into your own types.
I remember the first time I made my first 200 call in Postman. I instantly saw the possibilities, and figured this was a one-and-done kind of thing. It isn't. The distance between "it returned 200 in my terminal" and "I can leave this running and trust it" is the part nobody screenshots: auth, paging through results, handling the calls that fail, retrying the ones worth retrying, and turning someone else's JSON into your own types. Here's the whole path, using a generic orders API.
I'll use C# for the examples, but the steps are the same anywhere.
1. Make the call (and authenticate)
Most APIs want a token or key on every request. I covered that in API keys vs OAuth2, so here I'll assume you've already got a bearer token and just send it:
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.example.com/v1/orders");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await http.SendAsync(request);
Nothing surprising yet. The surprises start with the response.
2. Read the response like you don't trust it
For a long time I didn't. As I built more APIs, I never really questioned the responses or checked whether a call had actually succeeded. I assumed it would come back 200 and stay that way. That was a mistake, and one I wish I'd caught earlier, because the day it isn't 200 is the day things break quietly and you're left guessing where.
So now I check the status before I read the body:
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync();
throw new ApiException((int)response.StatusCode, error);
}
var page = await response.Content.ReadFromJsonAsync<OrderPage>();
Deserialize into a real type, not dynamic or a loose dictionary. You want the compiler on your side, and you want one place that defines what you think the response looks like:
public record Order(string Id, string Customer, decimal Total, string Status);
public record OrderPage(List<Order> Data, string? NextCursor);
If a field can be missing or null in their response, and on a long enough timeline it will be, model it as nullable and decide what you do about it. Don't let a null slip in and blow up three layers away from the call that caused it.
3. Pagination: there's always more than one page
An orders endpoint never returns every order. It returns a page, plus a way to get the next one. Two patterns cover almost everything.
Cursor-based, where the response hands you a token for the next page and you keep going until it stops:
async IAsyncEnumerable<Order> GetAllOrders()
{
string? cursor = null;
do
{
var url = "https://api.example.com/v1/orders?limit=100";
if (cursor is not null) url += $"&cursor={cursor}";
var page = await GetPage(url);
foreach (var order in page.Data)
yield return order;
cursor = page.NextCursor;
}
while (cursor is not null);
}
Offset-based works the same way with ?offset=200&limit=100, bumping the offset each loop until you get back a short page.
One judgment call here: don't pull every page just because you can. I used to grab everything, just to be sure I had it, and let my software sort out the records I actually needed. It works, but it piles on overhead and makes the app slower than it has any reason to be. Pulling only what you need up front saves you the rework, and saves you the "why is this so slow" complaints later on. If you only need this week's orders, filter on the request and let the API do the work.
4. Errors and retries: know what's worth a second try
Not every failure means the same thing. The split that matters is transient versus permanent.
Transient failures are a 429 (rate limited), a 503, a timeout, a dropped connection. These often work if you wait a moment and try again. Permanent failures are a 400 (your request is wrong), a 401 (auth), a 404 (it isn't there). Retrying those just fails slower. Fix the request instead.
So retry the transient ones, with backoff, and leave the rest alone:
async Task<HttpResponseMessage> SendWithRetry(Func<HttpRequestMessage> makeRequest)
{
for (var attempt = 1; ; attempt++)
{
var response = await http.SendAsync(makeRequest());
if (response.IsSuccessStatusCode)
return response;
var transient = (int)response.StatusCode is 429 or >= 500;
if (!transient || attempt == 4)
return response; // give up, let the caller deal with it
// honor Retry-After if they sent one, otherwise back off
var wait = response.Headers.RetryAfter?.Delta
?? TimeSpan.FromSeconds(Math.Pow(2, attempt));
await Task.Delay(wait);
}
}
Two things in there earn their keep. Backing off exponentially (1s, 2s, 4s) instead of retrying instantly keeps you from making a rate-limit problem worse. And honoring Retry-After when the server sends it is just doing what you were told. A lot of 429 loops are self-inflicted because the code ignored that header.
For real projects I usually reach for Polly instead of hand-rolling this, but it's worth writing once so you know what Polly is doing for you.
One more, for writes. If you're POSTing something and the request times out, you don't actually know whether it landed. Retrying could create the thing twice. If the API supports an idempotency key, send one so a retry is safe. If it doesn't, think hard before you retry a write at all.
5. Keep their shape out of your code
It's tempting to pass that Order record straight through your app. Resist it. The DTO that matches their JSON should stop at the edge of your integration, and you translate it into your own domain model:
var domainOrder = new SalesOrder
{
OrderNumber = dto.Id,
CustomerName = dto.Customer,
Amount = dto.Total,
IsOpen = dto.Status is "open" or "pending"
};
This feels like busywork on day one. It pays for itself the first time they rename a field or turn status from a string into an object. When that happens, and it will, usually without much warning, you fix it in one mapping function instead of chasing it through every file that ever touched their data.
A couple of things that bite in production
No timeout. HttpClient will happily wait a very long time. Set a timeout so one slow call doesn't tie up a request thread.
A new HttpClient per call. This exhausts sockets under load. Reuse one, or use IHttpClientFactory in .NET. This one fools people because it works fine right up until real traffic shows up.
Logging the raw response. Handy while debugging, until the body contains a token or someone's personal data and now it's sitting in your logs. Log the status and a request id, not the whole payload.
Trusting their uptime. Their API will be down at some point. Decide now whether that means you fail, queue the work, or fall back. You don't want to be figuring that out during the outage.
The "is it actually done" checklist
Before I call an integration finished, it handles all of this:
- auth, with tokens cached and refreshed
- pagination, fetching only what's needed
- transient errors retried with backoff, permanent ones surfaced
- a timeout on every call
- one reused HttpClient
- their response mapped into my own types
- secrets kept out of the code and out of the logs
Get through that list and you've got an integration you can leave running, which was the whole point. The first 200 is the easy ten percent.