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.

.cs C# Updated View as Markdown

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. 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:

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 page covers the api_key query parameter as an alternative to the header.

Shell
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"

Step 2: Create the project

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.

Shell
dotnet new console -n MovieLookup -f net8.0
cd MovieLookup

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.

C#
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.

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 page lists every other field.

C#
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);

Step 5: Handle errors and rate limits

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

StatusMeaningWhat the app does
401Key missing, wrong or suspendedPrint a message and stop, a retry cannot help.
402The plan period has endedPrint a message and stop. Renew on pricing.
404No record for that idReport it and exit.
429Over your requests per minuteWait Retry-After seconds, capped at 30, and retry at most twice.
500 or 504Server side failureReport the status and run the command again later.

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 and errors for the full lists.

C#
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
}

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.

C#
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.

C#
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.

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 for the size list.

Shell
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

Next steps

  • Search with a year or paging in search, and filter lists with discover.
  • Show every score and its null rules with ratings.
  • Move the console code into an ASP.NET Core service with AddHttpClient and IMemoryCache.
  • Compare the same task in Java or Go.