Volanea
Transactional email via Volanea
medusa-provider-notification-volanea
Send Medusa's transactional email through Volanea — order confirmations, shipment notices, password resets, customer invites, and anything else you dispatch through the Notification Module.
Zero runtime dependencies. Medusa v2, Node 20+.
Install
1npm install medusa-provider-notification-volanea
Register
Copy to clipboardmedusa-config.ts:
1234567891011121314151617181920module.exports = defineConfig({modules: [{resolve: "@medusajs/medusa/notification",options: {providers: [{resolve: "medusa-provider-notification-volanea",id: "volanea",options: {channels: ["email"],apiKey: process.env.VOLANEA_API_KEY,from: process.env.VOLANEA_FROM,fromName: process.env.VOLANEA_FROM_NAME,},},],},},],
Copy to clipboard.env:
123VOLANEA_API_KEY=sk_live_xxxxxxxxxxxxVOLANEA_FROM=orders@yourstore.comVOLANEA_FROM_NAME=Your Store
Only one provider can serve a channel, so this replaces whatever was on Copy to clipboardemail before.
Copy to clipboardfrom must be on a domain verified in Volanea. An unverified domain is a 403 on every send, not a warning. Verify it under Domains in the dashboard before you register the provider.
Send
1234567891011const notificationModuleService = container.resolve(Modules.NOTIFICATION)await notificationModuleService.createNotifications({to: "customer@example.com",channel: "email",template: "tpl_order_placed", // a Volanea template iddata: {order: { id: order.id, total: "$49.00" },customer: { first_name: "Ada" },},})
Or with inline content and no stored template:
12345678910await notificationModuleService.createNotifications({to: "customer@example.com",channel: "email",template: "",content: {subject: "Your order shipped",html: "<p>On its way.</p>",text: "On its way.",},})
Two behaviours to design around
1. A 200 does not mean delivered. Copy to clipboard/v1/send returns as soon as the message is durably queued; the provider hand-off happens afterwards. This provider reports accepted, never delivered. For the real outcome, subscribe a webhook endpoint to Copy to clipboardemail.delivered, Copy to clipboardemail.bounced and Copy to clipboardemail.complained and correlate on the id this provider returns.
2. A 200 can mean nothing was sent. Volanea reports per-recipient skips — suppressed, unsubscribed, over quota, reputation-paused — inside a successful body. Medusa reads a resolved promise as success, so a fully skipped send would otherwise vanish: the notification row says sent, and the customer never got their order confirmation.
So when every recipient was skipped, this provider throws (Copy to clipboardall_recipients_skipped). Set Copy to clipboardthrowOnSkip: false if you would rather have the send resolve and read the skips out of your logs.
Options
Option Default Purpose Copy to clipboardapiKey — Required. Volanea secret key. Copy to clipboardbaseUrl Copy to clipboardhttps://api.volanea.com Override the API host. Copy to clipboardfrom — Default sender. Must be on a verified domain. Copy to clipboardfromName — Default sender display name. Copy to clipboardreplyTo — Default Reply-To. Copy to clipboardtimeout Copy to clipboard15000 Abort the request after this many ms. Copy to clipboardthrowOnSkip Copy to clipboardtrue Throw when every recipient was skipped. Copy to clipboardchannels — Read by Medusa, not by this provider.
Medusa's first-party providers spell options in snake_case (Copy to clipboardapi_key), so every option above is also accepted in that style — Copy to clipboardapi_key, Copy to clipboardbase_url, Copy to clipboardfrom_name, Copy to clipboardreply_to, Copy to clipboardthrow_on_skip. camelCase wins if you supply both.
A missing or malformed option throws at construction, so a misconfigured provider stops Medusa from booting rather than failing silently at the first order.
How a notification maps onto the API
Medusa Volanea Notes Copy to clipboardto Copy to clipboardto Copy to clipboard"Ada <ada@x.com>" splits into an addressed recipient. Copy to clipboardtemplate Copy to clipboardtemplateId A non-empty template names the body. Copy to clipboardcontent.subject Copy to clipboardsubject Sent either way, so it can override a template's subject. Copy to clipboardcontent.html / Copy to clipboard.text Copy to clipboardhtml / Copy to clipboardtext Used only when there is no Copy to clipboardtemplate. Copy to clipboarddata Copy to clipboardvariables Flattened — see below. Copy to clipboardfrom Copy to clipboardfrom + Copy to clipboardfromName A display name is split out. Copy to clipboardattachments Copy to clipboardattachments Copy to clipboardcontent is base64; Copy to clipboardcontent_type → Copy to clipboardcontentType. Copy to clipboardprovider_data.idempotencyKey Copy to clipboardIdempotency-Key header A repeat replays the stored response instead of sending twice.
Everything is sent as Copy to clipboardtype: "transactional" — an order confirmation has to reach someone who unsubscribed from marketing. Pass Copy to clipboardprovider_data: { type: "marketing" } for a send that should honour unsubscribes and carry the unsubscribe footer.
Copy to clipboardprovider_data also passes Copy to clipboardheaders, Copy to clipboardsendAt, Copy to clipboardcreateContact, Copy to clipboardreplyTo and Copy to clipboardvariables straight through.
Why Copy to clipboarddata is flattened
Volanea validates Copy to clipboardvariables as a flat Copy to clipboardRecord<string, string | number | boolean> and rejects anything else with a 422 — but Medusa's Copy to clipboarddata is routinely nested. This provider flattens to dotted keys rather than rejecting:
1234{ order: { id: "order_01", total: 4900 }, items: [{ title: "Mug" }] }// becomes{ "order.id": "order_01", "order.total": 4900,"items.length": 1, "items.0.title": "Mug" }
Volanea's renderer resolves a placeholder by trying the literal key first, so Copy to clipboard{{order.id}} in your template picks the flattened key up unchanged — you write the template exactly as the nested data reads. Copy to clipboardnull and Copy to clipboardundefined are dropped rather than rendering the literal text "null"; Copy to clipboardDate values become ISO strings.
Attachments
Copy to clipboardcontent is treated as already base64, matching Medusa's own type and how its providers hand attachments to SendGrid. Buffers, Copy to clipboardUint8Arrays and Copy to clipboarddata: URIs are also accepted. Up to 10 attachments, 10 MiB per request. Nothing is read from disk or from a URL — pass the bytes.
Errors
Every failure arrives as a Copy to clipboardMedusaError naming the specific cause. Branch on the code, never on the wording.
Code Meaning Copy to clipboardunauthorized Missing or invalid API key. Copy to clipboardsend_failed Send rejected — an unverified Copy to clipboardfrom domain is the usual cause, and the domain is named in the message. Copy to clipboardtemplate_not_found The Copy to clipboardtemplate is not a Volanea template id. Copy to clipboardvalidation_error The request was malformed; the failing fields are in Copy to clipboarddetails. Copy to clipboardidempotency_key_reused Same key, different body. Nothing was sent. Copy to clipboardrate_limited Per-project burst cap. The retry delay is in the message. Copy to clipboardall_recipients_skipped Accepted, but every recipient was skipped. Copy to clipboardinvalid_attachment An attachment had no content. Copy to clipboardtimeout No response within Copy to clipboardtimeout ms. Copy to clipboardnetwork_error The API was unreachable. Copy to clipboardbad_response A non-JSON response.
A raw Copy to clipboardfetch error never reaches Medusa — transport failures are wrapped before they leave the provider.
Troubleshooting
403 on every send — the Copy to clipboardfrom domain is not verified in Volanea. Check Domains in the dashboard. This is the single most common cause of a provider that "doesn't work" from the first order onward.
Nothing arrives and there is no error — you are running with Copy to clipboardthrowOnSkip: false, and the recipients were skipped. Check the logs for Copy to clipboardrecipient(s) skipped, then Suppressions in the dashboard. A suppressed address stays suppressed until you remove it.
It works in development but not production — you are almost certainly using a test-mode key. Test-mode sends render, validate and log, but never leave the building. The provider logs a warning on every test-mode send; check for Copy to clipboardsent with a test-mode key.
Medusa boots but the provider is never called — another provider holds the Copy to clipboardemail channel. Only one provider serves a channel; remove the other entry.
Copy to clipboardapiKey is required at boot — the option did not reach the provider. It belongs in the provider entry's own Copy to clipboardoptions, not the notification module's top-level Copy to clipboardoptions.
Testing locally
Send to Copy to clipboardsuccess@simulator.amazonses.com. It is absorbed by the SES mailbox simulator: it never bounces, and it never counts against your quota, bounce rate, or sender reputation.
Do not invent a test address on a real domain. It hard-bounces, and hard bounces damage the sender reputation of every domain on your account.
1npm test # builds, then runs the suite against the built output
The suite uses Node's built-in test runner with no framework dependency: Copy to clipboardtest/payload.test.js and Copy to clipboardtest/client.test.js cover the mapping and the HTTP client in isolation, and Copy to clipboardtest/medusa-contract.test.js loads the built entry point against the real Copy to clipboard@medusajs/framework so an upstream signature change fails here instead of at your boot.
Licence
MIT

