Skip to content

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,
};
}
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.

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.

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.

public record Tag(int? Id, string Name, string? Color);
public record TagAssignment(int[] TagIds);
// Create
var 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();
// Rename
var renameRes = await http.PatchAsJsonAsync($"tags/{created.Id}",
new Tag(null, "Final selects", null), PhotoPick.Json);
renameRes.EnsureSuccessStatusCode();
// Delete
var delRes = await http.DeleteAsync($"tags/{created.Id}");
Console.WriteLine((int)delRes.StatusCode); // 204

Required scopes: tags:write (create/rename/delete), photos:write (attach), tags:read (list).

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.