C# examples
Snippets target .NET 8+ and use only the BCL (HttpClient, System.Text.Json). The same code runs in a console app, ASP.NET Core service, or .NET MAUI client.
using System.Net.Http.Headers;using System.Net.Http.Json;using System.Text.Json;using System.Text.Json.Serialization;
public static class PhotoPick{ public const string Api = "https://api.photopick.cz/api/v1";
public static HttpClient CreateClient() { var token = Environment.GetEnvironmentVariable("PHOTOPICK_API_KEY") ?? throw new InvalidOperationException("PHOTOPICK_API_KEY not set");
var http = new HttpClient { BaseAddress = new Uri(Api + "/") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); return http; }
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, };}1. Verify your key (/me)
Section titled “1. Verify your key (/me)”public record RateLimit(int Limit, int Remaining, int Reset);public record Me(int ApiKeyId, int CustomerId, string Name, string[] Scopes, RateLimit RateLimit);
using var http = PhotoPick.CreateClient();
var me = await http.GetFromJsonAsync<Me>("me", PhotoPick.Json) ?? throw new InvalidOperationException("empty body");
Console.WriteLine(string.Join(", ", me.Scopes));Console.WriteLine($"remaining: {me.RateLimit.Remaining}");Required scope: customer:read.
2. List photos (paginated)
Section titled “2. List photos (paginated)”IAsyncEnumerable<Photo> yields every photo across pages — works with await foreach and supports CancellationToken:
public record Photo(int Id, string Filename, long Bytes, string? TakenAt, int[] TagIds);public record Pagination(string? NextCursor, bool HasMore);public record Page<T>(IReadOnlyList<T> Data, Pagination Pagination);
public static async IAsyncEnumerable<Photo> IteratePhotosAsync( HttpClient http, int pageSize = 100, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default){ string? cursor = null; while (true) { var query = $"photos?limit={pageSize}" + (cursor is null ? "" : $"&cursor={Uri.EscapeDataString(cursor)}"); var page = await http.GetFromJsonAsync<Page<Photo>>(query, PhotoPick.Json, ct) ?? throw new InvalidOperationException("empty body");
foreach (var p in page.Data) yield return p;
if (!page.Pagination.HasMore || page.Pagination.NextCursor is null) yield break; cursor = page.Pagination.NextCursor; }}
await foreach (var photo in IteratePhotosAsync(http)) Console.WriteLine($"{photo.Id} {photo.Filename}");Required scope: photos:read.
3. Fetch one photo + download original
Section titled “3. Fetch one photo + download original”HttpCompletionOption.ResponseHeadersRead + CopyToAsync streams the body to disk without buffering.
public static async Task<Photo> DownloadPhotoAsync( HttpClient http, int photoId, string destination, CancellationToken ct = default){ var meta = await http.GetFromJsonAsync<Photo>($"photos/{photoId}", PhotoPick.Json, ct) ?? throw new InvalidOperationException("empty body");
using var res = await http.GetAsync( $"photos/{photoId}/download", HttpCompletionOption.ResponseHeadersRead, ct); res.EnsureSuccessStatusCode();
await using var fs = File.Create(destination); await res.Content.CopyToAsync(fs, ct); return meta;}
var meta = await DownloadPhotoAsync(http, 1234, "photo-1234.jpg");Console.WriteLine($"Saved {meta.Filename} ({meta.Bytes} bytes)");Required scope: photos:read.
4. Tag CRUD
Section titled “4. Tag CRUD”public record Tag(int? Id, string Name, string? Color);public record TagAssignment(int[] TagIds);
// Createvar createRes = await http.PostAsJsonAsync("tags", new Tag(null, "Selects", "#e94b60"), PhotoPick.Json);createRes.EnsureSuccessStatusCode();var created = await createRes.Content.ReadFromJsonAsync<Tag>(PhotoPick.Json) ?? throw new InvalidOperationException("empty body");
// Attach (full replace — pass the complete tag list)var attachRes = await http.PutAsJsonAsync("photos/1234/tags", new TagAssignment(new[] { created.Id!.Value }), PhotoPick.Json);attachRes.EnsureSuccessStatusCode();
// Renamevar renameRes = await http.PatchAsJsonAsync($"tags/{created.Id}", new Tag(null, "Final selects", null), PhotoPick.Json);renameRes.EnsureSuccessStatusCode();
// Deletevar delRes = await http.DeleteAsync($"tags/{created.Id}");Console.WriteLine((int)delRes.StatusCode); // 204Required scopes: tags:write (create/rename/delete), photos:write (attach), tags:read (list).
Retry pattern (rate limits + 5xx)
Section titled “Retry pattern (rate limits + 5xx)”The idiomatic .NET answer is Polly wired via Microsoft.Extensions.Http.Resilience. The snippet below shows the same logic without dependencies so you can see the moving parts.
public static async Task<HttpResponseMessage> WithRetryAsync( Func<Task<HttpResponseMessage>> call, int maxAttempts = 5, CancellationToken ct = default){ for (var attempt = 1; attempt <= maxAttempts; attempt++) { var res = await call(); if (res.IsSuccessStatusCode || ((int)res.StatusCode < 500 && (int)res.StatusCode != 429)) { return res; }
var retryAfter = res.Headers.RetryAfter?.Delta?.TotalSeconds ?? double.Parse(res.Headers.RetryAfter?.ToString() ?? attempt.ToString()); var backoff = TimeSpan.FromSeconds(Math.Min(retryAfter, Math.Pow(2, attempt))); res.Dispose();
await Task.Delay(backoff, ct); } throw new InvalidOperationException("retry budget exhausted");}
var res = await WithRetryAsync(() => http.GetAsync("photos"));See Rate limits and Errors for the full vocabulary.