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.

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.