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"}]}.

2-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}")
end

Gleam

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

35 providers ship with thinwrap_notifications@1.2.0.
ProviderChannelAuthREADME
sendgrid SendGridemailbearerdocs
ses AWS SESemailaws-sig-v4docs
mailgun Mailgunemailbasicdocs
postmark Postmarkemailheaderdocs
resend Resendemailbearerdocs
brevo Brevo (Sendinblue)emailheaderdocs
mailersend MailerSendemailbearerdocs
mailtrap Mailtrapemailbearerdocs
sparkpost SparkPostemailheaderdocs
scaleway Scalewayemailheaderdocs
twilio Twiliosmsbasicdocs
vonage Vonagesmsquerydocs
plivo Plivosmsbasicdocs
sns AWS SNSsmsaws-sig-v4docs
messagebird MessageBird (Bird)smsheaderdocs
infobip Infobipsmsheaderdocs
telnyx Telnyxsmsbearerdocs
sinch Sinchsmsbearerdocs
textmagic Textmagicsmsheaderdocs
d7networks D7 Networkssmsbearerdocs
fcm Firebase Cloud Messagingpushrs256-jwtdocs
apns Apple Push Notificationspushes256-jwtdocs
expo Expo Pushpushbearerdocs
one-signal OneSignalpushheaderdocs
pusher-beams Pusher Beamspushbearerdocs
wonderpush WonderPushpushbearerdocs
slack Slackchatwebhook-urldocs
discord Discordchatwebhook-urldocs
ms-teams Microsoft Teamschatwebhook-urldocs
telegram Telegramchatwebhook-urldocs
whatsapp-business WhatsApp Businesschatbearerdocs
line LINEchatbearerdocs
google-chat Google Chatchatwebhook-urldocs
rocket-chat Rocket.Chatchatwebhook-urldocs
mattermost Mattermostchatwebhook-urldocs

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.

Read more