Acme Store Docs
API Reference

Email Notification Endpoints

POST /api/quotes/notify and POST /api/notifications/status for Resend emails.

The boilerplate ships two public API routes that send transactional email through Resend. Both are optional — email is env-gated by RESEND_API_KEY. When that variable is unset, the endpoints do nothing and respond with { skipped: true }, so a store running in the default quote-based mode keeps working without any email provider configured.

  • POST /api/quotes/notify — fires when a customer submits a quote request. Sends an admin alert plus a customer confirmation.
  • POST /api/notifications/status — fires when an admin changes a quote or order status. Sends a status-change email to the customer.

For the bigger picture — enabling Resend, the email templates, sender/recipient configuration, and how these endpoints are called — see the emails feature guide.

Shared behavior

Both routes live in src/app/api/quotes/notify/route.ts and src/app/api/notifications/status/route.ts and share the same contract, defined by the zod schemas in src/lib/email.ts:

  • Public, no auth. Anyone can POST. Because of that, request bodies are strictly zod-validated and size-capped (string/array length limits on every field).
  • Identifier + fallback trust model. Each request carries an identifier (quoteNumber, or kind + referenceNumber) plus an optional fallback block with the full email content. When the server has Firebase Admin credentials (FIREBASE_SERVICE_ACCOUNT*), it looks the record up in Firestore by the identifier and rebuilds the recipient and content from the persisted document — the fallback block is ignored, and an unknown identifier returns 404 without sending mail. Without admin credentials (credential-free starter mode), the route renders the validated fallback block instead; a request without one is rejected with 400. See SECURITY.md in the repo root for the threat model.
  • Never echo input. Error responses are generic ("Invalid request body", "Invalid JSON body") and never reflect the submitted data back.
  • Email is optional. The RESEND_API_KEY is read inside the handler and the Resend client is instantiated lazily, so builds and deployments without the key succeed. When the key is missing the route returns 200 with { skipped: true, reason: "email not configured" }.
  • Rate limited per client IP (in-memory), returning 429 with a Retry-After header when exceeded.

Rate limits

EndpointLimitWindowRate-limit key
POST /api/quotes/notify5 requests10 minutesnotify:<ip>
POST /api/notifications/status20 requests10 minutesstatus:<ip>

The client IP is derived by clientIpFrom(request) from src/lib/rate-limit.ts. When the limit is hit:

HTTP/1.1 429 Too Many Requests
Retry-After: 42
Content-Type: application/json

{ "error": "Too many requests" }

Response codes

StatusBodyWhen
200{ "skipped": true, "reason": "email not configured" }RESEND_API_KEY is unset
200{ "sent": <n>, "failed": <n> }Request accepted and dispatch attempted
400{ "error": "Invalid JSON body" }Body is not valid JSON
400{ "error": "Invalid request body" }Body fails zod validation, or no fallback block on a credential-free server
404{ "error": "Quote not found" } / { "error": "Record not found" }Admin credentials configured and the identifier matches no persisted record
200{ "skipped": true, "reason": "… not emailable" }Record found, but it cannot produce a legitimate email (e.g. no valid customer email)
429{ "error": "Too many requests" }Rate limit exceeded (includes Retry-After)
500{ "error": "Internal server error" }Unexpected error during send

A 200 with { "sent": 0, "failed": 1 } means the request was valid and Resend was called, but the provider rejected or failed the send. Resend resolves with { data, error } rather than throwing, so a provider-level failure is counted in failed and logged server-side — it does not become a 4xx/5xx.

POST /api/quotes/notify

Sends two emails via resend.emails.send, dispatched together with Promise.allSettled:

  1. Admin alertto is QUOTE_NOTIFICATION_EMAIL, falling back to siteConfig.contact.email. Subject: New quote request <quoteNumber>.
  2. Customer confirmationto is customer.email. Subject: We received your quote request <quoteNumber>.

Both use QuoteEmailTemplate (rendered with audience: "admin" and audience: "customer" respectively). The from address is RESEND_FROM_EMAIL, defaulting to onboarding@resend.dev.

The response reports how many of the two sends succeeded:

{ "sent": 2, "failed": 0 }

Request body — quoteNotifyRequestSchema

FieldTypeRules
quoteNumberstringrequired, 1–100 chars — the identifier used for the Firestore lookup
fallbackobjectoptional — full email content (quoteEmailContentSchema, below), rendered only by servers without Firebase Admin credentials

With admin credentials configured, { "quoteNumber": "Q-…" } alone is a complete request — everything else is read from the persisted quote. The storefront always includes fallback so credential-free deployments keep working.

fallback

FieldTypeRules
customerobjectrequired (see below)
itemsarrayrequired, 1–200 items (see below)
messagestringoptional, max 5000 chars
discountobjectoptional (see below)

customer

FieldTypeRules
emailstringrequired, valid email, max 320 chars
firstNamestringoptional, max 200 chars
lastNamestringoptional, max 200 chars
phonestringoptional, max 50 chars
companyNamestringoptional, max 200 chars

items[]

FieldTypeRules
productNamestringrequired, 1–500 chars
quantitynumberrequired, positive integer, max 1,000,000
idstringoptional, max 200 chars
brandstringoptional, max 200 chars
variantNamestringoptional, max 200 chars — selected variant's display name
pricenumberoptional, finite, non-negative

discount (the code applied in the cart, with the amount taken off)

FieldTypeRules
codestringrequired, 1–50 chars
amountnumberrequired, finite, non-negative

Example request

Terminal
curl -X POST https://your-store.example/api/quotes/notify \
  -H "Content-Type: application/json" \
  -d '{
    "quoteNumber": "Q-20260703-0042",
    "fallback": {
      "customer": {
        "firstName": "Dana",
        "lastName": "Reyes",
        "email": "dana@acme.test",
        "companyName": "Acme Industrial"
      },
      "items": [
        {
          "id": "prod_123",
          "productName": "Stainless Bolt M8",
          "brand": "FastenPro",
          "variantName": "50mm",
          "quantity": 500,
          "price": 0.12
        }
      ],
      "message": "Please quote bulk pricing for a recurring monthly order.",
      "discount": { "code": "WELCOME10", "amount": 6.00 }
    }
  }'
Response — email configured
{ "sent": 2, "failed": 0 }
Response — RESEND_API_KEY unset
{ "skipped": true, "reason": "email not configured" }

POST /api/notifications/status

Sends one email to the customer notifying them that their quote or order status changed. Uses StatusUpdateEmailTemplate. Subject: Update on your <kind> <referenceNumber>: <status>. The from address is RESEND_FROM_EMAIL (default onboarding@resend.dev).

{ "sent": 1, "failed": 0 }

Request body — statusUpdateRequestSchema

FieldTypeRules
kindenumrequired, "quote" or "order" — selects the Firestore collection for the lookup
referenceNumberstringrequired, 1–100 chars — matched against the stored quoteNumber/orderNumber
fallbackobjectoptional — full email content (statusEmailContentSchema, below), rendered only by servers without Firebase Admin credentials

With admin credentials configured the recipient, current status, item count, and total all come from the persisted record; fallback is ignored.

fallback

FieldTypeRules
statusstringrequired, 1–50 chars
customerobjectrequired (see below)
itemCountnumberoptional, positive integer, max 10,000
totalnumberoptional, finite, non-negative

customer

FieldTypeRules
emailstringrequired, valid email, max 320 chars
firstNamestringoptional, max 200 chars
lastNamestringoptional, max 200 chars

Example request

Terminal
curl -X POST https://your-store.example/api/notifications/status \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "quote",
    "referenceNumber": "Q-20260703-0042",
    "fallback": {
      "status": "approved",
      "customer": {
        "email": "dana@acme.test",
        "firstName": "Dana",
        "lastName": "Reyes"
      },
      "itemCount": 3,
      "total": 148.50
    }
  }'
Response — email configured
{ "sent": 1, "failed": 0 }

Client helper: sendStatusUpdateNotification

src/lib/email.ts exports a fire-and-forget helper used by admin status-update code so you rarely POST this endpoint by hand:

src/lib/email.ts
import { sendStatusUpdateNotification } from "@/lib/email";

sendStatusUpdateNotification({
  kind: "order",
  referenceNumber: order.number,
  status: "shipped",
  customer: order.customer, // loose shape; email is validated
  itemCount: order.items.length,
  total: order.total,
});

It builds a statusUpdateRequestSchema-shaped payload — the kind + referenceNumber identifier plus a fallback content block — and POSTs it in the background. It never throws and never blocks the caller, and silently does nothing when the customer record has no valid-looking email — email is a best-effort side effect of a status update. It trims and truncates names, clamps referenceNumber/status to their max lengths, and only includes itemCount/total when they pass the same bounds the schema enforces. Servers with Firebase Admin credentials ignore the fallback block and rebuild the email from the persisted record.

Environment variables

VariableRequiredDefaultPurpose
RESEND_API_KEYFor emailEnables sending. Unset ⇒ endpoints return { skipped: true }.
RESEND_FROM_EMAILNoonboarding@resend.devThe from address for all emails.
QUOTE_NOTIFICATION_EMAILNositeConfig.contact.emailAdmin recipient for quote alerts.

See environment variables for the full list and the emails feature guide for setup steps and template details.

On this page