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.

Receipt patterns · Stripe billing blog · Pricing