# C# movie API tutorial for .NET 8

> This tutorial builds a .NET 8 console app that takes a movie title, searches amodb, loads the best match with its ratings and prints a short card. It uses only HttpClient and System.Text.Json, reads the key from AMODB_KEY, retries on 429 and caches responses in files. Setup takes about 15 minutes.

## What you build

You build `MovieLookup`, a console app that takes a title on the command line, searches amodb, loads the best match with its ratings and prints a short card with a poster link. You need the .NET 8 SDK, an amodb key and about 15 minutes. The program uses only `HttpClient` and `System.Text.Json`, so there are no NuGet packages to install.

## Step 1: Get an API key

Pick a plan on [pricing](https://amodb.dev/pricing/). The plans differ only in rate, at 20, 40 or 120 requests per minute. Create a key in your account dashboard, a string of 32 lowercase hex characters. Export it and test it with one call:

```bash
export AMODB_KEY=YOUR_API_KEY
curl -s -o /dev/null -w "%{http_code}\n" \
  -H "Authorization: Bearer $AMODB_KEY" \
  "https://api.amodb.dev/3/movie/550"
```

A `200` means the key works. A `401` means the key is wrong or missing. In PowerShell set the variable with `$env:AMODB_KEY = "YOUR_API_KEY"`. The [authentication](https://amodb.dev/authentication/) page covers the `api_key` query parameter as an alternative to the header.

## Step 2: Create the project

```bash
dotnet new console -n MovieLookup -f net8.0
cd MovieLookup
```

The .NET 8 template turns on implicit usings and nullable reference types. That already imports `System`, `System.IO`, `System.Linq` and `System.Net.Http`. You add the other `using` lines in the file.

## Step 3: Send the first request

Create one `HttpClient` with a base address, a 10 second timeout and the Bearer header. End the base address with a slash and write relative paths without a leading slash. A leading slash would replace the `/3` part of the base path.

```csharp
using System.Net.Http.Headers;

using var http = new HttpClient
{
    BaseAddress = new Uri("https://api.amodb.dev/3/"),
    Timeout = TimeSpan.FromSeconds(10),
};
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("AMODB_KEY"));

var response = await http.GetAsync("search/movie?query=fight%20club&language=en-US");
Console.WriteLine((int)response.StatusCode);
```

## Step 4: Parse the JSON into records

The response uses snake case names such as `release_date`. Set `JsonNamingPolicy.SnakeCaseLower`, which is new in .NET 8, and write PascalCase members. Fields you do not declare are ignored.

```csharp
record SearchResponse(List<SearchHit> Results);
record SearchHit(int Id, string Title, string? ReleaseDate);
record Movie(int Id, string Title, string? Tagline, string? ReleaseDate, int? Runtime,
    string? PosterPath, double VoteAverage, RatingSet? Ratings);
record RatingSet(Score? Imdb, Score? Kinopoisk, Score? Metacritic, Score? RottenTomatoes, Score? Tmdb);
record Score(double Value, int? Votes);
```

The `ratings` object appears because the details call adds `append_to_response=ratings`. Each of its five sources is an object with `value` and `votes`, or `null` when the score is missing. `votes` can be `null` too: the live reply for Fight Club has no vote count for Kinopoisk, Metacritic or Rotten Tomatoes. The [movies](https://amodb.dev/movies/) page lists every other field.

## Step 5: Handle errors and rate limits

Wrap the call in a loop that reacts to the status code.

| Status     | Meaning                         | What the app does                                                         |
| ---------- | ------------------------------- | ------------------------------------------------------------------------- |
| 401        | Key missing, wrong or suspended | Print a message and stop, a retry cannot help.                            |
| 402        | The plan period has ended       | Print a message and stop. Renew on [pricing](https://amodb.dev/pricing/). |
| 404        | No record for that id           | Report it and exit.                                                       |
| 429        | Over your requests per minute   | Wait `Retry-After` seconds, capped at 30, and retry at most twice.        |
| 500 or 504 | Server side failure             | Report the status and run the command again later.                        |

```csharp
for (var attempt = 0; ; attempt++)
{
    using var response = await http.GetAsync(path);
    if (response.StatusCode == HttpStatusCode.TooManyRequests && attempt < 2)
    {
        var seconds = Math.Min(response.Headers.RetryAfter?.Delta?.TotalSeconds ?? 5, 30);
        await Task.Delay(TimeSpan.FromSeconds(seconds));
        continue;
    }
    if (response.StatusCode == HttpStatusCode.Unauthorized)
        throw new AmodbException("The API key was rejected (401). Check AMODB_KEY.");
    // ...handle other statuses, then read the body
}
```

Network failures surface as `HttpRequestException`, and a timeout surfaces as `TaskCanceledException`. Catch both at the top level. Every keyed reply also carries `X-RateLimit-Remaining`, so a long job can slow down before it reaches a 429. See [rate limits](https://amodb.dev/rate-limits/) and [errors](https://amodb.dev/errors/) for the full lists.

## Step 6: Cache responses on disk

The rate limit check runs before the edge cache, so an answer served from the edge still counts toward your requests per minute. A cache in your own program is the only way to save requests. The edge keeps details for 6 hours and search results for 10 minutes, so the app uses the same lifetimes. A file in the temp folder is fresh when its write time is inside the TTL.

```csharp
class FileCache
{
    readonly string dir = Path.Combine(Path.GetTempPath(), "amodb-cache");

    string PathFor(string key) =>
        Path.Combine(dir, Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key))) + ".json");

    public string? Get(string key, TimeSpan ttl)
    {
        var file = PathFor(key);
        return File.Exists(file) && DateTime.UtcNow - File.GetLastWriteTimeUtc(file) < ttl
            ? File.ReadAllText(file)
            : null;
    }
}
```

## Step 7: Put it together

This is the complete `Program.cs`. It adds the `Set` method to the cache and the `AmodbClient` class that combines the cache, the retry loop and the JSON options.

```csharp
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; // same number format on every machine

var key = Environment.GetEnvironmentVariable("AMODB_KEY");
if (string.IsNullOrWhiteSpace(key))
{
    Console.Error.WriteLine("Set AMODB_KEY first.");
    return 1;
}

var title = string.Join(' ', args).Trim();
if (title.Length == 0)
{
    Console.Error.WriteLine("Usage: dotnet run -- \"movie title\"");
    return 1;
}

using var http = new HttpClient
{
    BaseAddress = new Uri("https://api.amodb.dev/3/"),
    Timeout = TimeSpan.FromSeconds(10),
};
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", key);
var client = new AmodbClient(http, new FileCache());

try
{
    var search = await client.GetAsync<SearchResponse>(
        $"search/movie?query={Uri.EscapeDataString(title)}&language=en-US", TimeSpan.FromMinutes(10));
    var hit = search.Results.FirstOrDefault();
    if (hit is null)
    {
        Console.WriteLine($"No movie found for \"{title}\".");
        return 0;
    }

    var movie = await client.GetAsync<Movie>(
        $"movie/{hit.Id}?language=en-US&append_to_response=ratings", TimeSpan.FromHours(6));
    Print(movie);
    return 0;
}
catch (AmodbException ex)
{
    Console.Error.WriteLine(ex.Message);
    return 1;
}
catch (HttpRequestException ex)
{
    Console.Error.WriteLine($"Network error: {ex.Message}");
    return 1;
}
catch (TaskCanceledException)
{
    Console.Error.WriteLine("The request timed out after 10 seconds.");
    return 1;
}

static void Print(Movie m)
{
    var year = m.ReleaseDate is { Length: >= 4 } d ? d[..4] : "n/a";
    Console.WriteLine($"{m.Title} ({year}), {m.Runtime?.ToString() ?? "?"} min");
    if (!string.IsNullOrEmpty(m.Tagline)) Console.WriteLine(m.Tagline);
    Console.WriteLine($"IMDb: {Describe(m.Ratings?.Imdb)}");
    Console.WriteLine($"TMDB: {Describe(m.Ratings?.Tmdb)}");
    if (m.PosterPath is not null)
        Console.WriteLine($"Poster: https://img.amodb.dev/t/p/w342{m.PosterPath}");
}

static string Describe(Score? s) =>
    s is null ? "not known" : $"{s.Value:0.0}" + (s.Votes is { } v ? $" ({v:N0} votes)" : "");

record SearchResponse(List<SearchHit> Results);
record SearchHit(int Id, string Title, string? ReleaseDate);
record Movie(int Id, string Title, string? Tagline, string? ReleaseDate, int? Runtime,
    string? PosterPath, double VoteAverage, RatingSet? Ratings);
record RatingSet(Score? Imdb, Score? Kinopoisk, Score? Metacritic, Score? RottenTomatoes, Score? Tmdb);
record Score(double Value, int? Votes);

class AmodbException : Exception
{
    public AmodbException(string message) : base(message) { }
}

class FileCache
{
    readonly string dir = Path.Combine(Path.GetTempPath(), "amodb-cache");

    string PathFor(string key) =>
        Path.Combine(dir, Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key))) + ".json");

    public string? Get(string key, TimeSpan ttl)
    {
        var file = PathFor(key);
        return File.Exists(file) && DateTime.UtcNow - File.GetLastWriteTimeUtc(file) < ttl
            ? File.ReadAllText(file)
            : null;
    }

    public void Set(string key, string json)
    {
        Directory.CreateDirectory(dir);
        File.WriteAllText(PathFor(key), json);
    }
}

class AmodbClient(HttpClient http, FileCache cache)
{
    static readonly JsonSerializerOptions Options = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
    };

    public async Task<T> GetAsync<T>(string path, TimeSpan ttl)
    {
        var cached = cache.Get(path, ttl);
        if (cached is not null) return JsonSerializer.Deserialize<T>(cached, Options)!;

        for (var attempt = 0; ; attempt++)
        {
            using var response = await http.GetAsync(path);
            if (response.StatusCode == HttpStatusCode.TooManyRequests && attempt < 2)
            {
                var seconds = Math.Min(response.Headers.RetryAfter?.Delta?.TotalSeconds ?? 5, 30);
                await Task.Delay(TimeSpan.FromSeconds(seconds));
                continue;
            }
            if (response.StatusCode == HttpStatusCode.Unauthorized)
                throw new AmodbException("The API key was rejected (401). Check AMODB_KEY.");
            if (!response.IsSuccessStatusCode)
                throw new AmodbException($"amodb returned status {(int)response.StatusCode}.");

            var body = await response.Content.ReadAsStringAsync();
            cache.Set(path, body);
            return JsonSerializer.Deserialize<T>(body, Options)!;
        }
    }
}
```

Run it with a title. This is the output for the first search hit, movie 550. Vote counts grow over time, so yours will differ a little.

```bash
dotnet run -- "fight club"
# Fight Club (1999), 139 min
# Mischief. Mayhem. Soap.
# IMDb: 8.8 (2,669,989 votes)
# TMDB: 8.4 (33,039 votes)
# Poster: https://img.amodb.dev/t/p/w342/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg
```

The TMDB line shows 8.4 because the code prints one decimal of the 8.437 in `ratings.tmdb.value`. The poster host `img.amodb.dev` needs no key, so you can pass the poster URL straight to a browser or an image control. See [images](https://amodb.dev/images-api/) for the size list.

## Next steps

- Search with a year or paging in [search](https://amodb.dev/search/), and filter lists with [discover](https://amodb.dev/discover/).
- Show every score and its null rules with [ratings](https://amodb.dev/ratings/).
- Move the console code into an ASP.NET Core service with `AddHttpClient` and `IMemoryCache`.
- Compare the same task in [Java](https://amodb.dev/java-movie-api/) or [Go](https://amodb.dev/go-movie-api/).

## Questions and answers

### Do I need a NuGet package to call the API from C#?

No. HttpClient and System.Text.Json ship with .NET 8, so this tutorial adds no packages. Send the key in an Authorization Bearer header, or pass it as the api_key query parameter if you prefer.

### How do I read the Retry-After header in .NET?

Use response.Headers.RetryAfter. When the server sends a number of seconds, its Delta property holds a TimeSpan. Wait that long, capped at 30 seconds, and retry at most twice before reporting the 429.

### Should I create one HttpClient for every request?

No. Create one instance and reuse it, because each new instance opens fresh connections. In ASP.NET Core register a typed client with AddHttpClient and inject it. A console app can keep a single instance for its whole run.

### Can I reuse C# models written for TMDB v3?

Yes. For the endpoints amodb offers, paths, parameters and JSON fields match TMDB v3, so models you already wrote deserialize unchanged. Point the base address at https://api.amodb.dev/3/ and use your amodb key.

### Why does the card print not known for a rating?

A rating source is null when amodb has no score for that title. Treat null as unknown, never as zero, and print only the sources that are present. New fields can appear over time, so ignore fields you do not know.

## Related pages

- [Java movie API tutorial](https://amodb.dev/java-movie-api/): Build a Java episode guide with the JDK HttpClient and Jackson records: fetch a season, honor Retry-After on a 429 and cache replies in files. Java 17 or newer.
- [Search API](https://amodb.dev/search/): Search API with the same paths and JSON as TMDB v3. Find movies, TV shows, people, collections, companies and keywords by text, with year filters and paging.
- [Movie details API](https://amodb.dev/movies/): Movie details API with the same paths and JSON as TMDB v3. Get one movie by id, add credits, ratings and moods with append_to_response, in 14 languages.
- [Movie and TV ratings](https://amodb.dev/ratings/): Movie ratings API that returns IMDb, Kinopoisk, Metacritic, Rotten Tomatoes and TMDB scores for a title in one call, with null for any source not known.
- [Authentication](https://amodb.dev/authentication/): How to send your amodb key: api_key in the query, an Authorization Bearer header, or apikey for OMDb calls. Key format, storage, rotation and the 401 errors.
- [Rate limits](https://amodb.dev/rate-limits/): amodb limits each key by requests per minute in a sliding 60 second window: 20, 40 or 120 by plan. Read the rate limit headers and handle 429 with Retry-After.

Updated: 2026-10-18. Source: https://amodb.dev/csharp-movie-api/
