Knowledgebase

How to make HTTP requests in C: libcurl, sockets and TLS

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:

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.

# 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.

#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:

#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:

/* 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.

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:

    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.

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.

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.

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.

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.

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.

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.

#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.

/* 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.

/* 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.

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.

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.

/* 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:

    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:

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: 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:

/* 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

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

#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.

/* 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 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():

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:

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:

    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://.

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 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 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:

/* 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.

/* 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.

#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.

The third is TLS fingerprinting, 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 covers the same ground with a standard library that does most of the work.

The libcurl API reference documents every option used here.