curlcl

libcurl from Common Lisp — the whole public C API, and an HTTP client built on top of it.

If you know libcurl, you already know most of this library. Every section below is one C call beside the Lisp that does the same thing. The names are libcurl's, mechanically: drop CURLOPT_, downcase, underscores to hyphens, so CURLOPT_SSL_VERIFYPEER is :ssl-verifypeer.

About the code on this page. Every Lisp example is executed before publishing — they live in docs/examples.lisp and make docs-check runs them; the results shown are what they actually returned, against libcurl 8.21.0. The C column is written to libcurl's documented usage to show the correspondence, and is not compiled.
Install (library)ocicl install, then (asdf:load-system :curlcl)
Install (command)brew tap lispnik/curlcl && brew trust lispnik/curlcl && brew install curlcl
Packagecurlcl, nicknamed curl
Needslibcurl 7.83 or newer. No C toolchain — nothing here is groveled or compiled.

Which libcurl you get matters, because there is usually more than one on a machine and they differ in version, TLS backend and whether ws:// exists at all. curlcl -V prints the one that was opened, and (curl:libcurl-pathname) answers the same question from Lisp.

1Hello, transfer

The shape is the same: make a handle, set options on it, perform, release. What changes is that releasing is the macro's job, and a failure is a condition rather than a code you must remember to check.

C

CURL *handle = curl_easy_init();
curl_easy_setopt(handle, CURLOPT_URL, "https://example.com/");
curl_easy_setopt(handle, CURLOPT_FOLLOWLOCATION, 1L);

CURLcode code = curl_easy_perform(handle);
if (code != CURLE_OK)
    fprintf(stderr, "%s\n", curl_easy_strerror(code));

long status;
curl_easy_getinfo(handle, CURLINFO_RESPONSE_CODE, &status);
curl_easy_cleanup(handle);

Lisp

(curl:with-easy (handle)
  (curl:setopts handle :url "https://example.com/"
                       :followlocation t)
  (curl:perform handle)
  (curl:getinfo handle :response-code))

returns 200

2Options and info

One setopts takes as many options as you like. Each value is converted according to the type libcurl declares for that option, so t becomes 1L where a long is wanted and a string is copied where libcurl copies. An option this libcurl does not have is reported by name, not as CURLE_UNKNOWN_OPTION.

C

curl_easy_setopt(handle, CURLOPT_URL, "https://example.com/");
curl_easy_setopt(handle, CURLOPT_USERAGENT, "curlcl/docs");
curl_easy_setopt(handle, CURLOPT_TIMEOUT, 30L);
curl_easy_setopt(handle, CURLOPT_SSL_VERIFYPEER, 1L);

char *url; double bytes; curl_off_t n;
curl_easy_getinfo(handle, CURLINFO_EFFECTIVE_URL, &url);
curl_easy_getinfo(handle, CURLINFO_SIZE_DOWNLOAD, &bytes);

Lisp

(curl:setopts handle :url "https://example.com/"
                     :useragent "curlcl/docs"
                     :timeout 30
                     :ssl-verifypeer t)
(curl:perform handle)
(list :code  (curl:getinfo handle :response-code)
      :url   (curl:getinfo handle :effective-url)
      :type  (curl:getinfo handle :content-type)
      :bytes (curl:getinfo handle :size-download))

returns (:CODE 200 :URL "https://example.com/" :TYPE "text/html" :BYTES 559.0d0)

3Taking the body

In C a write callback is a function pointer plus a void * you cast back. Here it is a closure, so the state it needs is just what it closes over. Returning true accepts the chunk; a Lisp condition signalled inside is caught at the boundary and re-signalled afterwards, so it never unwinds through C.

C

static size_t on_data(char *p, size_t sz, size_t n, void *user)
{
    size_t bytes = sz * n;
    *(size_t *)user += bytes;
    return bytes;            /* short return aborts */
}

size_t total = 0;
curl_easy_setopt(handle, CURLOPT_WRITEFUNCTION, on_data);
curl_easy_setopt(handle, CURLOPT_WRITEDATA, &total);

Lisp

(let ((total 0))
  (curl:with-easy (handle)
    (setf (curl:callback-function handle :write)
          (lambda (octets)
            (incf total (length octets))
            t))
    (curl:setopts handle :url "https://example.com/")
    (curl:perform handle))
  total)

returns 559

4Many at once

The multi interface without the loop. run-transfers performs, waits and drains the message queue until everything has finished, and returns a result per transfer. The socket API underneath it is exported too, for fitting into an event loop you already have.

C

CURLM *multi = curl_multi_init();
curl_multi_add_handle(multi, h1);
curl_multi_add_handle(multi, h2);

int running;
do {
    curl_multi_perform(multi, &running);
    if (running) curl_multi_poll(multi, NULL, 0, 1000, NULL);
} while (running);

CURLMsg *m; int left;
while ((m = curl_multi_info_read(multi, &left)))
    if (m->msg == CURLMSG_DONE) check(m->data.result);

Lisp

(curl:with-multi (multi)
  (dolist (h handles)
    (curl:add-transfer multi h))
  (mapcar #'curl:result-code-name
          (curl:run-transfers multi)))

returns (:OK :OK :OK)

5URLs

libcurl's own parser, which is worth using over a Lisp one because it is the parser that will be applied to the transfer, quirks included. parse-url is the whole thing as a plist; with-url keeps a handle so parts can be set and the result read back out.

C

CURLU *u = curl_url();
curl_url_set(u, CURLUPART_URL, "https://example.com/a", 0);
curl_url_set(u, CURLUPART_PATH, "/b/c", 0);

char *host, *out;
curl_url_get(u, CURLUPART_HOST, &host, 0);
curl_url_get(u, CURLUPART_URL, &out, 0);
curl_free(host); curl_free(out);
curl_url_cleanup(u);

Lisp

(curl:parse-url "https://example.com:8443/a/b?q=1#frag")

(curl:with-url (u "https://example.com/a")
  (setf (curl:url-part u :path) "/b/c")
  (curl:url-string u))

the first returns (:SCHEME "https" :HOST "example.com" :PORT "8443" :PATH "/a/b" :QUERY "q=1" :FRAGMENT "frag"), the second "https://example.com/b/c"

6Multipart

curl_mime_* is bound; curl_formadd is not, being deprecated everywhere and variadic with a sentinel list. Parts can carry data, a file, a content type, extra headers, or nested subparts.

C

curl_mime *mime = curl_mime_init(handle);

curl_mimepart *part = curl_mime_addpart(mime);
curl_mime_name(part, "field");
curl_mime_data(part, "value", CURL_ZERO_TERMINATED);

part = curl_mime_addpart(mime);
curl_mime_name(part, "note");
curl_mime_data(part, "hello", CURL_ZERO_TERMINATED);
curl_mime_type(part, "text/plain");

curl_easy_setopt(handle, CURLOPT_MIMEPOST, mime);

Lisp

(let ((mime (curl:make-mime handle)))
  (curl:add-mime-part mime :name "field" :data "value")
  (curl:add-mime-part mime :name "note" :data "hello"
                           :content-type "text/plain")
  (curl:attach-mime handle mime))
(curl:setopts handle :url "https://example.com/")
(curl:perform handle)
(curl:getinfo handle :response-code)

against example.com this returns 405 — it declines a POST, which is itself the evidence that a body was built and sent. The client layer below spells the same thing :multipart.

7Sharing between handles

A share lets several handles pool DNS answers, TLS sessions, connections and cookies. Lock callbacks are always installed, so a share is safe to use across threads without arranging that yourself.

C

CURLSH *share = curl_share_init();
curl_share_setopt(share, CURLSHOPT_SHARE, CURL_LOCK_DATA_DNS);
curl_share_setopt(share, CURLSHOPT_SHARE, CURL_LOCK_DATA_SSL_SESSION);
curl_share_setopt(share, CURLSHOPT_LOCKFUNC, lock);
curl_share_setopt(share, CURLSHOPT_UNLOCKFUNC, unlock);

curl_easy_setopt(handle, CURLOPT_SHARE, share);
/* ... */
curl_share_cleanup(share);

Lisp

(curl:with-share (share)
  (curl:share-data share :dns)
  (curl:share-data share :ssl-session)
  (loop repeat 2
        collect (curl:with-easy (handle)
                  (curl:attach-share handle share)
                  (curl:setopts handle :url "https://example.com/")
                  (curl:perform handle)
                  (curl:getinfo handle :response-code))))

two transfers over one share return (200 200), the second reusing the first's connection

A share, or a session?

These are the same idea at two levels, and the page shows both, so: a session contains a share. make-session creates one and adds what libcurl has no concept of — a pool of easy handles, defaults merged into every request, and a teardown that closes the handles before releasing the share, because curl_share_cleanup answers CURLSHE_IN_USE if shared state is pulled out from under a live handle.

sharesession
What it pools DNS, TLS sessions, connections, cookies — whichever you name the same, defaulting to all four, plus the handles themselves
Handles yours to create, configure and close pooled and reused; :max-idle kept, default 8
Use it when you drive easy handles yourself, or want a narrower set — DNS only, no cookies you use the client layer and want reuse and cookies across a run of requests

They are not exclusive: session-share hands back the share the session built, so a raw handle can join the very state the client layer is using.

Lisp

(curl:with-session (session)
  (let ((share (curl:session-share session)))
    (curl:with-easy (handle)
      (curl:attach-share handle share)          ; a raw handle joins the pool
      (curl:setopts handle :url "https://example.com/")
      (curl:perform handle)
      (list :the-sessions-share (type-of share)
            :raw-handle (curl:getinfo handle :response-code)
            :client-request (curl:response-status
                             (curl:http-get "https://example.com/"
                                            :session session))))))

returns (:THE-SESSIONS-SHARE CURLCL:SHARE-HANDLE :RAW-HANDLE 200 :CLIENT-REQUEST 200)

8Response headers

Headers as libcurl parsed them, rather than as text you split yourself. Duplicates are kept, which is the only representation that can be right for Set-Cookie.

C

struct curl_header *h;
if (curl_easy_header(handle, "content-type", 0,
                     CURLH_HEADER, -1, &h) == CURLHE_OK)
    puts(h->value);

struct curl_header *prev = NULL;
while ((h = curl_easy_nextheader(handle, CURLH_HEADER, -1, prev))) {
    printf("%s: %s\n", h->name, h->value);
    prev = h;
}

Lisp

(let ((response (curl:http-get "https://example.com/")))
  (list :content-type
          (curl:response-header-values response "content-type")
        :how-many
          (length (curl:response-headers response))))

returns (:CONTENT-TYPE ("text/html") :HOW-MANY 9)

9Websockets

Feature-gated at runtime, because whether ws:// works is a property of the libcurl that got loaded — macOS ships one built without it. Ask before you rely on it. libcurl marks this API experimental, and that caveat is passed on rather than hidden.

C

curl_easy_setopt(handle, CURLOPT_URL, "wss://example.com/");
curl_easy_setopt(handle, CURLOPT_CONNECT_ONLY, 2L);
curl_easy_perform(handle);

size_t sent;
curl_ws_send(handle, "hello", 5, &sent, 0, CURLWS_TEXT);

size_t got; const struct curl_ws_frame *meta;
char buf[256];
curl_ws_recv(handle, buf, sizeof buf, &got, &meta);

Lisp

(when (curl:websockets-supported-p)
  (curl:with-websocket (handle "wss://example.com/")
    (curl:ws-send-text handle "hello")
    (curl:ws-receive handle)))     ; => (values octets frame)

(curl:websockets-supported-p) returned T against the libcurl used here; it is NIL against the one macOS ships

10Errors

No wrapper hands back a bare CURLcode. Every failure path ends in a condition, and the code is still there on it when you want to branch on the exact one. Codes decode tolerantly: a libcurl newer than this binding can return one it has never heard of, and that arrives as the integer rather than as a broken binding.

C

CURLcode code = curl_easy_perform(handle);
if (code != CURLE_OK) {
    fprintf(stderr, "%d: %s\n", code, curl_easy_strerror(code));
    if (code == CURLE_COULDNT_RESOLVE_HOST) { /* ... */ }
}

Lisp

(handler-case
    (curl:with-easy (handle)
      (curl:setopts handle :url "https://no-such-host.invalid/")
      (curl:perform handle))
  (curl:easy-error (condition)
    (list :code (curl:curl-error-code condition)
          :name (curl:curl-error-code-name condition))))

returns (:CODE 6 :NAME :COULDNT-RESOLVE-HOST)

Deciding at the point of failure

A condition is only half of what the condition system offers. Where the library would otherwise have to guess on your behalf, it signals and establishes a restart, so the choice is made where the information is — by a handler, rather than by going back and calling differently.

Retrying a body that goes to :on-data is the case worth showing. A retry delivers the body from the beginning, so the consumer would see whatever the failed attempt managed to hand over and then the whole of the successful one. Rather than corrupt the stream quietly or refuse outright, the library refuses and offers continue.

Lisp

(flet ((discard (octets) (declare (ignore octets))))
  (list
   ;; Refused by default: the consumer would see part of the body twice.
   :refused
   (handler-case
       (curl:http-get "https://example.com/" :on-data #'discard :retry 3)
     (curl:unsafe-retry (condition)
       (curl:unsafe-retry-sink condition)))

   ;; Or say, at the point of refusal, that repeated delivery is acceptable.
   :allowed
   (handler-bind ((curl:unsafe-retry #'continue))
     (curl:response-status
      (curl:http-get "https://example.com/" :on-data #'discard :retry 3)))))

returns (:REFUSED :ON-DATA :ALLOWED 200)

Warnings you can act on

Setting an option libcurl has deprecated warns once per option, with a condition rather than a bare string — so it can be muffled, logged, or made fatal for a test run, and what it knows is readable off it instead of being formatted away.

Lisp

(let ((seen '()))
  (handler-bind ((curl:deprecated-option
                   (lambda (condition)
                     (push (list (curl:deprecated-option-name condition)
                                 (curl:deprecated-option-since condition)
                                 (curl:deprecated-option-replacement condition))
                           seen)
                     (muffle-warning condition))))
    (curl:with-easy (handle)
      (curl:setopts handle :put t)))
  seen)

returns ((:PUT "7.12.1" "Use CURLOPT_UPLOAD"))

11The client layer

This section has no C column, and that is the point. libcurl has no retry policy, no session pooling and no notion of decoding a body; these are what the library adds over the binding. A non-2xx status is a response, not a condition — only transport failures signal, because only then is there nothing to return.

with-session below is the share from §7 with a handle pool around it — see A share, or a session? for which to reach for. Retry and streaming interact, and not always the way you want: Deciding at the point of failure is what that looks like.

Lisp

;; One request.  The body is decoded when the Content-Type says it is
;; text and names a charset we know; otherwise it arrives as octets.
(let ((response (curl:http-get "https://example.com/")))
  (list :status (curl:response-status response)
        :ok     (curl:successful-response-p response)
        :text-p (stringp (curl:response-text response))))

;; Several at once, over the multi interface.  A failure sits in its own
;; slot as a condition rather than aborting the batch.
(curl:request-many (list "https://example.com/"
                         "https://example.com/"))

;; A session pools handles over a share, so connections, DNS answers,
;; TLS sessions and cookies are common to a run of requests.
(curl:with-session (session)
  (curl:http-get "https://example.com/" :session session)
  (curl:http-get "https://example.com/" :session session))

;; Retry, which libcurl has none of.  POST is not retried unless you say
;; so, since only you know whether repeating one duplicates an order.
(curl:http-get "https://flaky.example/"
               :retry '(:max-attempts 5 :initial-delay 0.5))

;; Streaming, in either direction; the source never has to fit in memory.
(curl:download "https://example.com/big.iso" #p"/tmp/big.iso" :retry 3)
(curl:http-put "https://example.com/big.iso" :input #p"/tmp/big.iso")

the first returns (:STATUS 200 :OK T :TEXT-P T), the second (200 200), the third (200 200)

12And a curl you can run

The same library drives curlcl, a curl(1) workalike: option names, defaults, output destinations and exit codes follow curl, so most curl command lines work unchanged and scripts checking the status keep working. Holding to that is what forces the library to cover what a real client needs rather than what is convenient to expose.

Shell

$ curlcl -s -o /dev/null -w '%{http_code} in %{time_total}s\n' https://example.com/
200 in 0.086570s

$ curlcl -s -d @payload.json -H 'Content-Type: application/json' https://api.example/
$ curlcl -sZ -o a.html -o b.html https://a.example/ https://b.example/   # parallel
$ curlcl --retry 3 https://flaky.example/                                # backoff
$ printf 'hello\n' | curlcl ws://echo.example/                           # websockets

$ curlcl -V          # which libcurl did it open, and what can that one do?
curlcl 0.1.6 (aarch64-apple-darwin25.4.0) libcurl/8.21.0 OpenSSL/3.6.3 ...
Library: /opt/homebrew/opt/curl/lib/libcurl.4.dylib
Protocols: dict file ftp ftps ... http https ... ws wss