Marketplace Transactional Email: Orders, Shipping, and Disputes
Buyer and seller emails for a two-sided marketplace — order placed, new order for seller, shipped, cancelled — powered by Notify with idempotent outbox keys.
Marketplaces live and die on timely transactional email. Buyers need proof they ordered. Sellers need a ping when money (or intent) shows up. Everyone needs a clear message when something ships or cancels.
This guide sketches the email layer for a simple two-sided marketplace using Notify. Payment provider details are left abstract — swap in Stripe Connect, Lemon Squeezy, or your own ledger.
Email types (minimum set)
| Event | To | Subject seed | |-------|-----|--------------| | Order placed | Buyer | “Order confirmed” | | Order placed | Seller | “New order” | | Shipped | Buyer | “Your order shipped” | | Cancelled | Buyer + seller | “Order cancelled” | | Dispute opened (optional) | Both | “Dispute opened” |
No promotional cross-sells on these messages. Keep marketing on another domain if you add it later.
Models
create table orders (
id uuid primary key default gen_random_uuid(),
buyer_id uuid not null,
seller_id uuid not null,
status text not null check (status in ('placed', 'shipped', 'cancelled')),
total_cents int not null,
currency text not null default 'usd',
tracking_url text,
created_at timestamptz default now()
);
create table email_outbox (
dedupe_key text primary key,
created_at timestamptz default now()
);
Notify helper + outbox
export async function sendEmail(opts: {
to: string;
subject: string;
message: string;
}) {
const res = await fetch('https://notify.cx/api/email/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.NOTIFY_API_KEY!
},
body: JSON.stringify({
from: 'orders@your-verified-domain.com',
...opts
})
});
if (!res.ok) throw new Error(await res.text());
}
async function sendOnce(key: string, fn: () => Promise<void>) {
if (!(await tryInsertOutbox(key))) return;
await fn();
}
Place order → buyer + seller
export async function placeOrder(input: {
buyer: { id: string; email: string; name: string };
seller: { id: string; email: string; shopName: string };
totalCents: number;
currency: string;
itemSummaryHtml: string;
}) {
const order = await db.orders.insert({
buyerId: input.buyer.id,
sellerId: input.seller.id,
status: 'placed',
totalCents: input.totalCents,
currency: input.currency
});
const amount = (input.totalCents / 100).toFixed(2);
const currency = input.currency.toUpperCase();
const orderUrl = `${process.env.NEXT_PUBLIC_APP_URL}/orders/${order.id}`;
await sendOnce(`order-placed-buyer:${order.id}`, () =>
sendEmail({
to: input.buyer.email,
subject: `Order confirmed — ${amount} ${currency}`,
message: `
<h1>Order confirmed</h1>
${input.itemSummaryHtml}
<p>Total: <strong>${amount} ${currency}</strong></p>
<p><a href="${orderUrl}">View order</a></p>
`
})
);
await sendOnce(`order-placed-seller:${order.id}`, () =>
sendEmail({
to: input.seller.email,
subject: `New order on ${input.seller.shopName}`,
message: `
<h1>You have a new order</h1>
${input.itemSummaryHtml}
<p>Total: <strong>${amount} ${currency}</strong></p>
<p><a href="${orderUrl}">Fulfill order</a></p>
`
})
);
return order;
}
Ship order → buyer
export async function markShipped(orderId: string, trackingUrl: string) {
const order = await db.orders.update(orderId, {
status: 'shipped',
trackingUrl
});
const buyer = await getUser(order.buyerId);
await sendOnce(`order-shipped-buyer:${orderId}`, () =>
sendEmail({
to: buyer.email,
subject: 'Your order shipped',
message: `
<h1>On the way</h1>
<p><a href="${trackingUrl}">Track shipment</a></p>
<p><a href="${process.env.NEXT_PUBLIC_APP_URL}/orders/${orderId}">View order</a></p>
`
})
);
}
Cancel → both sides
export async function cancelOrder(orderId: string, reason: string) {
const order = await db.orders.update(orderId, { status: 'cancelled' });
const [buyer, seller] = await Promise.all([
getUser(order.buyerId),
getUser(order.sellerId)
]);
const body = `
<h1>Order cancelled</h1>
<p>${escapeHtml(reason)}</p>
<p><a href="${process.env.NEXT_PUBLIC_APP_URL}/orders/${orderId}">View order</a></p>
`;
await sendOnce(`order-cancelled-buyer:${orderId}`, () =>
sendEmail({
to: buyer.email,
subject: 'Order cancelled',
message: body
})
);
await sendOnce(`order-cancelled-seller:${orderId}`, () =>
sendEmail({
to: seller.email,
subject: 'Order cancelled',
message: body
})
);
}
Disputes (optional)
When a dispute opens, email both parties once with a link to the dispute thread. Same outbox pattern: dispute-opened:{disputeId}:{role}.
Ops notes
- Prefer sending after the DB commit that changes status
- If payment webhooks can retry, outbox keys are mandatory
- Escape all user-provided strings in HTML
- Use Notify logs when a seller says they never got “new order”
- Add webhooks for bounces on Pro/Scale
Bottom line
Marketplace email is a small set of status transitions and two audiences. Notify keeps delivery boring so you can keep the product interesting.