← Back to blog

Send Email with Lua using the Rovela API

Learn how to send transactional email from Lua using LuaSocket with LuaSec, lua-http or OpenResty, with retry logic and idempotency keys, concurrent batch sending via copas, and webhook verification with lua-resty-openssl and luaossl.

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

Lua is the language behind OpenResty, Redis scripting and a long tail of embedded systems, and it sends transactional email just as well as the heavier runtimes. This guide covers LuaSocket with LuaSec, lua-http and OpenResty, retry logic with idempotency keys, concurrent batch sending with copas, and webhook verification with lua-resty-openssl and luaossl.

Prerequisites

Set your API key as an environment variable:

export ROVELA_API_KEY="re_..."

Install the modules with LuaRocks:

luarocks install luasocket luasec lua-cjson copas lua-http luaossl
# OpenResty users get lua-resty-http and lua-resty-openssl from opm instead:
opm get ledgetech/lua-resty-http
opm get fffonion/lua-resty-openssl

Send with LuaSocket

socket.http cannot do TLS — an https:// URL through it silently fails or throws. Use ssl.https from LuaSec instead:

local https = require "ssl.https"   -- LuaSec; socket.http has no TLS support
local ltn12 = require "ltn12"
local cjson = require "cjson.safe"

local payload = assert(cjson.encode({
  from    = "Acme <noreply@yourdomain.com>",
  to      = "user@example.com",
  subject = "Welcome aboard",
  html    = "<p>Thanks for signing up!</p>",
}))

local chunks = {}
local ok, code, headers = https.request {
  url    = "https://api.rovela.dev/emails",
  method = "POST",
  headers = {
    ["Authorization"]  = "Bearer " .. assert(os.getenv "ROVELA_API_KEY"),
    ["Content-Type"]   = "application/json",
    ["Content-Length"] = tostring(#payload),
  },
  source = ltn12.source.string(payload),
  sink   = ltn12.sink.table(chunks),
}

local response = table.concat(chunks)
if not ok or code < 200 or code > 299 then
  error(("%s: %s"):format(tostring(code), response))
end

print(assert(cjson.decode(response)).id)

Send with lua-http

lua-http is built on cqueues and gives you non-blocking I/O without a framework:

local http_request = require "http.request"
local cjson = require "cjson.safe"

local req = assert(http_request.new_from_uri("https://api.rovela.dev/emails"))
req.headers:upsert(":method", "POST")
req.headers:upsert("authorization", "Bearer " .. assert(os.getenv "ROVELA_API_KEY"))
req.headers:upsert("content-type", "application/json")
assert(req:set_body(assert(cjson.encode({
  from    = "Acme <noreply@yourdomain.com>",
  to      = "user@example.com",
  subject = "Welcome aboard",
  html    = "<p>Thanks for signing up!</p>",
}))))

local headers, stream = assert(req:go())
local body = assert(stream:get_body_as_string())
assert(headers:get ":status" == "200", body)

print(assert(cjson.decode(body)).id)

Send with OpenResty

Inside OpenResty, lua-resty-http reuses the connection pool and respects nginx.conf SSL settings:

local httpc = require("resty.http").new()
local cjson = require "cjson.safe"

httpc:set_timeouts(2000, 2000, 5000)

local res, err = httpc:request_uri("https://api.rovela.dev/emails", {
  method = "POST",
  body   = cjson.encode({
    from    = "Acme <noreply@yourdomain.com>",
    to      = "user@example.com",
    subject = "Welcome aboard",
    html    = "<p>Thanks for signing up!</p>",
  }),
  headers = {
    ["Authorization"] = "Bearer " .. assert(os.getenv "ROVELA_API_KEY"),
    ["Content-Type"]  = "application/json",
  },
  ssl_verify = true,   -- requires lua_ssl_trusted_certificate in nginx.conf
})

httpc:close()

if not res then
  ngx.log(ngx.ERR, "rovela request failed: ", err)
  return ngx.exit(ngx.HTTP_BAD_GATEWAY)
end
if res.status < 200 or res.status > 299 then
  ngx.log(ngx.ERR, "rovela send failed: ", res.status, " ", res.body)
  return ngx.exit(ngx.HTTP_BAD_GATEWAY)
end

ngx.say(assert(cjson.decode(res.body)).id)

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; every other status is returned to the caller.

local https  = require "ssl.https"
local ltn12  = require "ltn12"
local socket = require "socket"
local cjson  = require "cjson.safe"

local MAX_RETRIES = 4

local function idempotency_key()
  local f = assert(io.open("/dev/urandom", "rb"))
  local bytes = assert(f:read(16))
  f:close()
  return (bytes:gsub(".", function(c) return ("%02x"):format(c:byte()) end))
end

local function post(api_key, key, payload)
  local chunks = {}
  local ok, code, headers = https.request {
    url    = "https://api.rovela.dev/emails",
    method = "POST",
    headers = {
      ["Authorization"]   = "Bearer " .. api_key,
      ["Content-Type"]    = "application/json",
      ["Content-Length"]  = tostring(#payload),
      ["Idempotency-Key"] = key,
    },
    source = ltn12.source.string(payload),
    sink   = ltn12.sink.table(chunks),
  }
  return ok, code, headers, table.concat(chunks)
end

local api_key = assert(os.getenv "ROVELA_API_KEY")
local payload = assert(cjson.encode({
  from    = "Acme <noreply@yourdomain.com>",
  to      = "user@example.com",
  subject = "Welcome aboard",
  html    = "<p>Thanks for signing up!</p>",
}))

local key = idempotency_key()   -- generated once, reused on every retry
local body

for attempt = 0, MAX_RETRIES - 1 do
  local ok, code, headers, response = post(api_key, key, payload)

  if not ok then
    error("network error: " .. tostring(code))
  elseif code == 429 then
    socket.sleep(tonumber((headers or {})["retry-after"]) or 2 ^ attempt)
  elseif code >= 200 and code <= 299 then
    body = response
    break
  else
    error(("%d: %s"):format(code, response))
  end
end

if not body then
  error("rate limit exceeded after " .. MAX_RETRIES .. " attempts")
end

print(assert(cjson.decode(body)).id)

Batch sending with copas

copas turns blocking LuaSocket calls into coroutines that yield while waiting on the network. It runs every thread concurrently, so chunk the list to cap how many requests are in flight:

local copas = require "copas"
local https = require "ssl.https"
local ltn12 = require "ltn12"
local cjson = require "cjson.safe"

local CONCURRENCY = 5
local api_key = assert(os.getenv "ROVELA_API_KEY")

local function send_one(r)
  local payload = assert(cjson.encode({
    from    = "Acme <noreply@yourdomain.com>",
    to      = r.to,
    subject = r.subject,
    html    = r.html,
  }))
  local chunks = {}
  local ok, code = https.request {
    url    = "https://api.rovela.dev/emails",
    method = "POST",
    headers = {
      ["Authorization"]  = "Bearer " .. api_key,
      ["Content-Type"]   = "application/json",
      ["Content-Length"] = tostring(#payload),
    },
    source = ltn12.source.string(payload),
    sink   = ltn12.sink.table(chunks),
  }
  print(("%s: %s"):format(r.to, ok and tostring(code) or "error"))
end

local recipients = {
  { to = "ada@example.com",   subject = "Hello Ada",   html = "<p>Hi Ada</p>" },
  { to = "grace@example.com", subject = "Hello Grace", html = "<p>Hi Grace</p>" },
}

for i = 1, #recipients, CONCURRENCY do
  for j = i, math.min(i + CONCURRENCY - 1, #recipients) do
    local r = recipients[j]
    copas.addthread(function() send_one(r) end)
  end
  copas.loop()   -- returns once this chunk has finished
end

Verify webhooks in OpenResty

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 in constant time.

local hmac  = require "resty.openssl.hmac"
local cjson = require "cjson.safe"

local function unhex(s)
  if #s % 2 ~= 0 or s:find("%X") then return nil end
  return (s:gsub("%x%x", function(pair)
    return string.char(tonumber(pair, 16))
  end))
end

local function constant_time_eq(a, b)
  if #a ~= #b then return false end
  local diff = 0
  for i = 1, #a do
    diff = diff + math.abs(a:byte(i) - b:byte(i))
  end
  return diff == 0
end

ngx.req.read_body()
local raw = ngx.req.get_body_data()
if not raw then
  -- the body was buffered to disk; raise client_body_buffer_size
  return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
end

local header = ngx.req.get_headers()["x-resend-signature"] or ""
local ts, v1 = header:match("^t=([^,]+),v1=(%x+)$")
if not ts then
  return ngx.exit(ngx.HTTP_BAD_REQUEST)
end

local provided = unhex(v1)
if not provided then
  return ngx.exit(ngx.HTTP_BAD_REQUEST)
end

local mac = assert(hmac.new(ngx.var.rovela_webhook_secret, "sha256"))
local expected = assert(mac:final(ts .. "." .. raw))

if not constant_time_eq(expected, provided) then
  return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end

local event = assert(cjson.decode(raw))
if event.type == "email.delivered" then
  -- mark delivered
elseif event.type == "email.bounced" then
  -- flag the address
end

ngx.say("ok")

Verify webhooks with luaossl

Outside OpenResty, luaossl provides the same primitives. Keep the verifier framework-agnostic and hand it the body exactly as it arrived:

local hmac = require "openssl.hmac"

local function unhex(s)
  if #s % 2 ~= 0 or s:find("%X") then return nil end
  return (s:gsub("%x%x", function(pair)
    return string.char(tonumber(pair, 16))
  end))
end

local function constant_time_eq(a, b)
  if #a ~= #b then return false end
  local diff = 0
  for i = 1, #a do
    diff = diff + math.abs(a:byte(i) - b:byte(i))
  end
  return diff == 0
end

-- Pass the raw request body, not a decoded table.
local function verify_rovela(secret, signature_header, raw_body)
  local ts, v1 = signature_header:match("^t=([^,]+),v1=(%x+)$")
  if not ts then
    return false, "missing signature fields"
  end

  local provided = unhex(v1)
  if not provided then
    return false, "malformed hex"
  end

  local expected = hmac.new(secret, "sha256"):final(ts .. "." .. raw_body)
  if not constant_time_eq(expected, provided) then
    return false, "invalid signature"
  end

  return true
end

With lua-http's server, the body is already buffered: call verify_rovela(secret, stream.request_headers:get("x-resend-signature") or "", assert(stream:get_body_as_string())) before decoding it.

Both comparisons walk the whole string and only look at the result at the end, so the running time does not reveal how many leading bytes matched.