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. 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_. The authentication page covers the api_key query parameter as an alternative to the header.
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.
dotnet new console -n MovieLookup -f net8.0
cd MovieLookupStep 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.
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_. 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.
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.
| 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. |
| 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. |
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.
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.
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.
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.
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