Making HTTP requests in C isn't as straightforward as Python's requests.get() or JavaScript's fetch(). You're working at a lower level, which means more control but also more complexity. Whether you're building a lightweight API client, scraping data, or just need to fetch a resource, understanding how to make HTTP requests in C opens up possibilities that higher-level languages abstract away.

I've been writing C for over a decade, and I've seen developers struggle with this topic more than almost anything else in network programming. The confusion usually stems from choosing between libraries like libcurl, implementing raw sockets, or handling HTTPS with OpenSSL. This guide will walk you through all three approaches, plus some tricks I've picked up along the way.

Understanding HTTP requests in C

Before diving into code, let's clarify what we're actually doing. An HTTP request is just formatted text sent over a TCP connection. When you type https://example.com into your browser, it:

  1. Opens a TCP socket to example.com on port 80 (or 443 for HTTPS)
  2. Sends a formatted string like GET / HTTP/1.1\r\nHost: example.com\r\n\r\n
  3. Reads the response from the server
  4. Closes the connection

The \r\n sequences are critical—HTTP requires CRLF (carriage return + line feed) to separate headers, not just \n. Miss this detail and your requests will hang or fail with cryptic errors.

Method 1: Using libcurl (the pragmatic choice)

Let's be honest: unless you have a specific reason not to, use libcurl. It handles redirects, cookies, SSL/TLS, compression, and dozens of edge cases you don't want to debug yourself. Here's the simplest possible example:

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

size_t write_callback(void *ptr, size_t size, size_t nmemb, void *userdata) {
    size_t realsize = size * nmemb;
    printf("%.*s", (int)realsize, (char *)ptr);
    return realsize;
}

int main(void) {
    CURL *curl;
    CURLcode res;
    
    curl_global_init(CURL_GLOBAL_DEFAULT);
    curl = curl_easy_init();
    
    if(curl) {
        curl_easy_setopt(curl, CURLOPT_URL, "http://httpbin.org/get");
        curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);
        
        res = curl_easy_perform(curl);
        
        if(res != CURLE_OK) {
            fprintf(stderr, "curl_easy_perform() failed: %s\n",
                    curl_easy_strerror(res));
        }
        
        curl_easy_cleanup(curl);
    }
    
    curl_global_cleanup();
    return 0;
}

Compile with: gcc -o http_client http_client.c -lcurl

The callback function gets called multiple times as data arrives. libcurl doesn't buffer the entire response—it streams it to you in chunks. This is actually a feature, not a bug. For large responses, you don't want to allocate gigabytes of RAM.

Capturing the response to a string

Here's a more practical example that captures the entire response into memory:

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

struct MemoryStruct {
    char *memory;
    size_t size;
};

size_t write_memory_callback(void *contents, size_t size, size_t nmemb, void *userp) {
    size_t realsize = size * nmemb;
    struct MemoryStruct *mem = (struct MemoryStruct *)userp;
    
    char *ptr = realloc(mem->memory, mem->size + realsize + 1);
    if(!ptr) {
        printf("Not enough memory (realloc returned NULL)\n");
        return 0;
    }
    
    mem->memory = ptr;
    memcpy(&(mem->memory[mem->size]), contents, realsize);
    mem->size += realsize;
    mem->memory[mem->size] = 0;
    
    return realsize;
}

int main(void) {
    CURL *curl;
    CURLcode res;
    struct MemoryStruct chunk;
    
    chunk.memory = malloc(1);
    chunk.size = 0;
    
    curl_global_init(CURL_GLOBAL_DEFAULT);
    curl = curl_easy_init();
    
    if(curl) {
        curl_easy_setopt(curl, CURLOPT_URL, "http://httpbin.org/json");
        curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_memory_callback);
        curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void *)&chunk);
        curl_easy_setopt(curl, CURLOPT_USERAGENT, "libcurl-agent/1.0");
        
        res = curl_easy_perform(curl);
        
        if(res != CURLE_OK) {
            fprintf(stderr, "Request failed: %s\n", curl_easy_strerror(res));
        } else {
            printf("%lu bytes retrieved\n", (unsigned long)chunk.size);
            printf("%s\n", chunk.memory);
        }
        
        curl_easy_cleanup(curl);
        free(chunk.memory);
    }
    
    curl_global_cleanup();
    return 0;
}

Notice the realloc() pattern. We start with 1 byte and grow the buffer as data arrives. This is the standard approach for unknown response sizes, though it's not the most efficient. For better performance, you could allocate larger chunks at once (say, 4KB or 16KB increments).

Making POST requests with libcurl

GET requests are easy. POST requests require sending data in the request body:

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

int main(void) {
    CURL *curl;
    CURLcode res;
    
    curl_global_init(CURL_GLOBAL_DEFAULT);
    curl = curl_easy_init();
    
    if(curl) {
        curl_easy_setopt(curl, CURLOPT_URL, "http://httpbin.org/post");
        
        // Set POST data
        curl_easy_setopt(curl, CURLOPT_POSTFIELDS, "name=test&email=test@example.com");
        
        // Or use a struct for complex data
        struct curl_httppost *formpost = NULL;
        struct curl_httppost *lastptr = NULL;
        
        curl_formadd(&formpost, &lastptr,
                     CURLFORM_COPYNAME, "username",
                     CURLFORM_COPYCONTENTS, "admin",
                     CURLFORM_END);
        
        curl_formadd(&formpost, &lastptr,
                     CURLFORM_COPYNAME, "password",
                     CURLFORM_COPYCONTENTS, "secret123",
                     CURLFORM_END);
        
        curl_easy_setopt(curl, CURLOPT_HTTPPOST, formpost);
        
        res = curl_easy_perform(curl);
        
        if(res != CURLE_OK) {
            fprintf(stderr, "POST failed: %s\n", curl_easy_strerror(res));
        }
        
        curl_easy_cleanup(curl);
        curl_formfree(formpost);
    }
    
    curl_global_cleanup();
    return 0;
}

For JSON payloads, set the Content-Type header manually:

struct curl_slist *headers = NULL;
headers = curl_slist_append(headers, "Content-Type: application/json");
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, "{\"key\":\"value\"}");

Method 2: Raw sockets with POSIX (when you want control)

Sometimes libcurl is overkill—or unavailable. Maybe you're working on an embedded system, or you need precise control over timeout behavior. Raw sockets give you exactly that, at the cost of handling everything manually.

Here's a minimal HTTP GET using POSIX sockets:

#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <netdb.h>
#include <unistd.h>

#define BUFFER_SIZE 4096

int main(void) {
    struct addrinfo hints, *res;
    int sockfd;
    char buffer[BUFFER_SIZE];
    
    // Setup hints
    memset(&hints, 0, sizeof(hints));
    hints.ai_family = AF_INET;
    hints.ai_socktype = SOCK_STREAM;
    
    // Resolve hostname
    if (getaddrinfo("httpbin.org", "80", &hints, &res) != 0) {
        perror("getaddrinfo failed");
        return 1;
    }
    
    // Create socket
    sockfd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
    if (sockfd < 0) {
        perror("socket creation failed");
        freeaddrinfo(res);
        return 1;
    }
    
    // Connect
    if (connect(sockfd, res->ai_addr, res->ai_addrlen) < 0) {
        perror("connection failed");
        close(sockfd);
        freeaddrinfo(res);
        return 1;
    }
    
    freeaddrinfo(res);
    
    // Build HTTP request - CRITICAL: use \r\n, not \n
    const char *request = 
        "GET /get HTTP/1.1\r\n"
        "Host: httpbin.org\r\n"
        "User-Agent: custom-c-client/1.0\r\n"
        "Accept: */*\r\n"
        "Connection: close\r\n"
        "\r\n";
    
    // Send request
    if (send(sockfd, request, strlen(request), 0) < 0) {
        perror("send failed");
        close(sockfd);
        return 1;
    }
    
    // Read response
    int bytes_received;
    while ((bytes_received = recv(sockfd, buffer, BUFFER_SIZE - 1, 0)) > 0) {
        buffer[bytes_received] = '\0';
        printf("%s", buffer);
    }
    
    close(sockfd);
    return 0;
}

This prints both headers and body. To separate them, scan for \r\n\r\n—that's the delimiter between headers and body in HTTP. Here's how:

char *body_start = strstr(buffer, "\r\n\r\n");
if (body_start) {
    body_start += 4; // Skip past the \r\n\r\n
    printf("Body: %s\n", body_start);
}

Handling chunked responses properly

Here's a gotcha: recv() might not receive all data in one call. You need to loop until the connection closes or you've read Content-Length bytes:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <netdb.h>
#include <unistd.h>

#define INITIAL_BUFFER_SIZE 4096

char* read_http_response(int sockfd, size_t *total_read) {
    size_t capacity = INITIAL_BUFFER_SIZE;
    size_t used = 0;
    char *buffer = malloc(capacity);
    int bytes;
    
    if (!buffer) {
        return NULL;
    }
    
    while ((bytes = recv(sockfd, buffer + used, capacity - used - 1, 0)) > 0) {
        used += bytes;
        
        // Need more space?
        if (used + 1 >= capacity) {
            capacity *= 2;
            char *new_buffer = realloc(buffer, capacity);
            if (!new_buffer) {
                free(buffer);
                return NULL;
            }
            buffer = new_buffer;
        }
    }
    
    buffer[used] = '\0';
    *total_read = used;
    return buffer;
}

This dynamically grows the buffer, similar to the libcurl example. The key difference? We're managing the socket lifecycle ourselves.

Method 3: HTTPS with OpenSSL (securing the connection)

Modern APIs require HTTPS. Raw sockets won't cut it—you need TLS/SSL. Enter OpenSSL, which wraps your socket in encryption. The setup is verbose but straightforward:

#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <netdb.h>
#include <unistd.h>
#include <openssl/ssl.h>
#include <openssl/err.h>

#define BUFFER_SIZE 4096

int main(void) {
    SSL_CTX *ctx;
    SSL *ssl;
    int sockfd;
    struct addrinfo hints, *res;
    char buffer[BUFFER_SIZE];
    
    // Initialize OpenSSL
    SSL_library_init();
    SSL_load_error_strings();
    OpenSSL_add_all_algorithms();
    
    // Create SSL context
    const SSL_METHOD *method = TLS_client_method();
    ctx = SSL_CTX_new(method);
    if (!ctx) {
        ERR_print_errors_fp(stderr);
        return 1;
    }
    
    // Resolve hostname
    memset(&hints, 0, sizeof(hints));
    hints.ai_family = AF_INET;
    hints.ai_socktype = SOCK_STREAM;
    
    if (getaddrinfo("httpbin.org", "443", &hints, &res) != 0) {
        perror("getaddrinfo failed");
        SSL_CTX_free(ctx);
        return 1;
    }
    
    // Create and connect socket
    sockfd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
    if (sockfd < 0 || connect(sockfd, res->ai_addr, res->ai_addrlen) < 0) {
        perror("socket/connect failed");
        freeaddrinfo(res);
        SSL_CTX_free(ctx);
        return 1;
    }
    freeaddrinfo(res);
    
    // Create SSL connection
    ssl = SSL_new(ctx);
    SSL_set_fd(ssl, sockfd);
    
    // Perform TLS handshake
    if (SSL_connect(ssl) <= 0) {
        ERR_print_errors_fp(stderr);
        SSL_free(ssl);
        close(sockfd);
        SSL_CTX_free(ctx);
        return 1;
    }
    
    // Send HTTPS request
    const char *request = 
        "GET /get HTTP/1.1\r\n"
        "Host: httpbin.org\r\n"
        "Connection: close\r\n"
        "\r\n";
    
    SSL_write(ssl, request, strlen(request));
    
    // Read response
    int bytes;
    while ((bytes = SSL_read(ssl, buffer, BUFFER_SIZE - 1)) > 0) {
        buffer[bytes] = '\0';
        printf("%s", buffer);
    }
    
    // Cleanup
    SSL_shutdown(ssl);
    SSL_free(ssl);
    close(sockfd);
    SSL_CTX_free(ctx);
    
    return 0;
}

Compile with: gcc -o https_client https_client.c -lssl -lcrypto

The key change from raw sockets: SSL_write() and SSL_read() instead of send() and recv(). Everything else—DNS resolution, socket creation—remains the same.

Certificate verification (don't skip this)

The code above doesn't verify the server's certificate. In production, this is a security hole. Add verification like this:

// After creating the context, before SSL_connect
SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL);
SSL_CTX_set_default_verify_paths(ctx);

// After SSL_connect, verify the certificate
long verify_result = SSL_get_verify_result(ssl);
if (verify_result != X509_V_OK) {
    fprintf(stderr, "Certificate verification failed: %ld\n", verify_result);
    SSL_free(ssl);
    close(sockfd);
    SSL_CTX_free(ctx);
    return 1;
}

This uses your system's trusted CA certificates (usually in /etc/ssl/certs/ on Linux). For custom CAs, use SSL_CTX_load_verify_locations().

Advanced techniques

Setting timeouts

Raw sockets don't timeout by default. Your program will hang indefinitely if the server stops responding. Fix this with setsockopt():

#include <sys/socket.h>
#include <sys/time.h>

struct timeval timeout;
timeout.tv_sec = 5;  // 5 second timeout
timeout.tv_usec = 0;

setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));

For libcurl, use CURLOPT_TIMEOUT:

curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L);  // 5 seconds total
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 2L);  // 2 seconds to connect

Custom headers

With raw sockets, just modify the request string:

const char *request = 
    "GET /api/data HTTP/1.1\r\n"
    "Host: api.example.com\r\n"
    "Authorization: Bearer YOUR_TOKEN_HERE\r\n"
    "X-Custom-Header: value\r\n"
    "User-Agent: MyApp/1.0\r\n"
    "\r\n";

With libcurl, use a linked list:

struct curl_slist *headers = NULL;
headers = curl_slist_append(headers, "Authorization: Bearer TOKEN");
headers = curl_slist_append(headers, "X-Custom: value");
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);

// After curl_easy_perform:
curl_slist_free_all(headers);

Parsing JSON responses

C doesn't have built-in JSON parsing. Use a library like cJSON:

#include <cjson/cJSON.h>

// Assuming 'response' contains JSON string
cJSON *json = cJSON_Parse(response);
if (json) {
    cJSON *name = cJSON_GetObjectItem(json, "name");
    if (cJSON_IsString(name)) {
        printf("Name: %s\n", name->valuestring);
    }
    cJSON_Delete(json);
}

Install with: sudo apt-get install libcjson-dev (Ubuntu/Debian)

Or manually parse simple JSON with strstr() and sscanf() if you're feeling adventurous (not recommended for production).

Common pitfalls and how to avoid them

1. Forgetting \r\n in HTTP requests

HTTP spec requires CRLF (\r\n), not just LF (\n). If you only use \n, most servers will hang waiting for the rest of the request. Always use \r\n\r\n to end headers.

2. Not handling partial reads/writes

send() and recv() might not transfer all bytes in one call. Always check return values and loop:

int send_all(int socket, const char *buffer, size_t length) {
    size_t total_sent = 0;
    while (total_sent < length) {
        int sent = send(socket, buffer + total_sent, length - total_sent, 0);
        if (sent < 0) {
            return -1;
        }
        total_sent += sent;
    }
    return 0;
}

3. Memory leaks with libcurl

Always call curl_easy_cleanup() and curl_global_cleanup(). If you're using custom headers or forms, free them:

curl_slist_free_all(headers);
curl_formfree(formpost);

4. Ignoring SSL errors

OpenSSL functions return error codes. Check them:

if (SSL_connect(ssl) <= 0) {
    ERR_print_errors_fp(stderr);  // Print actual error
    // Handle error
}

5. Hardcoding buffer sizes

For unknown response sizes, use dynamic allocation. Fixed buffers (like char buffer[4096]) will overflow on large responses.

Wrapping up

Making HTTP requests in C isn't rocket science, but it requires attention to detail. libcurl handles 90% of use cases and should be your default choice. When you need fine-grained control or can't use external libraries, raw sockets work fine—just remember to handle errors, use \r\n, and loop on partial I/O.

For HTTPS, OpenSSL adds a layer of complexity but follows the same socket patterns. Don't skip certificate verification in production code.

The real trick? Know when each approach makes sense. Embedded system with tight resource constraints? Raw sockets. API client that needs to handle redirects and cookies? libcurl. Building something security-critical? Add proper error handling and certificate validation regardless of your chosen method.