> ## Content Index
> Fetch the complete content index at: https://roundproxies.com/blog/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to make HTTP requests in C: libcurl, sockets and TLS
- URL: https://roundproxies.com/blog/requests-c/
- Published: 2025-10-06T22:17:34.000Z
- Updated: 2026-09-24T13:54:26.000Z
- Description: Make HTTP requests in C with libcurl or raw sockets: GET, POST, HTTPS, proxies. Tested code.
- Author: Marius Bernard
- Tags: Knowledgebase, #dated-68e43f544fa498a2c6ee1faa

C has no HTTP client in its standard library. To make HTTP requests in C you either link libcurl or write the protocol yourself on top of a TCP socket.

That choice decides your build flags and whether HTTPS works at all.

Most answers online cover half the job. They send a GET over a socket, print whatever comes back, and stop.

The parts that break in production come after that: chunked bodies, certificate checks, SNI and proxies.

I'll start with libcurl, because it's the right default, then build the same requests by hand so you can see what libcurl does for you.

Every Linux snippet below compiles with `gcc -Wall -Wextra -Werror` and was run against local test servers for this update.

## How do you make an HTTP request in C?

HTTP requests in C go through either libcurl or a raw TCP socket. With libcurl you create a handle, set `CURLOPT_URL`, call `curl_easy_perform()` and collect the body in a write callback. With sockets you resolve the host, connect, send a CRLF-terminated request and read until the server closes. Pick libcurl unless you can't add dependencies.

Under both options sits the same thing: an HTTP/1.1 request is plain text on a TCP connection.

Every line ends in `\r\n`, and an empty line marks the end of the headers.

This is the exact text a client sends for a simple GET, with the carriage returns and line feeds made visible:

```text
GET /get HTTP/1.1␍␊
Host: httpbin.org␍␊
User-Agent: my-c-client/1.0␍␊
Connection: close␍␊
␍␊

```

The `Host` header is mandatory in HTTP/1.1 because one IP address usually serves many sites.

The final empty line tells the server you're done. Leave it off and the server waits for more headers.

## Which approach should you use for HTTP requests in C?

You have four realistic options, and they differ mostly in how much of HTTP you end up writing yourself.

| Approach          | HTTPS                                | Redirects, chunked, gzip | Dependency                     | Pick it when                                            |
| ----------------- | ------------------------------------ | ------------------------ | ------------------------------ | ------------------------------------------------------- |
| libcurl           | Built in, verification on by default | Handled                  | libcurl (plus its TLS library) | You're on Linux, macOS or BSD and can install a package |
| POSIX sockets     | No                                   | You write it             | None                           | Plain HTTP on embedded or locked-down machines          |
| Sockets + OpenSSL | Yes, you configure verification      | You write it             | OpenSSL                        | You need HTTPS but can't ship libcurl                   |
| WinHTTP           | Yes                                  | Handled                  | Ships with Windows             | Windows-only tools that shouldn't bundle DLLs           |

My default is libcurl. It's packaged for every Unix-like system I've deployed to.

It also handles certificate checks, cookies, proxies and HTTP/2 for you, which is a project's worth of code if you write it yourself.

Raw sockets earn their place on embedded targets and on machines where you can't install anything.

You also see every byte on the wire, which makes a broken server response much easier to debug.

Single-header wrappers such as requests.c sit between the two. They're fine for prototypes, but you inherit whatever edge cases their author skipped.

## Prerequisites

You need a C compiler plus the libcurl and OpenSSL development headers. The runtime libraries are often installed already; the headers usually aren't.

```bash
# Debian / Ubuntu
sudo apt install build-essential libcurl4-openssl-dev libssl-dev

# Fedora / RHEL
sudo dnf install gcc libcurl-devel openssl-devel

# macOS: curl headers come with the Command Line Tools, OpenSSL from Homebrew
xcode-select --install
brew install openssl@3

# Windows: see the Windows section below
vcpkg install curl openssl

```

Run `curl-config --version` afterwards. It prints the libcurl version you'll compile against, and it fails if the dev package is missing.

## Step 1: Send a GET request with libcurl

The smallest useful libcurl program is about fifteen lines. It fetches a URL and lets libcurl print the body to stdout.

```c
#include <stdio.h>
#include <curl/curl.h>

int main(void) {
    curl_global_init(CURL_GLOBAL_DEFAULT);   /* once per program */
    CURL *curl = curl_easy_init();
    if (!curl) return 1;

    curl_easy_setopt(curl, CURLOPT_URL, "https://httpbin.org/get");
    CURLcode rc = curl_easy_perform(curl);   /* body goes to stdout by default */
    if (rc != CURLE_OK)
        fprintf(stderr, "request failed: %s\n", curl_easy_strerror(rc));

    curl_easy_cleanup(curl);
    curl_global_cleanup();
    return rc == CURLE_OK ? 0 : 1;
}

```

Compile with `gcc -Wall -o get get.c -lcurl`. Put `-lcurl` after the source file; the linker resolves libraries left to right.

`CURLE_OK` means the transfer completed. It does not mean the server said 200, and a 404 page comes back as `CURLE_OK` too. The next step fixes that.

## Step 2: Capture the response body and status code

Printing to stdout is useless once you want to parse the response. Start a new file with a small struct that holds the body and its length:

```c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <curl/curl.h>

struct buffer {
    char  *data;
    size_t len;
};

```

Tracking the length separately matters for binary responses, where a zero byte in the middle would fool `strlen()`.

libcurl streams the body to a callback in pieces, so the callback grows the buffer as they arrive:

```c
/* libcurl calls this for each piece of the body as it arrives. */
static size_t on_body(char *ptr, size_t size, size_t nmemb, void *userdata) {
    size_t n = size * nmemb;               /* size is always 1; multiply anyway */
    struct buffer *buf = userdata;
    char *grown = realloc(buf->data, buf->len + n + 1);
    if (!grown) return 0;                  /* returning less than n aborts the transfer */
    buf->data = grown;
    memcpy(buf->data + buf->len, ptr, n);
    buf->len += n;
    buf->data[buf->len] = '\0';            /* keep it usable as a C string */
    return n;
}

```

The callback can fire once or hundreds of times per response, depending on how the server sends data. Returning anything other than `n` tells libcurl to stop with `CURLE_WRITE_ERROR`.

Now wire the callback into `main()` and set a User-Agent. libcurl sends no User-Agent header at all unless you set one, and some servers reject requests without it.

```c
int main(void) {
    struct buffer body = {0};
    long status = 0;

    curl_global_init(CURL_GLOBAL_DEFAULT);
    CURL *curl = curl_easy_init();
    if (!curl) return 1;

    curl_easy_setopt(curl, CURLOPT_URL, "https://httpbin.org/get");
    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, on_body);
    curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
    curl_easy_setopt(curl, CURLOPT_USERAGENT, "my-c-client/1.0");

```

`CURLOPT_WRITEDATA` is the pointer libcurl hands back to your callback as `userdata`. That's how the callback finds your buffer without a global variable.

The second half runs the request, reads the status code and cleans up:

```c
    CURLcode rc = curl_easy_perform(curl);
    if (rc != CURLE_OK) {
        fprintf(stderr, "request failed: %s\n", curl_easy_strerror(rc));
    } else {
        curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
        printf("HTTP %ld, %zu bytes\n%s\n", status, body.len,
               body.data ? body.data : "");
    }

    curl_easy_cleanup(curl);
    curl_global_cleanup();
    free(body.data);
    return rc == CURLE_OK ? 0 : 1;
}

```

`body.data` stays `NULL` when the response has no body, so the ternary avoids passing `NULL` to `%s`. Always read `CURLINFO_RESPONSE_CODE` before deciding a request worked.

## Step 3: Send POST requests (JSON, form, multipart)

A POST changes one option in most cases, but each body type has a trap. These snippets go inside `main()` after `curl_easy_init()`, reusing the buffer and callback from Step 2.

### JSON body

Setting `CURLOPT_POSTFIELDS` switches the method to POST. You still have to set `Content-Type` yourself, because libcurl assumes form encoding.

```c
const char *json = "{\"name\":\"test\",\"count\":3}";

struct curl_slist *hdrs = NULL;
hdrs = curl_slist_append(hdrs, "Content-Type: application/json");
hdrs = curl_slist_append(hdrs, "Accept: application/json");

curl_easy_setopt(curl, CURLOPT_URL, "https://httpbin.org/post");
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json);   /* implies POST; not copied */

CURLcode rc = curl_easy_perform(curl);
/* ...check rc and the status code as in Step 2... */
curl_slist_free_all(hdrs);                          /* after the transfer, not before */

```

`CURLOPT_POSTFIELDS` stores your pointer without copying it. The string must stay alive until `curl_easy_perform()` returns, which is fine for a literal but not for a buffer you free early.

### URL-encoded form

Form values need percent-encoding. `curl_easy_escape()` does it, and `CURLOPT_COPYPOSTFIELDS` copies the data so a stack buffer is safe.

```c
char *name = curl_easy_escape(curl, "Jane Doe & Co", 0);   /* percent-encode */
char fields[256];
snprintf(fields, sizeof fields, "name=%s&lang=c", name);
curl_free(name);

curl_easy_setopt(curl, CURLOPT_URL, "https://httpbin.org/post");
curl_easy_setopt(curl, CURLOPT_COPYPOSTFIELDS, fields);   /* copies the buffer */
CURLcode rc = curl_easy_perform(curl);

```

The server receives `name=Jane%20Doe%20%26%20Co&lang=c`. Without escaping, the `&` in the value would split it into a second, broken field.

### Multipart file upload

Older tutorials use `curl_formadd()` for this. It has been deprecated since libcurl 7.56.0; the MIME API below replaces it.

```c
curl_mime *form = curl_mime_init(curl);

curl_mimepart *part = curl_mime_addpart(form);
curl_mime_name(part, "username");
curl_mime_data(part, "admin", CURL_ZERO_TERMINATED);

part = curl_mime_addpart(form);
curl_mime_name(part, "report");
curl_mime_filedata(part, "report.pdf");   /* streamed from disk */

curl_easy_setopt(curl, CURLOPT_URL, "https://httpbin.org/post");
curl_easy_setopt(curl, CURLOPT_MIMEPOST, form);
CURLcode rc = curl_easy_perform(curl);
curl_mime_free(form);

```

Don't combine `CURLOPT_MIMEPOST` with `CURLOPT_POSTFIELDS` on the same handle. Each one replaces the other's body, and you'll send whichever you set last.

## Step 4: Set timeouts, redirects and connection reuse

libcurl's defaults suit a command-line tool, not a long-running program. Redirects are off, and there is no timeout, so a stalled server can hang your process forever.

```c
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);   /* off by default */
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L);   /* seconds for TCP + TLS setup */
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 20L);         /* hard cap on the whole transfer */
curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, "");  /* any encoding your build supports */
curl_easy_setopt(curl, CURLOPT_FAILONERROR, 1L);      /* 4xx/5xx become an error code */

```

Note the `L` suffix on every number. `curl_easy_setopt()` is variadic and reads a `long`; passing a plain `int` is undefined behavior and breaks on some 64-bit platforms.

With `CURLOPT_FAILONERROR` set, a 404 returns error 22, "HTTP response code said error".

Leave it off if you need the body of error responses, since many APIs put the error details there.

### Reuse the handle for repeat requests

Each easy handle keeps a small connection cache. Reusing it for requests to the same host skips the TCP and TLS handshakes after the first one.

```c
const char *urls[] = { "https://httpbin.org/get", "https://httpbin.org/uuid" };

for (int i = 0; i < 2; i++) {
    long new_conns = 0;
    curl_easy_setopt(curl, CURLOPT_URL, urls[i]);
    if (curl_easy_perform(curl) != CURLE_OK) continue;
    curl_easy_getinfo(curl, CURLINFO_NUM_CONNECTS, &new_conns);
    printf("%s -> new connections: %ld\n", urls[i], new_conns);
}

```

The first request prints `new connections: 1` and the second prints `0`, as long as the server keeps the connection open.

Creating a fresh handle per request throws that away. It's the most common libcurl performance mistake I see.

One rule if you add threads: a handle belongs to one thread at a time. Give each worker its own handle, and call `curl_global_init()` once before any threads start.

## Step 5: Build the same GET request with raw sockets

Everything libcurl did in Steps 1 to 4 now becomes your code. This version handles plain HTTP only; Step 7 adds TLS on top.

First, resolve the hostname and connect. `getaddrinfo()` can return several addresses (IPv6 and IPv4, or multiple IPs), so try each until one connects. The function returns a connected socket, or -1.

```c
int connect_to(const char *host, const char *port) {
    struct addrinfo hints = {0}, *res, *ai;
    hints.ai_family = AF_UNSPEC;             /* whichever family works */
    hints.ai_socktype = SOCK_STREAM;
    int rc = getaddrinfo(host, port, &hints, &res);
    if (rc != 0) {
        fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));
        return -1;
    }
    int fd = -1;
    for (ai = res; ai; ai = ai->ai_next) {   /* try each address in turn */
        fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
        if (fd < 0) continue;
        if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) break;
        close(fd);
        fd = -1;
    }
    freeaddrinfo(res);
    return fd;
}

```

`getaddrinfo()` reports failures through its return value, not `errno`, so `perror()` prints a misleading message here. Use `gai_strerror()`.

The file needs these headers at the top. The feature-test macro exposes the POSIX declarations when you compile with `-std=c11`.

```c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <signal.h>
#include <errno.h>
#include <unistd.h>
#include <netdb.h>
#include <sys/socket.h>
#include <sys/types.h>

```

Next, sending. `send()` is allowed to write fewer bytes than you asked for, especially for large bodies, so wrap it in a loop.

```c
/* send() may write fewer bytes than asked. Loop until everything is out. */
int send_all(int fd, const char *buf, size_t len) {
    while (len > 0) {
        ssize_t n = send(fd, buf, len, 0);
        if (n < 0) {
            if (errno == EINTR) continue;    /* interrupted by a signal: retry */
            return -1;
        }
        buf += n;
        len -= (size_t)n;
    }
    return 0;
}

```

The same goes for reading, in the other direction. One `recv()` call returns whatever has arrived so far, which is rarely the whole response.

```c
/* Read until the server closes the connection. Caller frees the result. */
char *read_all(int fd, size_t *out_len) {
    size_t cap = 8192, len = 0;
    char *buf = malloc(cap);
    if (!buf) return NULL;
    for (;;) {
        if (cap - len < 4096) {              /* keep room for the next read */
            char *grown = realloc(buf, cap *= 2);
            if (!grown) { free(buf); return NULL; }
            buf = grown;
        }
        ssize_t n = recv(fd, buf + len, cap - len - 1, 0);
        if (n == 0) break;                   /* server closed: response complete */
        if (n < 0) { if (errno == EINTR) continue; free(buf); return NULL; }
        len += (size_t)n;
    }
    buf[len] = '\0';
    *out_len = len;
    return buf;
}

```

Reading until the server closes works because the request sends `Connection: close`. With keep-alive you'd have to use `Content-Length` or the chunk framing to know where the response ends.

Now the request itself. Build it with `snprintf()` and check for truncation, because a silently cut-off header block produces confusing server errors.

```c
int main(void) {
    const char *host = "httpbin.org", *path = "/get";
    signal(SIGPIPE, SIG_IGN);       /* a closed socket should return an error, not kill us */

    int fd = connect_to(host, "80");
    if (fd < 0) return 1;

    char req[512];
    int n = snprintf(req, sizeof req,
        "GET %s HTTP/1.1\r\n"
        "Host: %s\r\n"
        "User-Agent: my-c-client/1.0\r\n"
        "Accept: */*\r\n"
        "Connection: close\r\n"     /* lets us read until EOF */
        "\r\n", path, host);        /* this empty line ends the request */
    if (n < 0 || (size_t)n >= sizeof req) return 1;
    if (send_all(fd, req, (size_t)n) < 0) { close(fd); return 1; }

```

On Linux, writing to a socket the server already closed raises `SIGPIPE`, which kills your process by default. Ignoring it turns that into an ordinary `EPIPE` error from `send()`.

For a POST, add `Content-Type` and `Content-Length` headers and append the body after the blank line.

A `Content-Length` that doesn't match the body makes the server wait for bytes that never come.

## Step 6: Parse the status line, headers and body

The response arrives as one blob: status line, headers, a blank line, then the body. Splitting it takes a `strstr()` for the blank line and an `sscanf()` for the status code.

```c
struct http_response {
    int    status;
    char  *headers;   /* NUL-terminated header block, status line included */
    char  *body;
    size_t body_len;
};

/* Case-insensitive search for needle anywhere in the header block. */
int header_contains(const char *headers, const char *needle) {
    size_t n = strlen(needle);
    for (const char *p = headers; *p; p++)
        if (strncasecmp(p, needle, n) == 0) return 1;
    return 0;
}

```

Header names are case-insensitive, so `Transfer-Encoding` and `transfer-encoding` must match. `strncasecmp()` comes from `<strings.h>` on POSIX systems.

```c
/* Split raw bytes into status, headers and body. Modifies raw in place. */
int parse_response(char *raw, size_t len, struct http_response *r) {
    char *sep = strstr(raw, "\r\n\r\n");            /* blank line ends the headers */
    if (!sep) return -1;
    *sep = '\0';
    if (sscanf(raw, "HTTP/%*d.%*d %d", &r->status) != 1) return -1;
    r->headers  = raw;
    r->body     = sep + 4;
    r->body_len = len - (size_t)(r->body - raw);
    if (header_contains(raw, "transfer-encoding: chunked")) {
        long n = dechunk(r->body, r->body_len);     /* defined in the next section */
        if (n < 0) return -1;
        r->body_len = (size_t)n;
        r->body[n] = '\0';
    }
    return 0;
}

```

The body is tracked by length, not by a terminating NUL, because images and compressed data can contain zero bytes.

Place this function after `dechunk()` in your file, or add a prototype.

Finish `main()` by reading, parsing and printing:

```c
    size_t len = 0;
    char *raw = read_all(fd, &len);
    close(fd);
    if (!raw) return 1;

    struct http_response r;
    if (parse_response(raw, len, &r) == 0)
        printf("status %d, %zu body bytes\n%s\n", r.status, r.body_len, r.body);
    else
        fprintf(stderr, "could not parse response\n");
    free(raw);
    return 0;
}

```

Compile with `gcc -std=c11 -Wall -o rawget rawget.c`. No libraries needed, which is the whole appeal on constrained systems.

## Decoding chunked responses by hand

This is the section most C examples skip, and it's the one that breaks first.

When an HTTP/1.1 server doesn't know the body size in advance, it sends the body in chunks, each prefixed with its length in hex.

A raw chunked response from my test server looks like this on the wire:

```text
HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked

7;ext=1
Hello, 
8;ext=1
chunked 
15;ext=1
world!
world!
world!

0

```

Print that without decoding and you get stray hex numbers in your data.

The format is defined in [RFC 9112, section 7.1](https://www.rfc-editor.org/rfc/rfc9112#section-7.1): size in hex, optional extensions after `;`, CRLF, the payload, CRLF, repeated until a zero-size chunk.

This decoder rewrites the body in place, so it needs no extra allocation:

```c
/* Decode a chunked body in place. buf must be NUL-terminated at buf[len].
   Returns the decoded length, or -1 if the body is malformed or truncated. */
long dechunk(char *buf, size_t len) {
    char *src = buf, *dst = buf, *end = buf + len;
    while (src < end) {
        char *p;
        unsigned long size = strtoul(src, &p, 16);  /* chunk size is hex */
        if (p == src) return -1;
        while (p + 1 < end && !(p[0] == '\r' && p[1] == '\n'))
            p++;                                    /* skip ";ext=..." if present */
        char *data = p + 2;
        if (size == 0) return (long)(dst - buf);    /* last chunk: done */
        size_t avail = data <= end ? (size_t)(end - data) : 0;
        if (avail < size + 2) return -1;            /* truncated chunk */
        memmove(dst, data, size);                   /* shift the payload left */
        dst += size;
        src = data + size + 2;                      /* skip payload and its CRLF */
    }
    return -1;                                      /* never saw the 0-size chunk */
}

```

The bounds check matters more than it looks. A server that dies mid-response leaves a truncated final chunk, and a decoder that trusts the size field will read past your buffer.

For this update, the decoder ran against 300 randomly chunked binary payloads and 100 truncated or garbage inputs under AddressSanitizer.

It reproduced every payload and rejected every bad input without a memory error.

There's also a shortcut. Send `GET /get HTTP/1.0` instead of `HTTP/1.1`, and servers can't use chunked encoding at all, since it doesn't exist in HTTP/1.0.

You lose keep-alive, which rarely matters for a one-shot client.

## Step 7: Add HTTPS with OpenSSL

Almost every public endpoint is HTTPS now, so plain sockets only get you so far. OpenSSL wraps the connected socket, and the rest of your code swaps `send()`/`recv()` for `SSL_write()`/`SSL_read()`.

<!-- DIAGRAM: layered view. TCP socket (connect\_to) at the bottom, OpenSSL SSL object on top of it, HTTP request text on top. Callouts: "SNI = which site", "SSL\_set1\_host = is this cert for that site", "CA store = do we trust the issuer". -->

![HTTPS request in C with OpenSSL: TLS layered over a TCP socket with SNI and certificate verification](https://claude.ai/chat/openssl-https-request-c.webp)

Add these includes, and set up a context once per program:

```c
#include <openssl/ssl.h>
#include <openssl/err.h>
#include <openssl/evp.h>

SSL_CTX *tls_ctx_new(void) {
    SSL_CTX *ctx = SSL_CTX_new(TLS_client_method());
    if (!ctx) return NULL;
    SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION);
    SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL);   /* abort on a bad certificate */
    SSL_CTX_set_default_verify_paths(ctx);            /* use the system CA store */
#ifdef SSL_OP_IGNORE_UNEXPECTED_EOF
    SSL_CTX_set_options(ctx, SSL_OP_IGNORE_UNEXPECTED_EOF);  /* OpenSSL 3.x */
#endif
    return ctx;
}

```

Skip the `SSL_library_init()` and `OpenSSL_add_all_algorithms()` calls you'll see in older examples. OpenSSL 1.1.0 and later initialize themselves.

The ignore-EOF option stops OpenSSL 3 from treating a server that closes without a TLS goodbye as an error.

The tradeoff is that you can't detect truncation that way, so compare against `Content-Length` when the server sends one.

Next, the handshake. Two lines here are missing from most tutorials, and without them your client either fails or trusts the wrong server.

```c
/* Run the TLS handshake on a connected socket. Returns NULL on failure. */
SSL *tls_open(SSL_CTX *ctx, int fd, const char *host) {
    SSL *ssl = SSL_new(ctx);
    if (!ssl) return NULL;
    SSL_set_fd(ssl, fd);
    SSL_set_tlsext_host_name(ssl, host);   /* SNI: which site on this IP you want */
    SSL_set1_host(ssl, host);              /* reject certs issued for other names */
    if (SSL_connect(ssl) != 1) {
        ERR_print_errors_fp(stderr);
        long v = SSL_get_verify_result(ssl);          /* the specific cert problem */
        if (v != X509_V_OK)
            fprintf(stderr, "verify: %s\n", X509_verify_cert_error_string(v));
        SSL_free(ssl);
        return NULL;
    }
    return ssl;
}

```

Without SNI, CDN-hosted sites can't tell which certificate to present, and many abort the handshake.

Without `SSL_set1_host()`, OpenSSL checks that the certificate is valid but not that it belongs to the host you asked for. Any valid cert would pass.

`SSL_set1_host()` works from OpenSSL 1.1.0 through 3.x. The [OpenSSL docs](https://docs.openssl.org/master/man3/SSL%5Fset1%5Fhost) mark it deprecated in 4.0 in favor of `SSL_set1_dnsname()`, so expect a warning there.

Reading is the same growing-buffer loop as `read_all()`, with `SSL_read()` in place of `recv()`:

```c
char *tls_read_all(SSL *ssl, size_t *out_len) {
    size_t cap = 8192, len = 0;
    char *buf = malloc(cap);
    if (!buf) return NULL;
    for (;;) {
        if (cap - len < 4096) {
            char *grown = realloc(buf, cap *= 2);
            if (!grown) { free(buf); return NULL; }
            buf = grown;
        }
        int n = SSL_read(ssl, buf + len, (int)(cap - len - 1));
        if (n <= 0) break;                    /* closed, or an error: stop */
        len += (size_t)n;
    }
    buf[len] = '\0';
    *out_len = len;
    return buf;
}

```

On a blocking socket, `SSL_write()` either sends everything or fails, because OpenSSL leaves partial writes off by default. So unlike `send()`, it needs no loop.

The HTTPS version of `main()` reuses `connect_to()`, `parse_response()` and `dechunk()` unchanged:

```c
int main(void) {
    const char *host = "httpbin.org";
    signal(SIGPIPE, SIG_IGN);
    SSL_CTX *ctx = tls_ctx_new();
    int fd = connect_to(host, "443");
    SSL *ssl = (ctx && fd >= 0) ? tls_open(ctx, fd, host) : NULL;
    if (!ssl) return 1;

    char req[512];
    int n = snprintf(req, sizeof req, "GET /get HTTP/1.1\r\nHost: %s\r\n"
                     "Connection: close\r\n\r\n", host);
    SSL_write(ssl, req, n);

    size_t len = 0;
    char *raw = tls_read_all(ssl, &len);
    struct http_response r;
    if (raw && parse_response(raw, len, &r) == 0)
        printf("status %d\n%s\n", r.status, r.body);

```

Close down in reverse order of creation:

```c
    SSL_shutdown(ssl);          /* sends the TLS close_notify */
    SSL_free(ssl);
    SSL_CTX_free(ctx);
    close(fd);
    free(raw);
    return 0;
}

```

Compile with `gcc -std=c11 -Wall -o httpsget httpsget.c -lssl -lcrypto`. On macOS, add `-I$(brew --prefix openssl@3)/include -L$(brew --prefix openssl@3)/lib`.

## Send requests through a proxy

Proxies are where C clients usually meet reality: rate limits, geo-restricted content, or a corporate network that only allows outbound traffic through one gateway.

### With libcurl

It takes two options. libcurl picks the right mechanism automatically: an absolute-URL request for `http://` targets, a `CONNECT` tunnel for `https://`.

```c
curl_easy_setopt(curl, CURLOPT_PROXY, "http://proxy.example.com:8080");
curl_easy_setopt(curl, CURLOPT_PROXYUSERPWD, "user:password");

/* SOCKS5 instead, with DNS resolved on the proxy side: */
/* curl_easy_setopt(curl, CURLOPT_PROXY, "socks5h://proxy.example.com:1080"); */

```

The `h` in `socks5h` matters. Plain `socks5://` resolves the hostname locally, which leaks your DNS lookups and fails for names only the proxy can resolve.

Watch how a wrong password shows up, because it differs by scheme. For an `http://` URL you get `CURLE_OK` with status 407.

For `https://` you get error 56, "Failure when receiving data from the peer", because the tunnel never opened.

If no `CURLOPT_PROXY` is set, libcurl also reads the lowercase `http_proxy` and `https_proxy` environment variables. That surprises people when a program behaves differently under cron or in a container.

For rotating residential or ISP IPs, the proxy is still one `host:port`, and rotation happens on the provider's gateway.

Pointing this code at a Roundproxies endpoint, for example, changes only the two strings above.

The [differences between HTTP, HTTPS and SOCKS5 proxies](https://roundproxies.com/blog/http-vs-https-vs-socks5-proxies/) decide which scheme goes in that URL.

If you want to test a proxy from the shell first, the guide on [using proxies with the curl command line](https://roundproxies.com/blog/curl-proxy/) covers the equivalent flags.

### By hand, with a CONNECT tunnel

For HTTPS through an HTTP proxy, you ask the proxy to open a raw TCP tunnel, then run the normal TLS handshake through it.

First, a helper that reads only the proxy's reply headers:

```c
/* Read up to the blank line that ends a header block. */
int read_head(int fd, char *buf, size_t cap) {
    size_t used = 0;
    while (used < cap - 1) {
        if (recv(fd, buf + used, 1, 0) != 1) return -1;
        buf[++used] = '\0';
        if (used >= 4 && memcmp(buf + used - 4, "\r\n\r\n", 4) == 0) return 0;
    }
    return -1;                                  /* header block too large */
}

```

Reading one byte at a time looks slow, but the proxy reply is under 100 bytes. It also guarantees you never swallow bytes that belong to the tunnel.

```c
/* Ask an HTTP proxy for a tunnel to host:443. creds is "user:pass" or NULL. */
int proxy_connect(int fd, const char *host, const char *creds) {
    char auth[256] = "", req[768], resp[1024];
    if (creds && strlen(creds) < 140) {
        unsigned char b64[200];
        EVP_EncodeBlock(b64, (const unsigned char *)creds, (int)strlen(creds));
        snprintf(auth, sizeof auth, "Proxy-Authorization: Basic %s\r\n", b64);
    }
    int n = snprintf(req, sizeof req, "CONNECT %s:443 HTTP/1.1\r\n"
                     "Host: %s:443\r\n%s\r\n", host, host, auth);
    if (n < 0 || (size_t)n >= sizeof req || send_all(fd, req, (size_t)n) < 0)
        return -1;
    int status = 0;
    if (read_head(fd, resp, sizeof resp) == 0)
        sscanf(resp, "HTTP/%*d.%*d %d", &status);
    return status == 200 ? 0 : -1;      /* 407 means bad proxy credentials */
}

```

`EVP_EncodeBlock()` from OpenSSL handles the Base64 for Basic auth, so you don't need another dependency.

Usage is three calls: `connect_to()` the proxy, `proxy_connect()` to the target, then `tls_open()` with the target's hostname. SNI and certificate checks go to the real site, not the proxy.

## Making HTTP requests in C on Windows

On Windows, the same socket code needs Winsock initialized first, and a few names change. libcurl installed through vcpkg works exactly as in Steps 1 to 4.

```c
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")   /* MSVC; with MinGW, link -lws2_32 */

int main(void) {
    WSADATA wsa;
    if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) return 1;   /* before any socket call */

    /* connect_to(), send_all() and read_all() work with these swaps:
       close(fd)   -> closesocket(fd)
       int fd      -> SOCKET fd, compared against INVALID_SOCKET
       errno       -> WSAGetLastError()
       ssize_t n   -> int n (MSVC has no ssize_t) */

    WSACleanup();
    return 0;
}

```

Forgetting `WSAStartup()` makes every socket call fail with error 10093, `WSANOTINITIALISED`. There's no `SIGPIPE` on Windows, so drop the `signal()` line.

If your tool only ever runs on Windows, WinHTTP is worth a look. It ships with the OS, handles TLS through the system certificate store, and adds nothing to your installer.

## Troubleshooting

These are the errors I see most, with the exact text your compiler, libcurl or OpenSSL prints.

### "fatal error: curl/curl.h: No such file or directory"

**Why:** The libcurl runtime is installed but the development headers aren't. **Fix:** Install `libcurl4-openssl-dev` (Debian/Ubuntu) or `libcurl-devel` (Fedora), then confirm with `curl-config --cflags`.

### "undefined reference to \`curl\_easy\_init'"

**Why:** The linker never saw libcurl, or saw it before your source file. **Fix:** Put `-lcurl` at the end: `gcc get.c -o get -lcurl`. The same applies to `-lssl -lcrypto` for errors like "undefined reference to \`SSL\_new'".

### "SSL peer certificate or SSH remote key was not OK" (libcurl error 60)

**Why:** libcurl can't verify the server's certificate, usually because the CA bundle is missing in a minimal container or the server uses a private CA. **Fix:** Install `ca-certificates`, or point `CURLOPT_CAINFO` at the right bundle. Don't set `CURLOPT_SSL_VERIFYPEER` to `0L` outside a test machine.

### "sslv3 alert handshake failure"

On OpenSSL 3 the full line reads `error:0A000410:SSL routines:ssl3_read_bytes:sslv3 alert handshake failure ... SSL alert number 40`.

**Why:** The server refused the handshake, and with raw OpenSSL code the usual cause is missing SNI. **Fix:** Call `SSL_set_tlsext_host_name(ssl, host)` before `SSL_connect()`, as in Step 7.

### "certificate verify failed"

**Why:** OpenSSL prints the same line for different problems. **Fix:** Print `X509_verify_cert_error_string(SSL_get_verify_result(ssl))` to see which. "unable to get local issuer certificate" means a CA store problem; "hostname mismatch" means you connected to a name the certificate doesn't cover.

### The body starts with hex numbers like "1a3" or "7;ext=1"

**Why:** The server used chunked transfer encoding and you're printing the framing. **Fix:** Run the body through `dechunk()` from the chunked section, or send the request as `HTTP/1.0`.

### The request hangs and nothing comes back

**Why:** The server is still waiting for input. Either the blank line after the headers is missing, or a POST's `Content-Length` is larger than the body you sent. **Fix:** End the headers with `\r\n\r\n` and compute `Content-Length` with `strlen()` on the exact body. For the other direction, set `SO_RCVTIMEO` on the socket so a silent server can't block `recv()` forever.

Bare `\n` line endings are a separate issue. RFC 9112 lets servers accept them, and Python's built-in test server did, but strict servers answer with 400.

Use `\r\n` everywhere and the question never comes up.

## FAQ

### Does C have a built-in HTTP library?

No. The C standard library has no networking at all.

POSIX systems give you sockets and Windows adds WinHTTP and WinINet. Everything above raw TCP comes from a library such as libcurl, or from your own code.

### How do I make an HTTP request in C without libcurl?

Open a TCP socket with `getaddrinfo()` and `connect()`, send a request string with `\r\n` line endings and a closing blank line, then `recv()` until the server closes.

Steps 5 and 6 above have the full code. For HTTPS you also need a TLS library such as OpenSSL.

### Is libcurl thread-safe?

Yes, within two rules. Never use one easy handle from two threads at the same time, and call `curl_global_init()` once before starting threads.

Since libcurl 7.84.0 the global init is thread-safe on most builds, but calling it early costs nothing.

### How do I parse a JSON response in C?

Use a small library such as cJSON or Jansson. With cJSON, pass the body from Step 2 to `cJSON_Parse()` and read fields with `cJSON_GetObjectItemCaseSensitive()`.

Free the tree with `cJSON_Delete()`. Hand-parsing JSON with `strstr()` works for exactly one response format and breaks on the next.

### Why does my C client get blocked when the same URL works in a browser?

Usually one of three things: no User-Agent (libcurl sends none by default), too many requests from one IP, or a TLS handshake that doesn't look like a browser's.

The first is one `setopt` call. For the second, read up on [handling HTTP 429 rate limits](https://roundproxies.com/blog/http-error-429/).

The third is [TLS fingerprinting](https://roundproxies.com/blog/what-is-tls-fingerprint/), and no header change fixes it. Open-source forks like curl-impersonate reproduce browser handshakes if you need that.

## Wrapping up

Default to libcurl, set a timeout and a User-Agent, and reuse the handle.

Drop to raw sockets when you can't ship dependencies, and budget time for the parts libcurl was hiding: partial reads, chunked bodies, SNI and hostname checks.

To make it stick, take the raw-socket client from Steps 5 and 6, point it at a chunked endpoint, and watch `dechunk()` clean up the output.

If you also write Go, the companion guide on [making HTTP requests in Go](https://claude.ai/blog/requests-go/) covers the same ground with a standard library that does most of the work.

The [libcurl API reference](https://curl.se/libcurl/c/) documents every option used here.