Manage storefront email subscriptions (opt-ins and opt-outs) in Medusa Admin.
npm install medusa-contact-usA comprehensive Medusa v2 plugin for managing contact requests and email subscriptions. The plugin provides a complete solution: database-backed contact requests with configurable status workflows, email subscriptions, admin APIs, helper utilities, and a polished admin UI.
Features:
- ๐ Contact request management with status workflow and email notifications
- โ๏ธ Email subscription management (opt-ins and opt-outs)
- ๐ Configurable status transitions and validation
- ๐ง Automatic email notifications on status changes
- ๐ฏ Payload validation against configurable field definitions
upsertContactSubscription) to abstract API wiringsubmitContactRequest) for easy integration``bash`
yarn add medusa-contact-usor
npm install medusa-contact-us
Inside medusa-config.ts register the plugin:
`ts
import type { ConfigModule } from "@medusajs/framework/types"
import {
ContactSubscriptionModule,
ContactRequestModule,
} from "medusa-contact-us"
const plugins = [
{
resolve: "medusa-contact-us",
options: {
// Contact Request Configuration
default_status: "pending",
payload_fields: [
{
key: "subject",
type: "text",
required: true,
label: "Subject",
placeholder: "Enter subject",
},
{
key: "message",
type: "textarea",
required: true,
label: "Message",
placeholder: "Enter your message",
},
{
key: "priority",
type: "select",
required: false,
label: "Priority",
options: [
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
],
},
],
allowed_statuses: ["pending", "in_progress", "resolved", "closed"],
status_transitions: [
{
from: null,
to: "pending",
send_email: false,
},
{
from: "pending",
to: "in_progress",
send_email: true,
email_subject: "Your request is being processed",
email_template: null, // Optional: path to email template
},
{
from: "in_progress",
to: "resolved",
send_email: true,
email_subject: "Your request has been resolved",
},
{
from: "resolved",
to: "closed",
send_email: false,
},
],
email: {
enabled: true,
default_subject: "Contact Request Status Update",
default_template: null, // Optional: default email template path
},
},
},
]
// Register the modules
const modules = [ContactSubscriptionModule, ContactRequestModule]
export default {
projectConfig: {
// Your Medusa project configuration
// database_url: process.env.DATABASE_URL,
// ...
},
plugins,
modules,
} satisfies ConfigModule
`
#### Contact Request Options
- default_status (string, optional): Default status for new requests. Default: "pending"payload_fields
- (array, optional): Field definitions for payload validation. Each field supports:key
- (string, required): Field identifiertype
- (string, required): Field type - "text", "textarea", "number", "email", "select", "checkbox"required
- (boolean, optional): Whether field is requiredlabel
- (string, optional): Display labelplaceholder
- (string, optional): Placeholder textoptions
- (array, optional): For select type - [{ value: string, label: string }]validation
- (object, optional): Validation rules (min, max, pattern)allowed_statuses
- (array, optional): List of allowed status values. Default: ["pending", "in_progress", "resolved", "closed"]status_transitions
- (array, optional): Allowed status transitions. Each transition supports:from
- (string | null): Source status (null = initial status)to
- (string, required): Target statussend_email
- (boolean, optional): Whether to send email on this transitionemail_subject
- (string, optional): Custom email subject for this transitionemail_template
- (string | null, optional): Custom email template pathemail
- (object, optional): Email notification settingsenabled
- (boolean, optional): Enable/disable email notifications. Default: truedefault_subject
- (string, optional): Default email subject. Default: "Contact Request Status Update"default_template
- (string | null, optional): Default email template path
#### Contact Requests
Create a contact request:
`bash`
curl -X POST https://your-medusa.com/store/contact-requests \
-H "Content-Type: application/json" \
-H "x-publishable-api-key: pk_storefront" \
-d '{
"email": "customer@example.com",
"payload": {
"subject": "Order inquiry",
"message": "I need help with my order #12345"
},
"metadata": {
"source_page": "contact"
},
"source": "storefront"
}'
Body fields:
- email โ required, valid email addresspayload
- โ optional, JSON object with custom fields (validated against configured payload_fields)metadata
- โ optional, additional metadatasource
- โ optional, request source identifier (default: "storefront")
Response:
`json`
{
"request": {
"id": "creq_123",
"email": "customer@example.com",
"payload": {
"subject": "Order inquiry",
"message": "I need help with my order #12345"
},
"status": "pending",
"status_history": [
{
"from": null,
"to": "pending",
"changed_at": "2024-11-29T16:33:17.000Z"
}
],
"metadata": {
"source_page": "contact"
},
"source": "storefront",
"created_at": "2024-11-29T16:33:17.000Z",
"updated_at": "2024-11-29T16:33:17.000Z"
}
}
#### Email subscriptions
`bash`
curl -X POST https://your-medusa.com/store/contact-email-subscriptions \
-H "Content-Type: application/json" \
-H "x-publishable-api-key: pk_storefront" \
-d '{
"email": "newsletter@example.com",
"status": "subscribed",
"source": "footer"
}'
Body fields:
- email โ required, unique per entry (case-insensitive).status
- โ optional, defaults to subscribed. Pass "unsubscribed" to honor opt-outs.metadata
- / source โ optional context stored alongside the entry.
Response:
`json`
{
"subscription": {
"id": "csub_123",
"email": "newsletter@example.com",
"status": "subscribed",
"source": "footer",
"created_at": "2024-11-26T10:00:00.000Z"
}
}
#### Contact Requests
List contact requests:
`bash`
curl -X GET "https://your-medusa.com/admin/contact-requests?status=pending&email=customer@example.com&limit=20&offset=0" \
-H "Authorization: Bearer
Query parameters:
- email โ optional, filter by email (partial match)status
- โ optional, filter by statussource
- โ optional, filter by sourcecreated_at.gte
- โ optional, filter by creation date (ISO 8601)created_at.lte
- โ optional, filter by creation date (ISO 8601)limit
- โ optional, number of results (default: 20, max: 100)offset
- โ optional, pagination offset (default: 0)order
- โ optional, sort field: created_at, updated_at, email (default: created_at)order_direction
- โ optional, sort direction: ASC or DESC (default: DESC)
Get contact request details:
`bash`
curl -X GET "https://your-medusa.com/admin/contact-requests/creq_123" \
-H "Authorization: Bearer
Response includes the request and next_allowed_statuses array for the admin UI:
`json`
{
"request": {
"id": "creq_123",
"email": "customer@example.com",
"payload": { ... },
"status": "pending",
"status_history": [ ... ],
"metadata": { ... },
"source": "storefront",
"created_at": "2024-11-29T16:33:17.000Z",
"updated_at": "2024-11-29T16:33:17.000Z"
},
"next_allowed_statuses": ["in_progress"]
}
Create contact request (admin):
`bash`
curl -X POST https://your-medusa.com/admin/contact-requests \
-H "Content-Type: application/json" \
-H "Authorization: Bearer
-d '{
"email": "customer@example.com",
"payload": {
"subject": "Support request",
"message": "Need assistance"
},
"source": "admin"
}'
Update contact request status:
`bash`
curl -X POST https://your-medusa.com/admin/contact-requests/creq_123/status \
-H "Content-Type: application/json" \
-H "Authorization: Bearer
-d '{
"status": "in_progress"
}'
Note: Only status transitions defined in status_transitions configuration are allowed. The API will return an error if an invalid transition is attempted.
#### Email Subscriptions
List subscriptions:
`bash`
curl -X GET "https://your-medusa.com/admin/contact-email-subscriptions?status=subscribed&limit=20" \
-H "Authorization: Bearer
Query parameters:
- status โ optional, filter by subscribed or unsubscribedq
- โ optional, search by emaillimit
- โ optional, number of results (default: 20)offset
- โ optional, pagination offset
After running medusa admin dev --plugins medusa-contact-us, the sidebar will include Contact email list:
- List view โ search by email, filter by status (subscribed/unsubscribed), inspect creation dates and unsubscribe timestamps.
- All UI components follow the Medusa UI kit spacing (8pt grid), color, and accessibility guidelines.
Skip hand-writing fetch calls by importing the provided helpers. Storefront requests must include a publishable API key (create one under Settings โ API Keys in the Medusa admin). The helpers automatically attach the header for you.
Submit a contact request from your storefront:
`ts
import { submitContactRequest } from "medusa-contact-us/helpers"
const result = await submitContactRequest(
{
email: "customer@example.com",
payload: {
subject: "Order inquiry",
message: "I need help with my order #12345",
priority: "high",
},
metadata: {
source_page: "contact",
user_agent: navigator.userAgent,
},
source: "storefront",
},
{
baseUrl: "https://store.myshop.com",
publishableApiKey: "pk_test_storefront",
}
)
console.log("Request created:", result.request.id)
`
Using a Medusa JS client:
`ts
import Medusa from "@medusajs/medusa-js"
import { submitContactRequest } from "medusa-contact-us/helpers"
const medusa = new Medusa({
baseUrl: "https://store.myshop.com",
publishableKey: "pk_live_client",
})
await submitContactRequest(
{
email: "customer@example.com",
payload: {
subject: "Support request",
message: "Need assistance",
},
},
{
client: medusa,
publishableApiKey: "pk_live_client",
}
)
`
For SSR or edge runtimes, preconfigure the helper:
`ts
import { createSubmitContactRequest } from "medusa-contact-us/helpers"
export const submitRequest = createSubmitContactRequest({
baseUrl: process.env.NEXT_PUBLIC_MEDUSA_URL,
publishableApiKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,
})
export async function action(formData: FormData) {
await submitRequest({
email: formData.get("email") as string,
payload: {
subject: formData.get("subject") as string,
message: formData.get("message") as string,
},
source: "contact_form",
})
}
`
`ts
import { upsertContactSubscription } from "medusa-contact-us/helpers"
await upsertContactSubscription(
{
email: "newsletter@example.com",
status: "subscribed",
metadata: { channel: "footer" },
},
{
baseUrl: "https://store.myshop.com",
publishableApiKey: "pk_test_storefront",
}
)
`
Using a Medusa JS client keeps credentials in one place while still letting you override headers (including publishable keys) per call:
`ts
import Medusa from "@medusajs/medusa-js"
import { upsertContactSubscription } from "medusa-contact-us/helpers"
const medusa = new Medusa({
baseUrl: "https://store.myshop.com",
publishableKey: "pk_live_client",
})
await upsertContactSubscription(
{
email: "newsletter@example.com",
status: "subscribed",
},
{
client: medusa,
publishableApiKey: "pk_live_client",
headers: {
Cookie: "connect.sid=...",
},
}
)
`
For SSR or edge runtimes, preconfigure the helper once:
`ts
import { createUpsertContactSubscription } from "medusa-contact-us/helpers"
export const upsertSubscription = createUpsertContactSubscription({
baseUrl: process.env.NEXT_PUBLIC_MEDUSA_URL,
publishableApiKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,
})
export async function action(formData: FormData) {
await upsertSubscription({
email: formData.get("email") as string,
status: formData.get("unsubscribe") ? "unsubscribed" : "subscribed",
source: "footer_form",
})
}
`
- publishableApiKey โ Automatically sets the x-publishable-api-key header when no Medusa client is used.baseUrl
- โ Storefront API origin (ignored when client is provided).client
- โ Pre-configured Medusa JS/SDK instance (reuses its base URL and publishable key).fetchImpl
- โ Custom fetch implementation (SSR, React Native, etc.).headers
- โ Additional headers merged into the request (e.g., session cookie, localization). Values you pass here override the defaults, including the publishable key header if you need a per-request key.
Customer-authenticated endpoints still require the appropriate session cookie or JWT. Provide those via the headers option if they're not already managed by the browser fetch call.
Contact requests follow a configurable status workflow:
1. Initial Status: New requests are created with the default_status (typically "pending")status_transitions
2. Status Transitions: Only transitions defined in are allowedsend_email: true
3. Status History: All status changes are tracked with timestamps and actor information
4. Email Notifications: Emails can be sent on specific transitions when
``
pending โ in_progress โ resolved โ closed
In this example:
- Admin can move requests from pending to in_progress (email sent)in_progress
- Admin can move requests from to resolved (email sent)resolved
- Admin can move requests from to closed (no email)
When fetching a contact request via the admin API, the response includes next_allowed_statuses:
`json`
{
"request": { ... },
"next_allowed_statuses": ["in_progress"]
}
Use this array to populate a status dropdown in your admin UI, ensuring only valid transitions are shown.
After installing the plugin, run migrations to create the required tables:
`bash`
npx medusa db:migrate
This will create:
- contact_email_subscription table (for email subscriptions)contact_request
- table (for contact requests)
Note: If you're developing the plugin locally, generate migrations after model changes:
`bash`
npx medusa plugin:db:generate
`bashmedusa plugin:build
yarn test # runs Vitest table-driven suites
yarn build # compiles the plugin via `
Always run yarn build after development to ensure the bundler succeeds before publishing or yalc linking.
- Admin UI blank: Rebuild the plugin (yarn build) and restart the Admin app with the plugin registered in medusa-config.ts.status_transitions
- Status transition errors: Ensure the transition is defined in configuration. Only transitions from the current status to an allowed next status are permitted.payload_fields
- Payload validation errors: Check that all required fields defined in are provided and match the expected types.email.enabled
- Email notifications not sending: Verify that is true in configuration and that the transition has send_email: true. Ensure the notification service is properly configured in your Medusa instance.ContactSubscriptionModule
- Service resolution errors: Make sure both and ContactRequestModule are registered in the modules array in medusa-config.ts`.