Skip to the content.

Call a REST API with curl in C#

To call a REST API with curl in C#, hand CurlDotNet the curl command you already know (curl -X POST ... -H 'Authorization: Bearer ...') and read the typed result. This recipe walks through GET, POST, PUT, PATCH and DELETE with -X, sending a bearer token, the fluent builder, a reusable LibCurl client that carries the token for you, and turning the JSON reply into a C# object with EnsureSuccess() and ParseJson<T>().

The problem

You have an API’s documentation written in curl examples and want to call the same endpoints from .NET without rewriting each one for HttpClient.

Solution 1: run the curl command string

using System;
using System.Threading.Tasks;
using CurlDotNet;

var result = await Curl.ExecuteAsync(
    "curl https://api.github.com/repos/curl/curl");

Console.WriteLine(result.StatusCode);   // 200
Console.WriteLine(result.Body);         // JSON describing the repository

Like curl, CurlDotNet sends User-Agent: curl/8.7.1 and Accept: */* by default, so APIs that insist on a User-Agent (GitHub’s does) work without extra flags. Use -A 'MyApp/1.0' when you want to identify your own app.

Without -f, HTTP error statuses do not throw; a 404 comes back as a result with IsSuccess == false and StatusCode == 404.

Every method via -X

var created = await Curl.ExecuteAsync(@"
    curl -X POST https://httpbin.org/post --json '{""name"": ""widget""}'");

var replaced = await Curl.ExecuteAsync(@"
    curl -X PUT https://httpbin.org/put --json '{""name"": ""widget"", ""qty"": 2}'");

var patched = await Curl.ExecuteAsync(@"
    curl -X PATCH https://httpbin.org/patch --json '{""qty"": 3}'");

var deleted = await Curl.ExecuteAsync("curl -X DELETE https://httpbin.org/delete");

Console.WriteLine($"{created.StatusCode} {replaced.StatusCode} {patched.StatusCode} {deleted.StatusCode}");

--json sets Content-Type and Accept to application/json; -H 'Content-Type: application/json' -d '{...}' is the long form. See HTTP methods.

Send a bearer token

Keep secrets out of source: read the token from an environment variable and interpolate it.

string token = Environment.GetEnvironmentVariable("GITHUB_TOKEN");

var result = await Curl.ExecuteAsync($@"
    curl https://api.github.com/user \
      -H 'Authorization: Bearer {token}' \
      -H 'Accept: application/vnd.github+json' \
      -A 'MyApp/1.0'");

Console.WriteLine(result.StatusCode);   // 200, or 401 if the token is wrong

Solution 2: the fluent builder

using CurlDotNet.Core;

var result = await CurlRequestBuilder
    .Post("https://httpbin.org/post")
    .WithBearerToken(token)
    .WithJson(new { name = "widget", qty = 2 })
    .WithTimeout(TimeSpan.FromSeconds(15))
    .ExecuteAsync();

Console.WriteLine(result.StatusCode);

CurlRequestBuilder.Get/Post/Put/Patch/Delete/Head(url) pick the method; Request("OPTIONS", url) covers anything else. .ToCurlCommand() returns the curl string the builder will execute.

Solution 3: a reusable client with LibCurl

When one API is called many times, create a LibCurl instance once with the headers, token and timeout, and reuse it.

using CurlDotNet.Lib;

using var api = new LibCurl()
    .WithBearerToken(token)
    .WithHeader("Accept", "application/vnd.github+json")
    .WithUserAgent("CurlDotNet-Recipe")
    .WithTimeout(TimeSpan.FromSeconds(15));

var me = await api.GetAsync("https://api.github.com/user");
var starred = await api.GetAsync("https://api.github.com/user/starred");

Console.WriteLine($"{me.StatusCode} {starred.StatusCode}");

LibCurl also has PostAsync(url, data), PutAsync, PatchAsync, DeleteAsync and HeadAsync; an object passed as data is serialized to JSON. WithUserAgent is optional; it replaces the default curl/8.7.1. Unlike Curl.ExecuteAsync, LibCurl reports a transport failure as a failed CurlResult (its Exception property is set) instead of throwing, and throws OperationCanceledException when you cancel.

Read the reply as a C# object

using System.Text.Json.Serialization;

public class Repo
{
    public string Description { get; set; }                                   // "description": case-insensitive match
    [JsonPropertyName("full_name")] public string FullName { get; set; }      // snake_case needs the attribute
    [JsonPropertyName("stargazers_count")] public int Stars { get; set; }
}

var result = await Curl.ExecuteAsync("curl https://api.github.com/repos/curl/curl");
var repo = result.EnsureSuccess().ParseJson<Repo>();

Console.WriteLine($"{repo.FullName} has {repo.Stars} stars");

EnsureSuccess() returns the same result on 2xx and throws CurlDotNet.Core.CurlHttpException (with StatusCode, ResponseBody, ResponseHeaders) otherwise, so you can chain straight into ParseJson<T>(). Property names are matched case-insensitively, so description binds to Description; snake_case names need [JsonPropertyName]. A List<Repo> works for array replies.

Handle failures

using CurlDotNet.Exceptions;

try
{
    var repo = (await Curl.ExecuteAsync("curl -m 10 https://api.github.com/repos/nope/nope"))
        .EnsureSuccess().ParseJson<Repo>();
}
catch (CurlDotNet.Core.CurlHttpException ex)   // 4xx / 5xx
{
    Console.WriteLine($"HTTP {ex.StatusCode}: {ex.ResponseBody}");
}
catch (CurlOperationTimeoutException) { Console.WriteLine("timed out"); }
catch (CurlCouldntConnectException ex) { Console.WriteLine($"cannot reach {ex.Host}"); }

CurlHttpException exists in both CurlDotNet.Core and CurlDotNet.Exceptions; qualify it as above when both namespaces are imported.

Variations

API key in a header or query string

var byHeader = await Curl.ExecuteAsync(
    "curl https://httpbin.org/get -H 'X-Api-Key: YOUR_KEY'");

string key = Uri.EscapeDataString("YOUR_KEY");
var byQuery = await Curl.ExecuteAsync($"curl 'https://httpbin.org/get?api_key={key}'");

Basic auth (-u)

var result = await Curl.ExecuteAsync("curl -u 'user:passwd' https://httpbin.org/basic-auth/user/passwd");
Console.WriteLine(result.StatusCode);   // 200

Digest, NTLM and other schemes are covered in Authentication schemes.

Several calls at once

var results = await Curl.ExecuteManyAsync(
    "curl https://api.github.com/repos/curl/curl",
    "curl https://api.github.com/repos/dotnet/runtime");

foreach (var r in results)
    Console.WriteLine(r.StatusCode);

Troubleshooting

401 Unauthorized. The token is missing, expired, or in the wrong header. Confirm it is Authorization: Bearer <token> (some APIs use Token <token> or X-Api-Key) and that the environment variable is actually set in the process that runs your app.

403 Forbidden from api.github.com. Usually rate limiting: unauthenticated calls are capped per hour. Send a token, and if the reply has a Retry-After header, wait that long. (GitHub also requires a User-Agent; CurlDotNet sends curl/8.7.1 by default, so only check this if you removed it with -A '' or -H 'User-Agent:'.)

404 Not Found. Check the path and any ids in the URL. result.Body normally carries the API’s own message.

429 Too Many Requests. Slow down and honour Retry-After. --retry 3 does this for you: it retries 429 (and 408/5xx) responses and waits as long as the Retry-After header asks. See curl –retry in C#.

ParseJson<T>() throws InvalidOperationException. The body is empty (204, HEAD) or not JSON (an HTML error page); the JSON parser’s error is the inner exception. Call EnsureSuccess() first and inspect result.Body or result.AsJsonDynamic().

The reply is a redirect (301/302). Add -L to follow it; see Follow Redirects.