Implement and test the event app to the contract

Narrow the template to the Messages you send email for, make the mapping testable, and state the delivery policy it implements.

Ask about this Page
Copy for AI
View as Markdown

After completing this page, you should be able to:

  • Explain why the template is at-most-once and what happens to an email when the send fails.

  • Narrow the Subscription filter and the router to the same explicit Message types.

  • Test the Message-to-email mapping as a pure function, and test routing and unsupported types.

  • Name the emails that need at-least-once delivery instead, and the deduplication options in order.

The email template returns 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.

You work in the 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

Price the contract stated the contract for Zen Electron's order confirmation email. Treat it as the acceptance target for this page:
  • 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.
Acknowledging first prevents retries only if Connect receives the acknowledgment within its event deadline, which is 10 seconds. A sandbox Deployment scales to zero when idle and takes about 15 seconds to start again, so the first event after an idle period can miss the deadline and be redelivered. At-most-once therefore means no retry after a failed send, not exactly one email per Order. The rest of this page relies on this caveat without repeating it. Check Application behavior and limitations and Environments for the current values.

Read what the template does today

Open src/controllers/mail-sending.controller.js. The first statement of messageHandler is the acknowledgment:
src/controllers/mail-sending.controller.js (unmodified, excerpt)Javascript
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...
  }
};
Everything after the first statement runs after the local response is sent. The 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:
  1. Checks for request.body.message.data and decodes it from base64 JSON. This is the Google Cloud Pub/Sub push envelope.
  2. Skips ResourceCreated notifications, which Connect sends when the Subscription is created.
  3. Rejects Message types that aren't in its lists.
  4. Checks that an Order Message references an Order and skips Orders that link to no Customer contact.
  5. Skips changes that the Connector's own API Client made, so the Connector can't trigger itself.
Each "skip" or "reject" throws a CustomError with a status code, but because the response already left, that code only decides whether the error is logged as info or error.
The controller then routes nine Message types to six handlers. 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.
The template decodes only the Pub/Sub envelope. Its 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.

The guide says that the Subscription filter and the handler router must contain the same explicit set. The template keeps those in separate lists. You narrow them to one list that both read.
First, narrow the list in src/constants/constants.js:
src/constants/constants.jsdiff
+// 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,
 ];
Next, make the Subscription filter in src/connector/actions.js subscribe to Order Messages only. Remove the three Customer build...MessageType functions and their imports, which are now unused:
src/connector/actions.jsdiff
-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()],
Then make the router read the same list in src/validators/message.validators.js. isOrderConfirmationMessage had its own inline list, and isValidMessageType accepted every type the template knew:
src/validators/message.validators.jsdiff
 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);
 }
Also remove 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:

src/controllers/mail-sending.controller.jsJavascript
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 getHandler is a second line of defense. doValidation already rejects a type that isn't in ORDER_SUBSCRIPTION_MESSAGE_TYPES and 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 catch block logs err.message with a correlation context, not the whole error object. The context carries the Message type, resource.id, and sequenceNumber once 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 a customerId, the Customer (view_customers), and postDeploy still 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.

An 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

The guide asks you to keep the Message-to-provider mapping a pure function, so that you can test it without calling commercetools or the provider. In the template, the mapping sits inside OrderConfirmationHandler.process(), between two API calls and a send. Extract it.
Create src/mappers/order-confirmation.mapper.js. It moves the template's mapping without changing its output, apart from two guards:
src/mappers/order-confirmation.mapper.jsJavascript
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]: images is optional on a ProductVariant. The template read images[0] directly, so an Order whose variant has no images field threw a TypeError and no email was sent.
  • The recipient check: the template fell back to customer.email without checking that a Customer exists, which threw a TypeError instead of a clear error.
Now replace src/handlers/order-confirmation.handler.js so that it fetches, maps, sends, and logs:
src/handlers/order-confirmation.handler.jsJavascript
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;
This also fixes the logging. The original handler logged the entire Message (for 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.
Last, make the provider stub in src/handlers/generic.handler.js honest about what it does:
src/handlers/generic.handler.js (sendMail)Javascript
  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,
    });
  }
The sender address and template ID are your own configuration, not customer data, so logging them is safe and lets you confirm configuration changes later. You keep the stub for the rest of this module, so you need no provider account. When you replace it with a real provider call, keep the signature, and pass the provider an idempotency key if you ever change the policy (see Record the policy).

Test locally

The template's tests use Jest with sinon for stubs. Add tests/unit/order-confirmation.spec.js with three groups of tests: the mapping, the routing, and the failure mode.
tests/unit/order-confirmation.spec.jsJavascript
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,
    });
  });
});
The test stubs 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.
Run the unit tests and lint from mail-sender/:
npm run test:unit
npx eslint src tests
All eight unit tests pass: your seven and the template's existing controller test. Against the unmodified template, the routing test for 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

To see the HTTP behavior end to end, start the application and post a Pub/Sub push envelope to it. Test an event application describes the envelope format. The template listens on port 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.
Terminal 1bash
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
Terminal 2bash
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\"}}"
The response is 202 Accepted whatever happens next. Read the outcome in Terminal 1:
  • With the ID of an existing Order that has a customerEmail, the log shows Email provider stub: email not sent, then Order confirmation handed to email provider with the Message type, Order ID, and sequenceNumber.
  • With "type":"ReturnInfoAdded", the log shows Message 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

The failure test above is the scenario from the start of this page. The provider throws after the controller returns 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

The guide asks you to record the delivery policy for each email type. Add it to your repository's README.md, where the next person to change the handler will see it:
README.md (excerpt)markdown
## Delivery policy

| Email type | Policy | Why |
| --- | --- | --- |
| Order confirmation (`OrderCreated`, `OrderImported`) | At-most-once | A rare missed email is tolerable; a duplicate is worse. |
If Zen Electron later sends an email where a miss blocks the customer, such as password reset or email verification, that email needs at-least-once delivery with deduplication instead. In at-least-once delivery, the handler acknowledges only after the send is accepted, so Connect redelivers a failed event, and a redelivery repeats the send unless the handler deduplicates. The guide ranks the deduplication options and describes how to reconcile an ambiguous provider result in Choose delivery and deduplication behavior. This module doesn't build any of this, because the order confirmation doesn't need it.

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.
Delete-then-create isn't atomic. Messages created between the delete and the create aren't delivered, so a redeploy can cost missed emails. The guide recommends a get, create, or versioned update flow instead. This change is optional for this module: make 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 postDeploy fails, 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.

The tax integration template has a 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:
tax-calculator/src/controllers/tax.calculator.controller.js (excerpt)Javascript
  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);
The API Extension response rules treat any status other than 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:

ApplicationConnection point202 meansApprove or acknowledge with
mail-senderSubscription, through ConnectAcknowledged if received within the deadline; no retry for later failuresAn acknowledgment status within the deadline
tax-calculatorAPI ExtensionFailure; the triggering API call fails200 or 201, optionally with actions

Worked example: Zen Electron's order confirmation

Zen Electron's Connector now does one job and proves it:

  1. The Subscription filter and the router both read ORDER_SUBSCRIPTION_MESSAGE_TYPES, which lists OrderCreated and OrderImported.
  2. buildOrderConfirmationEmail is a pure function with tests for the mapping, the Customer fallback, and the missing-recipient case.
  3. The routing tests prove that both selected types reach the handler and that an unselected type is acknowledged without a send.
  4. The failure test proves local ordering: the handler returns 202 before a provider failure. It doesn't cover the deadline caveat in The contract you're implementing.
  5. The logs carry the Message type, resource.id, and sequenceNumber, with no addresses or Order content.
  6. The README records the policy: "Order confirmation: at-most-once; a rare missed email is tolerable and a duplicate is worse."
Commit the changes and tag 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-US locale, with no fallback. See Localize with a fallback.
  • orderTaxedPrice is always empty, because convertMoneyToText expects a Money value and taxedPrice is a TaxedPrice.
  • convertMoneyToText returns 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 from connect.yaml.
  • Recurring Orders emit OrderCreatedFromRecurringOrder instead of OrderCreated, and the Connector doesn't subscribe to it. Extend it with AI adds it.

Key takeaways

  • The template returns 202 before 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, and sequenceNumber. Don't log Message bodies, addresses, or names, and don't log "sent" when nothing was sent.
  • 202 acknowledges an event delivery but fails an API Extension call.

Test your knowledge