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, orkind+referenceNumber) plus an optionalfallbackblock 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 — thefallbackblock is ignored, and an unknown identifier returns404without sending mail. Without admin credentials (credential-free starter mode), the route renders the validatedfallbackblock instead; a request without one is rejected with400. SeeSECURITY.mdin 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_KEYis 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 returns200with{ skipped: true, reason: "email not configured" }. - Rate limited per client IP (in-memory), returning
429with aRetry-Afterheader when exceeded.
Rate limits
| Endpoint | Limit | Window | Rate-limit key |
|---|---|---|---|
POST /api/quotes/notify | 5 requests | 10 minutes | notify:<ip> |
POST /api/notifications/status | 20 requests | 10 minutes | status:<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
| Status | Body | When |
|---|---|---|
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:
- Admin alert —
toisQUOTE_NOTIFICATION_EMAIL, falling back tositeConfig.contact.email. Subject:New quote request <quoteNumber>. - Customer confirmation —
toiscustomer.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
| Field | Type | Rules |
|---|---|---|
quoteNumber | string | required, 1–100 chars — the identifier used for the Firestore lookup |
fallback | object | optional — 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
| Field | Type | Rules |
|---|---|---|
customer | object | required (see below) |
items | array | required, 1–200 items (see below) |
message | string | optional, max 5000 chars |
discount | object | optional (see below) |
customer
| Field | Type | Rules |
|---|---|---|
email | string | required, valid email, max 320 chars |
firstName | string | optional, max 200 chars |
lastName | string | optional, max 200 chars |
phone | string | optional, max 50 chars |
companyName | string | optional, max 200 chars |
items[]
| Field | Type | Rules |
|---|---|---|
productName | string | required, 1–500 chars |
quantity | number | required, positive integer, max 1,000,000 |
id | string | optional, max 200 chars |
brand | string | optional, max 200 chars |
variantName | string | optional, max 200 chars — selected variant's display name |
price | number | optional, finite, non-negative |
discount (the code applied in the cart, with the amount taken off)
| Field | Type | Rules |
|---|---|---|
code | string | required, 1–50 chars |
amount | number | required, finite, non-negative |
Example request
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 }
}
}'{ "sent": 2, "failed": 0 }{ "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
| Field | Type | Rules |
|---|---|---|
kind | enum | required, "quote" or "order" — selects the Firestore collection for the lookup |
referenceNumber | string | required, 1–100 chars — matched against the stored quoteNumber/orderNumber |
fallback | object | optional — 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
| Field | Type | Rules |
|---|---|---|
status | string | required, 1–50 chars |
customer | object | required (see below) |
itemCount | number | optional, positive integer, max 10,000 |
total | number | optional, finite, non-negative |
customer
| Field | Type | Rules |
|---|---|---|
email | string | required, valid email, max 320 chars |
firstName | string | optional, max 200 chars |
lastName | string | optional, max 200 chars |
Example request
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
}
}'{ "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:
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
| Variable | Required | Default | Purpose |
|---|---|---|---|
RESEND_API_KEY | For email | — | Enables sending. Unset ⇒ endpoints return { skipped: true }. |
RESEND_FROM_EMAIL | No | onboarding@resend.dev | The from address for all emails. |
QUOTE_NOTIFICATION_EMAIL | No | siteConfig.contact.email | Admin recipient for quote alerts. |
See environment variables for the full list and the emails feature guide for setup steps and template details.