thinwrap_notifications
thinwrap_notifications for Erlang, Elixir and Gleam — one Hex package, a unified send API across 35 providers for email, SMS, push and chat. Zero runtime deps (OTP httpc, crypto), stateless, BYO transport.
Erlang constraints
- Runtime dependencies
- Zero runtime dependencies
- BYO HTTP client
- transport behaviour: transport => {Module, Opts}, where Module exports request/2
- Provenance
- Hex registry signatures + lockfile checksums
- Minimum runtime
- Erlang/OTP 27+
- Truly zero dependencies — OTP only: httpc (inets + ssl) is the only HTTP surface, the stdlib json module parses JSON, and AWS SigV4 (SES, SNS, Bedrock) / JWT signing (FCM RS256, APNs ES256) are hand-rolled on crypto and public_key.
- Hex has no build-provenance attestation like npm provenance or PEP 740: the registry signs its index and your lockfile pins each tarball checksum, which proves integrity, not where the package was built.
- Stateless: no supervision tree, ETS tables or application env to configure. The default httpc transport is HTTPS-only and never follows redirects — a 3xx surfaces as an error rather than silently re-sending auth headers to another host.
30-second install
{deps, [{thinwrap_notifications, "~> 1.2"}]}.{:thinwrap_notifications, "~> 1.2"}gleam add thinwrap_notifications gleam_erlang envoy2-minute end-to-end example
One function per channel (send_email/2,send_sms/2, send_push/2,send_chat/2); the config's provider selects the vendor. A provider that doesn't serve the channel is rejected before anything is sent. One package, three ways to call it:
Erlang
Paste into a function body or the shell. Reads the key from SENDGRID_API_KEY. More on calling it from Erlang: /erlang.
Config = #{provider => sendgrid,
api_key => list_to_binary(os:getenv("SENDGRID_API_KEY")),
from => <<"hello@acme.com">>},
case thinwrap_notifications:send_email(Config, #{
to => <<"user@example.com">>,
subject => <<"Welcome">>,
text => <<"Thanks for signing up!">>,
html => <<"<p>Thanks for signing up!</p>">>
}) of
{ok, #{status := Status, provider_message_id := Id}} ->
io:format("~s ~s~n", [Status, Id]);
{error, #{code := Code, message := Msg}} ->
io:format("~s: ~ts~n", [Code, Msg])
end.Elixir
Top-level script code (mix run). Reads the key from SENDGRID_API_KEY. More on calling it from Elixir: /elixir.
config = %{
provider: :sendgrid,
api_key: System.fetch_env!("SENDGRID_API_KEY"),
from: "hello@acme.com"
}
case :thinwrap_notifications.send_email(config, %{
to: "user@example.com",
subject: "Welcome",
text: "Thanks for signing up!",
html: "<p>Thanks for signing up!</p>"
}) do
{:ok, %{status: status, provider_message_id: id}} ->
IO.puts("#{status} #{id}")
{:error, %{code: code, message: message}} ->
IO.puts("#{code}: #{message}")
endGleam
A complete module. Besides the package it uses gleam_erlang for atoms and envoy to read SENDGRID_API_KEY; rebar3 must be on PATH to build the package. More on calling it from Gleam: /gleam.
import envoy
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode
import gleam/erlang/atom
import gleam/io
import gleam/list
@external(erlang, "thinwrap_notifications", "send_email")
fn send_email(config: Dynamic, input: Dynamic) -> Result(Dynamic, Dynamic)
pub fn main() -> Nil {
let assert Ok(api_key) = envoy.get("SENDGRID_API_KEY")
let config =
obj([
#("provider", atom("sendgrid")),
#("api_key", dynamic.string(api_key)),
#("from", dynamic.string("hello@acme.com")),
])
let input =
obj([
#("to", dynamic.string("user@example.com")),
#("subject", dynamic.string("Welcome")),
#("text", dynamic.string("Thanks for signing up!")),
#("html", dynamic.string("<p>Thanks for signing up!</p>")),
])
case send_email(config, input) {
Ok(res) -> {
let sent = {
use status <- decode.field(key("status"), atom.decoder())
use id <- decode.field(key("provider_message_id"), decode.string)
decode.success(atom.to_string(status) <> " " <> id)
}
let assert Ok(line) = decode.run(res, sent)
io.println(line)
}
Error(err) -> io.println(describe(err))
}
}
// The library speaks atom-keyed Erlang maps; these helpers build and read them.
fn key(name: String) -> atom.Atom {
atom.create(name)
}
fn atom(name: String) -> Dynamic {
atom.to_dynamic(atom.create(name))
}
fn obj(fields: List(#(String, Dynamic))) -> Dynamic {
dynamic.properties(list.map(fields, fn(f) { #(atom(f.0), f.1) }))
}
fn describe(err: Dynamic) -> String {
let error = {
use code <- decode.field(key("code"), atom.decoder())
use message <- decode.field(key("message"), decode.string)
decode.success(atom.to_string(code) <> ": " <> message)
}
let assert Ok(line) = decode.run(err, error)
line
}Switch vendor by changing the config — setprovider to resend, postmark,ses, and so on, with that vendor's credentials. The call and the result map (success, status,provider_message_id, raw) stay identical. Same pattern for SMS, push, and chat.
Supported providers
| Provider | Channel | Auth | README |
|---|---|---|---|
sendgrid SendGrid | bearer | docs | |
ses AWS SES | aws-sig-v4 | docs | |
mailgun Mailgun | basic | docs | |
postmark Postmark | header | docs | |
resend Resend | bearer | docs | |
brevo Brevo (Sendinblue) | header | docs | |
mailersend MailerSend | bearer | docs | |
mailtrap Mailtrap | bearer | docs | |
sparkpost SparkPost | header | docs | |
scaleway Scaleway | header | docs | |
twilio Twilio | sms | basic | docs |
vonage Vonage | sms | query | docs |
plivo Plivo | sms | basic | docs |
sns AWS SNS | sms | aws-sig-v4 | docs |
messagebird MessageBird (Bird) | sms | header | docs |
infobip Infobip | sms | header | docs |
telnyx Telnyx | sms | bearer | docs |
sinch Sinch | sms | bearer | docs |
textmagic Textmagic | sms | header | docs |
d7networks D7 Networks | sms | bearer | docs |
fcm Firebase Cloud Messaging | push | rs256-jwt | docs |
apns Apple Push Notifications | push | es256-jwt | docs |
expo Expo Push | push | bearer | docs |
one-signal OneSignal | push | header | docs |
pusher-beams Pusher Beams | push | bearer | docs |
wonderpush WonderPush | push | bearer | docs |
slack Slack | chat | webhook-url | docs |
discord Discord | chat | webhook-url | docs |
ms-teams Microsoft Teams | chat | webhook-url | docs |
telegram Telegram | chat | webhook-url | docs |
whatsapp-business WhatsApp Business | chat | bearer | docs |
line LINE | chat | bearer | docs |
google-chat Google Chat | chat | webhook-url | docs |
rocket-chat Rocket.Chat | chat | webhook-url | docs |
mattermost Mattermost | chat | webhook-url | docs |
Migration paths
Zero-dependency signing
No vendor SDKs and no third-party dependencies — AWS SigV4 (SES/SNS) and JWT signing (FCM RS256, APNs ES256) are hand-rolled on OTPcrypto and public_key. The wrapper is stateless: FCM/APNs sign a fresh token on every send unless you supply atoken_cache (a get/set pair of funs) on the config. For APNs, supply one in production: Apple rejects a new provider token more often than once per 20 minutes.
Passthrough escape hatch
Only fields ≥90% of a channel's providers support are first-class on the input. Provider-specific fields flow through thepassthrough key (body deep-merged,headers/query shallow-merged; your values win) — a Twilio MMS mediaUrl, for example.
Bring your own transport
Pass a module implementing the one-callback transport behaviour (request/2) as transport => {Module, Opts}. The default OTP httpc transport is HTTPS-only and never follows redirects. There is deliberately no top-level retry key — the rawRetry-After header and its parsed seconds ride in the error's cause, and retry is your policy.
Cross-language siblings
The TypeScript (/packages/notifications-ts), PHP (/packages/notifications-php), Go (/packages/notifications-go), and Python (/packages/notifications-py) packages ship the same API shape and provider set — cross-language parity is a design goal, not a hard release gate.