← Back to blog

Send Email with Clojure using the Rovela API

Learn how to send transactional email from Clojure using clj-http, the JDK HttpClient or http-kit, with retry logic and idempotency keys, bounded batch sending via an ExecutorService, and webhook verification with Ring and http-kit.

Updated November 3, 2026
Send Email with Clojure using the Rovela API

Clojure runs on the JVM, so it inherits a mature HTTP and crypto stack while keeping the code short. This guide covers clj-http, the JDK's own HttpClient and http-kit, retry logic with idempotency keys, bounded batch sending with an ExecutorService, and webhook verification with Ring and http-kit.

Prerequisites

Set your API key as an environment variable:

export ROVELA_API_KEY="re_..."

Add the dependencies to deps.edn:

{:deps {clj-http/clj-http       {:mvn/version "3.13.0"}
        cheshire/cheshire       {:mvn/version "5.13.0"}
        http-kit/http-kit       {:mvn/version "2.8.0"}
        compojure/compojure     {:mvn/version "1.7.1"}
        ring/ring-core          {:mvn/version "1.12.2"}
        ring/ring-jetty-adapter {:mvn/version "1.12.2"}}}

Send with clj-http

clj-http is the most common choice. Pair it with cheshire for JSON:

(ns rovela.core
  (:require [clj-http.client :as http]
            [cheshire.core :as json]))

(defn send-email [to subject html]
  (let [resp (http/post "https://api.rovela.dev/emails"
                        {:headers {"Authorization" (str "Bearer " (System/getenv "ROVELA_API_KEY"))
                                   "Content-Type"  "application/json"}
                         :body    (json/generate-string {:from    "Acme <noreply@yourdomain.com>"
                                                         :to      to
                                                         :subject subject
                                                         :html    html})
                         :throw-exceptions false})]
    (when-not (<= 200 (:status resp) 299)
      (throw (ex-info (str "HTTP " (:status resp) ": " (:body resp))
                      {:status (:status resp)})))
    (json/parse-string (:body resp) true)))

(println (:id (send-email "user@example.com"
                          "Welcome aboard"
                          "<p>Thanks for signing up!</p>")))

Send with java.net.http

If you would rather not add a dependency, the JDK 11 client is a few interop calls away:

(ns rovela.jdk
  (:require [cheshire.core :as json])
  (:import (java.net URI)
           (java.net.http HttpClient HttpRequest
                          HttpRequest$BodyPublishers
                          HttpResponse$BodyHandlers)))

(defn send-email [to subject html]
  (let [body   (json/generate-string {:from    "Acme <noreply@yourdomain.com>"
                                      :to      to
                                      :subject subject
                                      :html    html})
        client (HttpClient/newHttpClient)
        req    (-> (HttpRequest/newBuilder (URI/create "https://api.rovela.dev/emails"))
                   (.header "Authorization" (str "Bearer " (System/getenv "ROVELA_API_KEY")))
                   (.header "Content-Type" "application/json")
                   (.POST (HttpRequest$BodyPublishers/ofString body))
                   (.build))
        resp   (.send client req (HttpResponse$BodyHandlers/ofString))]
    (when-not (<= 200 (.statusCode resp) 299)
      (throw (ex-info (str "HTTP " (.statusCode resp) ": " (.body resp)) {})))
    (json/parse-string (.body resp) true)))

Send with http-kit

http-kit returns a promise, which composes well with a mostly asynchronous application:

(ns rovela.async
  (:require [cheshire.core :as json]
            [org.httpkit.client :as http]))

(defn send-email [to subject html]
  (let [{:keys [status body error]}
        @(http/post "https://api.rovela.dev/emails"
                    {:headers {"Authorization" (str "Bearer " (System/getenv "ROVELA_API_KEY"))
                               "Content-Type"  "application/json"}
                     :body    (json/generate-string {:from    "Acme <noreply@yourdomain.com>"
                                                     :to      to
                                                     :subject subject
                                                     :html    html})})]
    (when error
      (throw (ex-info "request failed" {:error error})))
    (when-not (<= 200 status 299)
      (throw (ex-info (str "HTTP " status ": " body) {:status status})))
    (json/parse-string body true)))

Retry with idempotency key

Generate the key once before the loop and reuse it on every attempt. This prevents duplicate delivery when the connection drops after the server has already accepted the request. Only 429 is retried.

(ns rovela.retry
  (:require [cheshire.core :as json]
            [clj-http.client :as http])
  (:import (java.util UUID)))

(def ^:private api-url "https://api.rovela.dev/emails")

(defn- post-once [api-key key payload]
  (http/post api-url
             {:headers {"Authorization"   (str "Bearer " api-key)
                        "Content-Type"    "application/json"
                        "Idempotency-Key" key}
              :body payload
              :throw-exceptions false}))

(defn send-with-retry [payload & {:keys [max-retries] :or {max-retries 4}}]
  (let [api-key (System/getenv "ROVELA_API_KEY")
        key     (str (UUID/randomUUID))       ; generated once, reused on every retry
        body    (json/generate-string payload)]
    (loop [attempt 0]
      (let [{:keys [status headers body]} (post-once api-key key body)]
        (cond
          (<= 200 status 299)
          (json/parse-string body true)

          (and (= 429 status) (< attempt (dec max-retries)))
          (let [retry-after (some-> (get headers "retry-after") parse-long)
                delay-ms    (long (* 1000 (or retry-after (Math/pow 2 attempt))))]
            (Thread/sleep delay-ms)
            (recur (inc attempt)))

          :else
          (throw (ex-info (str "HTTP " status ": " body) {:status status})))))))

(println (:id (send-with-retry {:from    "Acme <noreply@yourdomain.com>"
                                :to      "user@example.com"
                                :subject "Welcome aboard"
                                :html    "<p>Thanks for signing up!</p>"})))

Batch sending with an ExecutorService

pmap uses as many threads as the machine has cores, which is the wrong limit for network calls. A fixed thread pool caps how many requests are in flight:

(ns rovela.batch
  (:require [rovela.core :as rovela])
  (:import (java.util.concurrent Callable Executors Future TimeUnit)))

(defn- submit ^Future [pool f]
  (.submit pool ^Callable f))

(defn send-batch [recipients concurrency]
  (let [pool (Executors/newFixedThreadPool concurrency)]
    (try
      (->> recipients
           (mapv (fn [r]
                   (submit pool
                           (fn []
                             (try
                               {:email  (:email r)
                                :result (rovela/send-email (:email r) (:subject r) (:html r))}
                               (catch Exception e
                                 {:email (:email r) :error (.getMessage e)}))))))
           (mapv (fn [^Future f] (.get f))))
      (finally
        (.shutdown pool)
        (.awaitTermination pool 30 TimeUnit/SECONDS)))))

The pool bounds concurrency; awaitTermination gives in-flight requests a chance to finish before the JVM exits.

Verify webhooks with Ring

Rovela signs every event with HMAC-SHA256. The signature arrives in the x-resend-signature header as t=<timestamp>,v1=<hex-signature>. Compute HMAC-SHA256 over <timestamp>.<raw-body> using your webhook secret and compare with MessageDigest/isEqual, which does not short-circuit.

(ns rovela.webhook
  (:require [cheshire.core :as json]
            [clojure.string :as str]
            [compojure.core :refer [POST defroutes]]
            [ring.adapter.jetty :refer [run-jetty]])
  (:import (java.security MessageDigest)
           (javax.crypto Mac)
           (javax.crypto.spec SecretKeySpec)))

(defn hmac-hex [secret data]
  (let [mac (doto (Mac/getInstance "HmacSHA256")
              (.init (SecretKeySpec. (.getBytes ^String secret "UTF-8") "HmacSHA256")))]
    (->> (.doFinal mac (.getBytes ^String data "UTF-8"))
         (map #(format "%02x" (bit-and % 0xff)))
         (apply str))))

(defn parse-signature [header]
  (->> (str/split (or header "") #",")
       (keep (fn [part]
               (let [[k v] (str/split part #"=" 2)]
                 (when v [k v]))))
       (into {})))

(defn verify [secret header raw-body]
  (let [parts (parse-signature header)
        ts    (get parts "t")
        v1    (get parts "v1")]
    (when (and ts v1)
      (let [expected (hmac-hex secret (str ts "." raw-body))]
        (MessageDigest/isEqual (.getBytes ^String expected "UTF-8")
                               (.getBytes ^String v1 "UTF-8"))))))

(defroutes app
  (POST "/webhooks/rovela" request
    (let [raw-body (slurp (:body request))
          header   (get-in request [:headers "x-resend-signature"])
          secret   (System/getenv "ROVELA_WEBHOOK_SECRET")]
      (if (verify secret header raw-body)
        {:status 200 :headers {"Content-Type" "text/plain"} :body "ok"}
        {:status 400 :headers {"Content-Type" "text/plain"} :body "invalid signature"}))))

(defn -main [& _]
  (run-jetty app {:port 8080 :join? true}))

Verify webhooks with http-kit

http-kit hands the handler a request map whose :body is already a string, so the same verifier works unchanged:

(ns rovela.webhook-async
  (:require [org.httpkit.server :as server]
            [rovela.webhook :refer [verify]]))

(defn handler [request]
  (let [raw-body (:body request)
        header   (get-in request [:headers "x-resend-signature"])
        secret   (System/getenv "ROVELA_WEBHOOK_SECRET")]
    (if (verify secret header raw-body)
      {:status 200 :headers {"Content-Type" "text/plain"} :body "ok"}
      {:status 400 :headers {"Content-Type" "text/plain"} :body "invalid signature"})))

(defn -main [& _]
  (server/run-server handler {:port 8080}))

Both handlers read the body before touching JSON. hmac-hex must run over the exact bytes the sender signed, and re-encoding a parsed map will almost never reproduce them.