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 dashboardDomains → 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

OptionDefaultPurpose
apiKeyRequired. Volanea secret key.
baseUrlhttps://api.volanea.comOverride the API host.
fromDefault sender. Must be on a verified domain.
fromNameDefault sender display name.
replyToDefault Reply-To.
timeout15000Abort the request after this many ms.
throwOnSkiptrueThrow 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

MedusaVolaneaNotes
toto"Ada <ada@x.com>" splits into an addressed recipient.
templatetemplateIdA non-empty template names the body.
content.subjectsubjectSent either way, so it can override a template's subject.
content.html / .texthtml / textUsed only when there is no template.
datavariablesFlattened — see below.
fromfrom + fromNameA display name is split out.
attachmentsattachmentscontent is base64; content_typecontentType.
provider_data.idempotencyKeyIdempotency-Key headerA 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.

CodeMeaning
unauthorizedMissing or invalid API key.
send_failedSend rejected — an unverified from domain is the usual cause, and the domain is named in the message.
template_not_foundThe template is not a Volanea template id.
validation_errorMalformed request; failing fields are in details.
idempotency_key_reusedSame key, different body. Nothing was sent.
rate_limitedPer-project burst cap.
all_recipients_skippedAccepted, but every recipient was skipped.
timeout / network_errorThe 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