202 before it validates, fetches, or sends anything, so a later send failure doesn't trigger a retry. The email integration guide recommends acknowledging first for order confirmations, so Zen Electron keeps that policy. This page makes the rest of the code match the contract: one email type, a tested mapping, honest logs, and a recorded policy.A customer reports that no confirmation email arrived. The provider timed out, yet the Connector's log says that the email has been sent. By the end of this page, you can point to the line in the controller that explains it, and you have a test that proves the behavior.
zen-order-confirmation repository that you tagged v0.1.0 in Start from the email template. All paths on this page are relative to its mail-sender/ folder.The contract you're implementing
- Latency budget: acknowledge within the Connect event acknowledgment deadline.
- Duplicate policy: acknowledge before sending, so a failed send isn't retried.
- Failure behavior: a send that fails after the acknowledgment is lost. The team watches the provider's activity log for missed sends.
Read what the template does today
src/controllers/mail-sending.controller.js. The first statement of messageHandler is the acknowledgment:export const messageHandler = async (request, response) => {
// Send ACCEPTED acknowledgement to Subscription
response.status(HTTP_STATUS_SUCCESS_ACCEPTED).send();
try {
// Check request body
doValidation(request);
// ...select a handler for the Message type...
await handler.process(messageBody);
} catch (err) {
// ...log the error...
}
};
catch block can only log: no later error can change the status code.doValidation in src/validators/message.validators.js then does the following, in order:- Checks for
request.body.message.dataand decodes it from base64 JSON. This is the Google Cloud Pub/Sub push envelope. - Skips
ResourceCreatednotifications, which Connect sends when the Subscription is created. - Rejects Message types that aren't in its lists.
- Checks that an Order Message references an Order and skips Orders that link to no Customer contact.
- Skips changes that the Connector's own API Client made, so the Connector can't trigger itself.
CustomError with a status code, but because the response already left, that code only decides whether the error is logged as info or error.OrderCreated and OrderImported both go to OrderConfirmationHandler, which re-fetches the Order by resource.id and never reads the Order embedded in the Message. That's why it isn't affected when a Message arrives with payloadNotIncluded. Re-fetching returns the Order's current state, which is fine for a confirmation but can change the meaning of a state-change email. The guide covers that case under Preserve state meaning.postDeploy script can create an SNS destination for AWS Regions, but nothing in the handler decodes an SNS-shaped body. If you deploy to an AWS Region, verify the envelope against a real delivery before you rely on it.Narrow the Message types
The template subscribes to Customer, Customer token, and Order Messages. Zen Electron sends only the order confirmation from this Connector. Every extra Message type costs a delivery, a decode, and a log line, and it widens what you must test.
src/constants/constants.js:+// The Message types this Connector sends email for. The Subscription filter
+// (connector/actions.js) and the router (validators/message.validators.js)
+// both read this list, so they can't drift apart.
export const ORDER_SUBSCRIPTION_MESSAGE_TYPES = [
ORDER_CREATION_SUBSCRIPTION_MESSAGE_TYPE,
ORDER_IMPORT_SUBSCRIPTION_MESSAGE_TYPE,
- ORDER_STATE_CHANGE_SUBSCRIPTION_MESSAGE_TYPE,
- ORDER_SHIPMENT_STATE_CHANGE_SUBSCRIPTION_MESSAGE_TYPE,
- ORDER_RETURN_INFO_CREATION_SUBSCRIPTION_MESSAGE_TYPE,
- ORDER_RETURN_INFO_UPDATE_SUBSCRIPTION_MESSAGE_TYPE,
];
src/connector/actions.js subscribe to Order Messages only. Remove the three Customer build...MessageType functions and their imports, which are now unused:-import {
- CUSTOMER_SUBSCRIPTION_MESSAGE_TYPES,
- CUSTOMER_EMAIL_TOKEN_SUBSCRIPTION_MESSAGE_TYPES,
- CUSTOMER_PASSWORD_TOKEN_SUBSCRIPTION_MESSAGE_TYPES,
- ORDER_SUBSCRIPTION_MESSAGE_TYPES,
-} from '../constants/constants.js';
+import { ORDER_SUBSCRIPTION_MESSAGE_TYPES } from '../constants/constants.js';
...
- messages: [
- buildCustomerChangeMessageType(),
- buildCustomerEmailTokenChangeMessageType(),
- buildCustomerPasswordTokenChangeMessageType(),
- buildOrderChangeMessageType(),
- ],
+ messages: [buildOrderChangeMessageType()],
src/validators/message.validators.js. isOrderConfirmationMessage had its own inline list, and isValidMessageType accepted every type the template knew: export function isOrderConfirmationMessage(messageBody) {
- return [
- ORDER_CREATION_SUBSCRIPTION_MESSAGE_TYPE,
- ORDER_IMPORT_SUBSCRIPTION_MESSAGE_TYPE,
- ].includes(messageBody.type);
+ return ORDER_SUBSCRIPTION_MESSAGE_TYPES.includes(messageBody.type);
}
...
function isValidMessageType(messageBody) {
- return (
- isCustomerSubscriptionMessage(messageBody) ||
- isCustomerEmailTokenSubscriptionMessage(messageBody) ||
- isCustomerPasswordTokenSubscriptionMessage(messageBody) ||
- isOrderSubscriptionMessage(messageBody)
- );
+ return isOrderConfirmationMessage(messageBody);
}
ORDER_CREATION_SUBSCRIPTION_MESSAGE_TYPE and ORDER_IMPORT_SUBSCRIPTION_MESSAGE_TYPE from the file's import list, because nothing in it uses them now.Finally, replace the controller's six-way routing with the one route you need. This is the complete file:
import { logger } from '../utils/logger.utils.js';
import { HTTP_STATUS_SUCCESS_ACCEPTED } from '../constants/http-status.constants.js';
import {
doValidation,
isOrderConfirmationMessage,
} from '../validators/message.validators.js';
import { decodeToJson } from '../utils/decoder.utils.js';
import HandlerFactory from '../factory/handler.factory.js';
import { HANDLER_TYPE_ORDER_CONFIRMATION } from '../constants/handler-type.constants.js';
export const messageHandler = async (request, response) => {
// Acknowledge first: a failure after this line is logged, not retried.
response.status(HTTP_STATUS_SUCCESS_ACCEPTED).send();
let messageBody;
try {
doValidation(request);
messageBody = decodeToJson(request.body.message.data);
if (!isOrderConfirmationMessage(messageBody)) {
return;
}
const handler = new HandlerFactory().getHandler(
HANDLER_TYPE_ORDER_CONFIRMATION
);
await handler.process(messageBody);
} catch (err) {
const logContext = {
statusCode: err.statusCode,
messageType: messageBody?.type,
resourceId: messageBody?.resource?.id,
sequenceNumber: messageBody?.sequenceNumber,
};
if (err.statusCode === HTTP_STATUS_SUCCESS_ACCEPTED) {
logger.info(err.message, logContext);
} else {
logger.error(err.message, logContext);
}
}
};
Points to note:
- The acknowledgment stays first. A failure after it is logged, not retried.
- The guard before
getHandleris a second line of defense.doValidationalready rejects a type that isn't inORDER_SUBSCRIPTION_MESSAGE_TYPESand logs an error, as the routing test checks. The controller now always asks for the confirmation handler, so the guard stops any other type from reaching it if someone widens the validator later. - The
catchblock logserr.messagewith a correlation context, not the whole error object. The context carries the Message type,resource.id, andsequenceNumberonce the Message has been decoded, so a failed send can be traced to its Order, and an error can't carry request data into the logs. A Message that fails validation is logged before it's decoded, so its line has only the error message. - Scopes don't change. The handler still retrieves the Order (
view_orders) and, for Orders with acustomerId, the Customer (view_customers), andpostDeploystill manages the Subscription (manage_subscriptions).
An unselected type that still reaches the endpoint, for example from a Subscription created by an older version, is acknowledged and nothing is sent. That's correct for an event application: returning a non-acknowledging status for a Message you'll never handle only causes redelivery until the Message expires.
event application and a service application behind an API Extension treat the same 202 status differently. The email integration guide states the difference, and the read-along at the end of this page shows it in a real template.Make the mapping testable and stub the provider
OrderConfirmationHandler.process(), between two API calls and a send. Extract it.src/mappers/order-confirmation.mapper.js. It moves the template's mapping without changing its output, apart from two guards:import CustomError from '../errors/custom.error.js';
import { HTTP_STATUS_BAD_REQUEST } from '../constants/http-status.constants.js';
import { convertMoneyToText } from '../utils/money.utils.js';
import { addImageSizeSuffix } from '../utils/image.utils.js';
import { IMAGE_SIZE_SMALL } from '../constants/image.constants.js';
const DEFAULT_LOCALE = 'en-US';
const DEFAULT_CUSTOMER_NAME = 'Customer';
function toEmailLineItem(lineItem) {
const image = lineItem.variant.images?.[0];
return {
productName: lineItem.name[DEFAULT_LOCALE],
productQuantity: lineItem.quantity,
productSku: lineItem.variant.sku,
productImage: image ? addImageSizeSuffix(image.url, IMAGE_SIZE_SMALL) : '',
productSubTotal: convertMoneyToText(lineItem.totalPrice),
};
}
export function buildOrderConfirmationEmail(order, customer) {
const recipient = order.customerEmail || customer?.email;
if (!recipient) {
throw new CustomError(
HTTP_STATUS_BAD_REQUEST,
`Order ${order.id} has no recipient email address.`
);
}
return {
recipient,
templateData: {
orderNumber: order.orderNumber || '',
customerEmail: recipient,
customerFirstName: customer?.firstName || DEFAULT_CUSTOMER_NAME,
customerMiddleName: customer?.middleName || '',
customerLastName: customer?.lastName || '',
orderCreationTime: order.createdAt,
orderTotalPrice: convertMoneyToText(order.totalPrice),
orderTaxedPrice: order.taxedPrice
? convertMoneyToText(order.taxedPrice)
: '',
orderLineItems: order.lineItems.map(toEmailLineItem),
},
};
}
The two guards fix crashes in the original:
images?.[0]:imagesis optional on a ProductVariant. The template readimages[0]directly, so an Order whose variant has noimagesfield threw aTypeErrorand no email was sent.- The recipient check: the template fell back to
customer.emailwithout checking that a Customer exists, which threw aTypeErrorinstead of a clear error.
src/handlers/order-confirmation.handler.js so that it fetches, maps, sends, and logs:import GenericHandler from './generic.handler.js';
import { logger } from '../utils/logger.utils.js';
import { getOrderById, getCustomerById } from '../client/query.client.js';
import { buildOrderConfirmationEmail } from '../mappers/order-confirmation.mapper.js';
class OrderConfirmationHandler extends GenericHandler {
async process(messageBody) {
const logContext = {
messageType: messageBody.type,
resourceId: messageBody.resource.id,
sequenceNumber: messageBody.sequenceNumber,
};
// Re-fetch the Order instead of reading messageBody.order, so a
// Message delivered with payloadNotIncluded is handled the same way.
const order = await getOrderById(messageBody.resource.id);
const customer = order.customerId
? await getCustomerById(order.customerId)
: undefined;
const { recipient, templateData } = buildOrderConfirmationEmail(
order,
customer
);
await this.sendMail(
process.env.SENDER_EMAIL_ADDRESS,
recipient,
process.env.ORDER_CONFIRMATION_TEMPLATE_ID,
templateData
);
logger.info('Order confirmation handed to email provider', logContext);
}
}
export default OrderConfirmationHandler;
OrderImported, the whole Order), the recipient's address, and the customer's name, then logged that the email had been sent, although sendMail sends nothing. The new handler logs only the Message type, resource.id, and sequenceNumber, which are enough to correlate a log line with a Message and a provider record. The guide's Keep logs safe section lists what to log and what to keep out.getOrderById throws if the Order doesn't exist, so the original if (order) … else branches are gone. The error reaches the controller's catch block and is logged.src/handlers/generic.handler.js honest about what it does: async sendMail(
senderEmailAddress,
recipientEmailAddress,
templateId,
templateData
) {
// Stub: replace with your email service provider's SDK call. It records
// what would be sent, without the recipient address or Order content.
logger.info('Email provider stub: email not sent', {
sender: senderEmailAddress,
templateId,
lineItemCount: templateData.orderLineItems?.length ?? 0,
});
}
Test locally
sinon for stubs. Add tests/unit/order-confirmation.spec.js with three groups of tests: the mapping, the routing, and the failure mode.import { expect, describe, it, beforeEach, afterEach } from '@jest/globals';
import sinon from 'sinon';
import { messageHandler } from '../../src/controllers/mail-sending.controller.js';
import { buildOrderConfirmationEmail } from '../../src/mappers/order-confirmation.mapper.js';
import OrderConfirmationHandler from '../../src/handlers/order-confirmation.handler.js';
import GenericHandler from '../../src/handlers/generic.handler.js';
import * as QueryClient from '../../src/client/query.client.js';
import * as ConfigUtil from '../../src/utils/config.utils.js';
import { logger } from '../../src/utils/logger.utils.js';
const order = {
id: 'order-1',
orderNumber: 'ZE-1001',
customerEmail: 'learner@example.com',
createdAt: '2026-10-13T09:00:00.000Z',
totalPrice: {
type: 'centPrecision',
currencyCode: 'AUD',
centAmount: 4900,
fractionDigits: 2,
},
lineItems: [
{
name: { 'en-US': 'Zen Electron 4K monitor' },
quantity: 1,
variant: { sku: 'ZE-MON-4K' },
totalPrice: {
type: 'centPrecision',
currencyCode: 'AUD',
centAmount: 4900,
fractionDigits: 2,
},
},
],
};
function pubSubRequest(message) {
const data = Buffer.from(JSON.stringify(message)).toString('base64');
return { body: { message: { data } } };
}
function orderMessage(type) {
return {
notificationType: 'Message',
id: 'msg-1',
type,
sequenceNumber: 1,
resource: { typeId: 'order', id: order.id },
resourceVersion: 1,
};
}
function fakeResponse() {
const send = sinon.spy();
const status = sinon.spy(() => ({ send }));
return { status, send };
}
describe('buildOrderConfirmationEmail', () => {
it('maps an Order to the recipient and template data', () => {
const { recipient, templateData } = buildOrderConfirmationEmail(order);
expect(recipient).toBe('learner@example.com');
expect(templateData.orderNumber).toBe('ZE-1001');
expect(templateData.customerFirstName).toBe('Customer');
expect(templateData.orderTotalPrice).toBe('AUD 49');
expect(templateData.orderLineItems).toEqual([
{
productName: 'Zen Electron 4K monitor',
productQuantity: 1,
productSku: 'ZE-MON-4K',
productImage: '',
productSubTotal: 'AUD 49',
},
]);
});
it('falls back to the Customer email address', () => {
const { recipient } = buildOrderConfirmationEmail(
{ ...order, customerEmail: undefined },
{ email: 'customer@example.com', firstName: 'Jane' }
);
expect(recipient).toBe('customer@example.com');
});
it('rejects an Order with no recipient', () => {
expect(() =>
buildOrderConfirmationEmail({ ...order, customerEmail: undefined })
).toThrow('has no recipient');
});
});
describe('messageHandler', () => {
let processStub;
let errorLog;
beforeEach(() => {
sinon.stub(ConfigUtil, 'default').returns({ clientId: 'connector-client' });
sinon.stub(logger, 'info');
errorLog = sinon.stub(logger, 'error');
processStub = sinon.stub(OrderConfirmationHandler.prototype, 'process');
});
afterEach(() => {
sinon.restore();
});
it.each(['OrderCreated', 'OrderImported'])(
'routes %s to the confirmation handler',
async (type) => {
const response = fakeResponse();
await messageHandler(pubSubRequest(orderMessage(type)), response);
expect(response.status.firstCall.firstArg).toBe(202);
expect(processStub.calledOnce).toBe(true);
}
);
it('acknowledges an unselected type and sends nothing', async () => {
const response = fakeResponse();
await messageHandler(
pubSubRequest(orderMessage('ReturnInfoAdded')),
response
);
expect(response.status.firstCall.firstArg).toBe(202);
expect(processStub.notCalled).toBe(true);
expect(errorLog.firstCall.firstArg).toBe(
'Message type ReturnInfoAdded is incorrect.'
);
});
});
describe('failure after the acknowledgment', () => {
afterEach(() => {
sinon.restore();
});
it('logs a provider failure after the local 202', async () => {
sinon.stub(ConfigUtil, 'default').returns({ clientId: 'connector-client' });
sinon.stub(QueryClient, 'getOrderById').resolves(order);
sinon.stub(logger, 'info');
const errorLog = sinon.stub(logger, 'error');
const sendMail = sinon
.stub(GenericHandler.prototype, 'sendMail')
.rejects(new Error('Provider timeout'));
const response = fakeResponse();
await messageHandler(pubSubRequest(orderMessage('OrderCreated')), response);
// The local 202 precedes the send, so the provider failure can't change it.
expect(response.status.calledBefore(sendMail)).toBe(true);
expect(sendMail.calledOnce).toBe(true);
expect(errorLog.firstCall.firstArg).toBe('Provider timeout');
expect(errorLog.firstCall.args[1]).toMatchObject({
resourceId: 'order-1',
sequenceNumber: 1,
});
});
});
readConfiguration because doValidation reads the Connector's client ID to filter self-made changes. The test Order has no images field on its variant on purpose: it's the case that crashed the original mapping.mail-sender/:npm run test:unit
npx eslint src tests
ReturnInfoAdded fails because the type is still accepted, and the failure test fails with a TypeError from the unguarded images[0]. The integration tests (npm run test:integration) start the Express server, which reads its configuration on startup. Run them only with valid CTP_* variables set, as in Post a Message to the running application.Post a Message to the running application
8080 and serves POST /mailSender. npm start validates the CTP_* variables on startup, so set them to your own API Client's values. The client ID must be 24 characters and the secret 32.cd mail-sender
CTP_CLIENT_ID=<client-id> \
CTP_CLIENT_SECRET=<client-secret> \
CTP_PROJECT_KEY=<project-key> \
CTP_SCOPE="view_orders:<project-key> view_customers:<project-key>" \
CTP_REGION=europe-west1.gcp \
npm start
PAYLOAD=$(printf '%s' '{"notificationType":"Message","id":"msg-1","sequenceNumber":1,"resource":{"typeId":"order","id":"<order-id>"},"resourceVersion":1,"type":"OrderCreated"}' | base64)
curl -i -X POST http://localhost:8080/mailSender \
-H 'Content-Type: application/json' \
-d "{\"message\":{\"data\":\"$PAYLOAD\"}}"
202 Accepted whatever happens next. Read the outcome in Terminal 1:- With the ID of an existing Order that has a
customerEmail, the log showsEmail provider stub: email not sent, thenOrder confirmation handed to email providerwith the Message type, Order ID, andsequenceNumber. - With
"type":"ReturnInfoAdded", the log showsMessage type ReturnInfoAdded is incorrect.and nothing is sent. - With credentials that can't authenticate, the log shows the authentication error. The client still received
202.
The last case is the at-most-once policy at small scale: the caller got a success status, and the work failed silently afterward.
See the failure mode
202, and the error appears only in the logs.This possible loss is the price of the policy. For an order confirmation, Zen Electron accepts it: a rare missed confirmation is tolerable, and a duplicate is worse. Work that continues after the response is sent isn't guaranteed to complete, so don't assume misses come only from provider failures. Measure the actual miss rate by comparing the Connector's logs with the provider's activity log before you rely on this policy in production. The stub log doesn't establish that a real provider accepted an email.
Record the policy and know the alternative
README.md, where the next person to change the handler will see it:## Delivery policy
| Email type | Policy | Why |
| --- | --- | --- |
| Order confirmation (`OrderCreated`, `OrderImported`) | At-most-once | A rare missed email is tolerable; a duplicate is worse. |
Lifecycle scripts
postDeploy runs createSubscription in src/connector/actions.js, which deletes the Subscription with the key ct-connect-email-delivery-subscription and then creates it again. preUndeploy runs deleteEmailDeliverySubscription, which deletes it.createSubscription query by key, create the Subscription if none exists, and otherwise update its destination and messages with the current version.For an API Extension, the same gap is worse: requests that should pass through the Extension run without it, or fail. Use get-then-update there.
Two consequences of the template's lifecycle scripts come up later in the module:
- The Subscription key is fixed. Two Deployments of this Connector in one Project share one Subscription, and undeploying either deletes it. Operate a Deployment diagnoses this.
- If
postDeployfails, the Deployment can still report success. Install and test your Connector shows how to prove that the Subscription exists.
Read-along: the API Extension contract in code
This section isn't hands-on. It shows why the status code that's correct for your event application is wrong for a service application behind an API Extension.
service application, tax-calculator, that its README describes as the target of an API Extension on frozen Carts with tax mode ExternalAmount. Its controller's success path, as of commit c5e973f (February 2026), returns 202: try {
// Implement tax calculation. For reference, Check the branch in this git repo: stripe-implementation
calculation = {};
} catch (err) {
logger.error(err);
if (err.statusCode) return response.status(err.statusCode).send(err);
return response.status(HTTP_STATUS_SERVER_ERROR).send(err);
}
return response.status(HTTP_STATUS_SUCCESS_ACCEPTED).send(calculation);
200, 201, or 400 as a failure to respond, so 202 makes the Cart update that triggered the Extension fail. When you implement the calculation, return 200 with the update actions that set the tax amounts.Compare the two applications:
| Application | Connection point | 202 means | Approve or acknowledge with |
|---|---|---|---|
mail-sender | Subscription, through Connect | Acknowledged if received within the deadline; no retry for later failures | An acknowledgment status within the deadline |
tax-calculator | API Extension | Failure; the triggering API call fails | 200 or 201, optionally with actions |
Worked example: Zen Electron's order confirmation
Zen Electron's Connector now does one job and proves it:
- The Subscription filter and the router both read
ORDER_SUBSCRIPTION_MESSAGE_TYPES, which listsOrderCreatedandOrderImported. buildOrderConfirmationEmailis a pure function with tests for the mapping, the Customer fallback, and the missing-recipient case.- The routing tests prove that both selected types reach the handler and that an unselected type is acknowledged without a send.
- The failure test proves local ordering: the handler returns
202before a provider failure. It doesn't cover the deadline caveat in The contract you're implementing. - The logs carry the Message type,
resource.id, andsequenceNumber, with no addresses or Order content. - The README records the policy: "Order confirmation: at-most-once; a rare missed email is tolerable and a duplicate is worse."
v0.2.0. The next page stages this tag.git add .
git commit -m "Narrow to order confirmation, extract mapping, add tests, record delivery policy"
git tag v0.2.0
git push origin main --tags
The template still has gaps that this module doesn't fix. Record them in the README so that they aren't mistaken for decisions:
- Line item names are read from the hardcoded
en-USlocale, with no fallback. See Localize with a fallback. orderTaxedPriceis always empty, becauseconvertMoneyToTextexpects a Money value andtaxedPriceis a TaxedPrice.convertMoneyToTextreturns an empty string for a currency with zero fraction digits or a zero amount.- Five template-ID keys in
connect.yaml(the Customer, state-change, and refund templates) are unused after the narrowing. The install form still asks for them until you remove them fromconnect.yaml. - Recurring Orders emit
OrderCreatedFromRecurringOrderinstead ofOrderCreated, and the Connector doesn't subscribe to it. Extend it with AI adds it.
Key takeaways
- The template returns
202before doing any work. If Connect receives it within 10 seconds, a later send failure isn't retried. A sandbox cold start can take about 15 seconds, so this policy doesn't guarantee one email per notification. - At-most-once is the guide's default for order confirmations. Emails where a miss blocks the customer, such as password reset, need at-least-once with deduplication.
- An email that needs at-least-once delivery also needs deduplication. The guide ranks the options.
- Make the Subscription filter and the router read one explicit list of Message types, so they can't drift apart.
- Keep the Message-to-email mapping a pure function, and test it without commercetools or the provider.
- Log the Message type,
resource.id, andsequenceNumber. Don't log Message bodies, addresses, or names, and don't log "sent" when nothing was sent. 202acknowledges an event delivery but fails an API Extension call.