What it does
medusa-provider-notification-volanea is a Medusa v2 notification provider that sends Medusa's transactional email through the Volanea API — order confirmations, shipment notices, password resets, customer invites, and anything else dispatched through the Notification Module.
Zero runtime dependencies, Node 20+. It maps Medusa's notification payload onto POST /v1/send and hands back the Volanea message id, so a notification row can be correlated with the delivery, bounce and complaint webhooks later.
Before you install
Verify a sending domain. Volanea dashboard → Domains → Add domain, then copy the DKIM, SPF and DMARC records into your DNS. Until the domain reads verified, every send returns 403. This is the most common cause of a provider that appears broken from the very first order.
Get your key. Dashboard → Settings → API keys. Use a test key while wiring up: test sends render and log but never reach an inbox.
Install
npm install medusa-provider-notification-volanea
Register
medusa-config.ts:
module.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,
},
},
],
},
},
],
})
VOLANEA_API_KEY=sk_live_xxxxxxxxxxxx
VOLANEA_FROM=orders@yourstore.com
VOLANEA_FROM_NAME=Your Store
Only one provider can serve a channel, so this replaces whatever held email before. A missing option throws during boot rather than at the first order — if Medusa starts, the provider is configured.
Send
const notificationModuleService = container.resolve(Modules.NOTIFICATION)
await notificationModuleService.createNotifications({
to: "customer@example.com",
channel: "email",
template: "tpl_order_placed", // a Volanea template id
data: {
order: { id: order.id, total: "$49.00" },
customer: { first_name: "Ada" },
},
})
Inline content works when you have no stored template — pass an empty template and a content object with subject, html and text.
Two behaviours to design around
A 200 does not mean delivered. /v1/send returns once the message is durably queued. The provider reports accepted, never delivered. For the real outcome, register a webhook for email.delivered, email.bounced and email.complained and correlate on the id the provider returns.
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 would say sent, and the customer would never receive their order confirmation.
So when every recipient was skipped, the provider throws (all_recipients_skipped). Set throwOnSkip: false to have the send resolve and read the skips out of your logs instead.
Options
| Option | Default | Purpose |
|---|---|---|
apiKey | — | Required. Volanea secret key. |
baseUrl | https://api.volanea.com | Override the API host. |
from | — | Default sender. Must be on a verified domain. |
fromName | — | Default sender display name. |
replyTo | — | Default Reply-To. |
timeout | 15000 | Abort the request after this many ms. |
throwOnSkip | true | Throw when every recipient was skipped. |
Medusa's first-party providers spell options in snake_case (@medusajs/notification-sendgrid takes api_key), so every option above is also accepted that way — api_key, base_url, from_name, reply_to, throw_on_skip. camelCase wins if both are given.
How a notification maps onto the API
| Medusa | Volanea | Notes |
|---|---|---|
to | to | "Ada <ada@x.com>" splits into an addressed recipient. |
template | templateId | A non-empty template names the body. |
content.subject | subject | Sent either way, so it can override a template's subject. |
content.html / .text | html / text | Used only when there is no template. |
data | variables | Flattened — see below. |
from | from + fromName | A display name is split out. |
attachments | attachments | content is base64; content_type → contentType. |
provider_data.idempotencyKey | Idempotency-Key header | A repeat replays the stored response instead of sending twice. |
Everything is sent as type: "transactional" — an order confirmation must reach someone who unsubscribed from marketing. Pass provider_data: { type: "marketing" } for a send that should honour unsubscribes and carry the unsubscribe footer.
Why data is flattened
Volanea validates variables as a flat map of string, number or boolean and rejects anything else with a 422 — but Medusa's data is routinely nested. The provider flattens to dotted keys rather than rejecting:
{ 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 {{order.id}} in your template picks the flattened key up unchanged — you write the template exactly as the nested data reads. null and undefined are dropped rather than rendering the text "null"; Date values become ISO strings.
Attachments
content is treated as already base64, matching Medusa's own type and how its providers hand attachments to SendGrid. Buffers, Uint8Arrays and data: URIs are also accepted. Up to 10 attachments, 10 MiB per request.
(If you also use the Volanea Strapi provider, note the rule is inverted there: a string is raw text, because that is Nodemailer's convention. The two ecosystems genuinely disagree, so each provider follows its own.)
Errors
Every failure arrives as a MedusaError naming the specific cause. Branch on the code, never the wording.
| Code | Meaning |
|---|---|
unauthorized | Missing or invalid API key. |
send_failed | Send rejected — an unverified from domain is the usual cause, and the domain is named in the message. |
template_not_found | The template is not a Volanea template id. |
validation_error | Malformed request; failing fields are in details. |
idempotency_key_reused | Same key, different body. Nothing was sent. |
rate_limited | Per-project burst cap. |
all_recipients_skipped | Accepted, but every recipient was skipped. |
timeout / network_error | The API did not answer. |
A raw fetch error never reaches Medusa — transport failures are wrapped before they leave the provider.
Troubleshooting
403 on every send. The from domain is not verified in Volanea. Check Domains in the dashboard.
Nothing arrives and there is no error. You are running with throwOnSkip: false and the recipients were skipped. Check the logs for recipient(s) skipped, then Suppressions in the dashboard.
It works in development but not production. A test-mode key. The provider logs a warning on every test-mode send — look for sent with a test-mode key.
Medusa boots but the provider is never called. Another provider holds the email channel. Only one provider serves a channel; remove the other entry.
apiKey is required at boot. The option did not reach the provider. It belongs in the provider entry's own options, not the notification module's top-level options.
Testing locally
Send to success@simulator.amazonses.com. The SES mailbox simulator absorbs it: never bounces, 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.
Source
- npm:
medusa-provider-notification-volanea - Source and issues: github.com/Pankaj-bit361/volanea-integrations
- API reference:
POST /v1/send