sfcc-dev-mcp
Version:
MCP server for Salesforce B2C Commerce Cloud development assistance including logs, debugging, and development tools
676 lines (543 loc) • 24.8 kB
Markdown
# Salesforce B2C Commerce: Custom SCAPI Endpoint Best Practices
This guide provides a concise overview of best practices and examples for creating custom Salesforce Commerce API (SCAPI) endpoints in B2C Commerce Cloud.
**IMPORTANT**: Before implementing custom SCAPI endpoints, consult the **Performance and Stability Best Practices** guide from this MCP server. Pay special attention to the external system integration guidelines, timeout requirements, and caching strategies for optimal endpoint performance.
## 1. Authentication Methodologies for Custom SCAPI Endpoints
Custom SCAPI endpoints leverage the Shopper Login and API Access Service (SLAS) for authentication and authorization. Understanding SLAS authentication flows is critical for secure endpoint implementation.
### 1.1 Client Architecture Decision: Public vs. Private
The most critical architectural decision is determining your client type, which dictates the entire authentication flow:
#### Private Clients
- **Capability**: Can securely store a `client_secret` on a server-side component
- **Use Cases**: Backend-for-Frontend (BFF), full-stack web applications, server-to-server integrations
- **Security Model**: Uses `client_secret` in Basic Authorization header for client authentication
- **OAuth Grant Types**:
- `client_credentials` (guest users, system operations)
- `authorization_code` (registered user authentication)
#### Public Clients
- **Capability**: Cannot securely store secrets (operates in untrusted environments)
- **Use Cases**: PWA Kit storefronts, Single-Page Applications (SPAs), native mobile apps
- **Security Model**: Uses Proof Key for Code Exchange (PKCE) for dynamic security
- **OAuth Grant Types**:
- `authorization_code_pkce` (all authentication scenarios)
### 1.2 Security Best Practices
#### Scope-Based Authorization in Custom Endpoints
Always implement fine-grained authorization checks in your endpoint scripts:
```javascript
// In your custom endpoint script
exports.getLoyaltyInfo = function () {
var customerId = request.getHttpParameterMap().get('c_customer_id').getStringValue();
// CRITICAL: Verify the authenticated user can access this customer's data
if (request.user && request.user.profile) {
var authenticatedCustomerId = request.user.profile.customerNo;
// Prevent privilege escalation
if (authenticatedCustomerId !== customerId) {
RESTResponseMgr.createError(403, "insufficient-privileges",
"Access Denied", "Cannot access another customer's data").render();
return;
}
}
// Continue with business logic...
};
```
### 1.3 Client Configuration and Scope Management
#### SLAS Client Configuration
Configure clients using the SLAS Admin API or UI:
```javascript
// Example API call to create/update a SLAS client
const clientConfig = {
"clientId": "your-client-id",
"name": "My Custom API Client",
"isPrivateClient": true, // or false for public clients
"secret": "secure-client-secret", // Only for private clients
"channels": ["your-site-id"],
"scopes": [
"sfcc.shopper-baskets-orders.rw",
"sfcc.shopper-myaccount.rw",
"sfcc.shopper-products",
"c_read_loyalty", // Your custom scope
"c_write_loyalty"
],
"redirectUri": [
"https://your-app.com/callback"
]
};
```
#### Custom Object Scopes
For accessing custom objects via SCAPI, configure both the object and scope:
1. **Define Custom Object** in Business Manager: `Administration > Site Development > Custom Object Types`
2. **Add Scope** to SLAS client: `sfcc.shopper-custom-objects.{object-type-id}`
```yaml
# In your schema.yaml
security:
- ShopperToken: [sfcc.shopper-custom-objects.StoreReview]
```
### 1.4 Error Handling and Troubleshooting
#### Common Authentication Errors
| Error Code | Cause | Solution |
|------------|-------|----------|
| 401 Unauthorized | Invalid or expired token | Refresh token or re-authenticate |
| 403 Forbidden | Valid token, missing scope | Check client scope configuration |
| 400 Bad Request | Invalid PKCE parameters | Verify code_verifier/code_challenge |
| 404 Not Found | Incorrect endpoint URL | Verify API registration and URL structure |
#### Token Validation in Scripts
```javascript
// Validate token and extract user information
function validateAndExtractUser() {
if (!request.user) {
RESTResponseMgr.createError(401, "unauthorized",
"Authentication Required", "Valid token required").render();
return null;
}
// For registered users
if (request.user.profile && request.user.profile.customerNo) {
return {
type: 'registered',
customerId: request.user.profile.customerNo,
email: request.user.profile.email
};
}
// For guest users
return {
type: 'guest',
customerId: request.user.ID // Guest customer ID
};
}
```
## 2. Core Concept: The Three Pillars of a Custom API
Every Custom SCAPI Endpoint is built from three mandatory files, located within a dedicated cartridge directory: `your_cartridge/cartridge/rest-apis/{api-name}/`.
- **`schema.yaml` (The Contract)**: An OpenAPI 3.0 specification that defines the endpoint's URL path, HTTP method, parameters, security requirements, and response models. SCAPI uses this for automated request validation.
- **`script.js` (The Logic)**: A server-side B2C Commerce script (`dw.*` packages) that contains the business logic. It must export a public function with a name that exactly matches the `operationId` in the schema.
- **`api.json` (The Mapping)**: A simple JSON file that links the `operationId` from the schema to the correct implementation script.
### 2.1 Development Approach: Start Simple, Then Expand **!IMPORTANT!**
When building custom endpoints, **always start with a minimal implementation** to establish connectivity and basic functionality before adding complexity. This mirrors how experienced SFCC developers approach endpoint development and significantly reduces debugging time.
**Phase 1: Create a Minimal Version of Your Actual Endpoint**
1. Define a basic OpenAPI schema with minimal parameters (just `siteId` for Shopper APIs and your core parameter)
2. Implement a simple script that returns a static, well-formed response matching your intended data structure
3. Test that the endpoint is reachable and authentication works correctly
4. Verify request/response flow through SCAPI
**Phase 2: Add Core Business Logic**
1. Expand the schema to include your actual parameters and response models
2. Implement the real business logic step by step
3. Add error handling and validation
4. Test each piece of functionality incrementally
This approach helps you isolate issues early - if the simple version doesn't work, you know the problem is with basic setup (cartridge registration, API configuration, authentication) rather than your business logic. Once the foundation is solid, you can confidently build upon it.
## 2. Quick Start Example: A "Loyalty Info" Endpoint
Here is a complete example for a custom Shopper API endpoint `GET /custom/loyalty-api/v1/.../loyalty-info?c_customer_id={id}`.
### Directory Structure
```
int_loyalty_api/
└── cartridge/
└── rest-apis/
└── loyalty-api/
├── schema.yaml
├── loyalty.js
└── api.json
```
### `schema.yaml`
```yaml
openapi: 3.0.0
info:
title: Loyalty Information API
version: "1.0.0" # Becomes /v1/ in the URL
paths:
/loyalty-info:
get:
summary: Retrieves loyalty points for a customer.
operationId: getLoyaltyInfo # Must match the exported function name
parameters:
- name: siteId # Required for Shopper APIs
in: query
required: true
schema:
type: string
minLength: 1
- name: c_customer_id # Custom parameters must be prefixed with 'c_'
in: query
required: true
schema:
type: string
responses:
'200':
description: Successful retrieval of loyalty information.
content:
application/json:
schema:
$ref: '#/components/schemas/LoyaltyInfo'
'404':
description: Customer not found.
security:
- ShopperToken: [c_read_loyalty] # Apply security scheme and custom scope
components:
schemas:
LoyaltyInfo:
type: object
properties:
tier:
type: string
points:
type: integer
securitySchemes:
ShopperToken: # Define the security scheme for Shopper APIs
type: oauth2
flows:
clientCredentials:
tokenUrl: https://my-shortcode.api.commercecloud.salesforce.com/shopper/auth/v1/organizations/my-org-id/oauth2/token
scopes:
c_read_loyalty: "Read access to loyalty data." # Define custom scope
```
### `loyalty.js`
```javascript
'use strict';
var RESTResponseMgr = require('dw/system/RESTResponseMgr');
var CustomerMgr = require('dw/customer/CustomerMgr');
/**
* Implements the getLoyaltyInfo operationId.
*/
exports.getLoyaltyInfo = function () {
var customerId = request.getHttpParameterMap().get('c_customer_id').getStringValue();
var customer = CustomerMgr.getCustomerByCustomerNumber(customerId);
// IMPORTANT: Add fine-grained authorization check here.
// e.g., verify request.user.profile.customerNo === customerId
if (customer) {
var loyaltyData = {
tier: 'Gold',
points: 25800
};
RESTResponseMgr.createSuccess(loyaltyData).render();
} else {
RESTResponseMgr.createError(404, "customer-not-found", "Customer Not Found", "Customer ID is unknown.").render();
}
};
// Function must be public to be exposed as an endpoint
exports.getLoyaltyInfo.public = true;
```
### `api.json`
```json
{
"endpoints": [
{
"endpoint": "getLoyaltyInfo",
"schema": "schema.yaml",
"implementation": "loyalty"
}
]
}
```
## 2.1 Working with Path Parameters
When your OpenAPI schema defines parameters with `in: path`, you must use the `getSCAPIPathParameters()` method to access them in your script implementation.
### Path Parameter Example
**schema.yaml with path parameter:**
```yaml
openapi: 3.0.0
info:
title: Product Reviews API
version: "1.0.0"
paths:
/products/{productId}/reviews:
get:
summary: Get reviews for a specific product
operationId: getProductReviews
parameters:
- name: productId
in: path # This is a path parameter
required: true
schema:
type: string
- name: siteId
in: query
required: true
schema:
type: string
responses:
'200':
description: Product reviews retrieved successfully
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Review'
components:
schemas:
Review:
type: object
properties:
id:
type: string
rating:
type: integer
comment:
type: string
```
**script.js accessing path parameter:**
```javascript
'use strict';
var RESTResponseMgr = require('dw/system/RESTResponseMgr');
var ProductMgr = require('dw/catalog/ProductMgr');
/**
* Implements the getProductReviews operationId.
*/
exports.getProductReviews = function () {
// Access path parameters using getSCAPIPathParameters()
var productId = request.getSCAPIPathParameters().get('productId');
// Access query parameters using getHttpParameterMap()
var siteId = request.getHttpParameterMap().get('siteId').getStringValue();
// Validate required path parameter
if (!productId) {
RESTResponseMgr.createError(400, "missing-product-id",
"Missing Product ID", "Product ID is required in the path").render();
return;
}
var product = ProductMgr.getProduct(productId);
if (!product) {
RESTResponseMgr.createError(404, "product-not-found",
"Product Not Found", "Product with ID " + productId + " was not found").render();
return;
}
// Business logic to fetch reviews
var reviews = [
{
id: "review-1",
rating: 5,
comment: "Excellent product!"
},
{
id: "review-2",
rating: 4,
comment: "Good quality, fast shipping"
}
];
RESTResponseMgr.createSuccess(reviews).render();
};
exports.getProductReviews.public = true;
```
### Key Points for Path Parameters
1. **Path Parameter Access**: Always use `request.getSCAPIPathParameters().get('parameterName')` for parameters defined with `in: path` in your OpenAPI schema.
2. **Query Parameter Access**: Continue using `request.getHttpParameterMap().get('parameterName')` for parameters defined with `in: query`.
3. **Parameter Validation**: Path parameters are automatically validated by SCAPI based on your schema, but you should still add business logic validation in your script.
4. **URL Structure**: Path parameters become part of the URL structure. For example, `/products/{productId}/reviews` with `productId: "ABC123"` becomes `/products/ABC123/reviews`.
5. **Multiple Path Parameters**: You can have multiple path parameters in a single endpoint:
```yaml
paths:
/customers/{customerId}/orders/{orderId}:
get:
parameters:
- name: customerId
in: path
required: true
- name: orderId
in: path
required: true
```
Access them individually:
```javascript
var customerId = request.getSCAPIPathParameters().get('customerId');
var orderId = request.getSCAPIPathParameters().get('orderId');
```
## 3. Core Best Practices
### Design & Architecture
**Shopper vs. Admin APIs**: Choose the correct type for your use case. This choice is critical and dictates security, required parameters, and performance limits.
- **Shopper API**: For customer-facing applications. Requires `siteId` parameter. Secured by ShopperToken (SLAS). 10-second timeout.
- **Admin API**: For merchant tools. Must not have `siteId`. Secured by AmOAuth2 (Account Manager). 60-second timeout.
**BFF Pattern**: Create endpoints that aggregate data for specific UI components to reduce the number of client-side calls and improve performance.
### Security
- **Custom Scopes**: Every endpoint must be secured by exactly one custom scope defined in the `schema.yaml`. Scope names must start with `c_` (e.g., `c_read_loyalty`).
- **Client Permissions**: The API client in SLAS (for Shopper) or Account Manager (for Admin) must be granted permission to use your custom scope.
- **In-Script Authorization**: The platform validates the token and scope. You must validate that the authenticated user has permission to access the specific data they are requesting. This prevents privilege escalation attacks.
### Performance
- **Timeouts**: Respect the hard timeouts: 10 seconds for Shopper APIs and 60 seconds for Admin APIs.
- **Efficient Scripting**: Avoid expensive API calls (e.g., `ProductMgr.getProduct()`) inside loops. Fetch data in bulk where possible.
- **External Calls**: Use the Service Framework (`dw.svc.*`) for any third-party callouts. Set aggressive timeouts and enable the circuit breaker to prevent cascading failures.
- **Caching**: Use Custom Caches (`dw.system.CacheMgr`) for data that is expensive to compute but doesn't change often.
### Versioning
- The URL version (e.g., `/v1/`) is automatically derived from the major version number in your `schema.yaml`'s `info.version` field (e.g., "1.0.0" or "1.2.5" both map to v1).
- Introduce breaking changes (e.g., removing a field, changing a data type) in a new major version (e.g., 2.0.0 -> `/v2/`) to avoid disrupting existing clients.
### Troubleshooting
- **404 Not Found**: This almost always means the API failed to register. Systematically check your cartridge structure, file names, `operationId` matching, and `api.json` syntax.
- **Log Center**: Use the Log Center with the query `CustomApiRegistry` to find detailed error messages about registration failures.
- **403 Forbidden**: The client's token is valid but is missing the required custom scope. Check the scope assignments in SLAS or Account Manager.
- **504 Gateway Timeout**: Your script exceeded the performance limit. Use the Code Profiler to find and optimize the bottleneck in your code.
## 4. Custom APIs vs. Hooks
This is a critical architectural decision.
- **Use Hooks to...** modify or augment the behavior of an existing, out-of-the-box SCAPI endpoint. For example, adding a custom attribute to the standard `/baskets` response.
- **Use Custom APIs to...** create entirely new functionality that has no OOTB equivalent. For example, a store locator, a loyalty points service, or a newsletter signup endpoint.
> **Note**: Choosing the wrong tool leads to technical debt. Do not use hooks to create net-new functionality.
---
## Appendix: Authentication Flow Examples
This section provides detailed implementation examples for different SLAS authentication flows. These are reference implementations that can be adapted to your specific client architecture.
### A.1 Private Client: Guest Token Flow (Client Credentials)
```javascript
// Server-side implementation for obtaining a guest token
const axios = require('axios');
async function getGuestToken() {
const clientId = 'your-private-client-id';
const clientSecret = 'your-client-secret';
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
try {
const response = await axios.post(
'https://your-shortcode.api.commercecloud.salesforce.com/shopper/auth/v1/organizations/your-org-id/oauth2/token',
new URLSearchParams({
'grant_type': 'client_credentials',
'channel_id': 'your-site-id',
'redirect_uri': 'https://your-app.com/callback'
}),
{
headers: {
'Authorization': `Basic ${credentials}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
return {
accessToken: response.data.access_token,
refreshToken: response.data.refresh_token,
customerId: response.data.customer_id,
usid: response.data.usid
};
} catch (error) {
throw new Error(`Failed to obtain guest token: ${error.message}`);
}
}
```
### A.2 Public Client: PKCE Authentication Flow
```javascript
// Browser-side implementation using PKCE
import { createHash, randomBytes } from 'crypto';
class PKCEAuthClient {
constructor(clientId, redirectUri, baseUrl) {
this.clientId = clientId;
this.redirectUri = redirectUri;
this.baseUrl = baseUrl;
}
// Step 1: Generate PKCE parameters
generatePKCEChallenge() {
const codeVerifier = randomBytes(32).toString('base64url');
const codeChallenge = createHash('sha256')
.update(codeVerifier)
.digest('base64url');
return { codeVerifier, codeChallenge };
}
// Step 2: Redirect to authorization endpoint
initiateLogin(hint = 'guest') {
const { codeVerifier, codeChallenge } = this.generatePKCEChallenge();
// Store code_verifier securely (sessionStorage for SPAs)
sessionStorage.setItem('pkce_code_verifier', codeVerifier);
const authUrl = new URL(`${this.baseUrl}/shopper/auth/v1/organizations/your-org-id/oauth2/authorize`);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', this.clientId);
authUrl.searchParams.set('redirect_uri', this.redirectUri);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('hint', hint);
window.location.href = authUrl.toString(); // GET redirect to authorization server
}
// Step 3: Exchange authorization code for tokens
async exchangeCodeForToken(authorizationCode) {
const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
if (!codeVerifier) {
throw new Error('Code verifier not found');
}
try {
const response = await fetch(
`${this.baseUrl}/shopper/auth/v1/organizations/your-org-id/oauth2/token`,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
'grant_type': 'authorization_code_pkce',
'code': authorizationCode,
'redirect_uri': this.redirectUri,
'client_id': this.clientId,
'code_verifier': codeVerifier,
'channel_id': 'your-site-id',
'usid': 'your-usid' // optional
})
}
);
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.statusText}`);
}
const tokens = await response.json();
// Clean up
sessionStorage.removeItem('pkce_code_verifier');
return tokens;
} catch (error) {
throw new Error(`Token exchange error: ${error.message}`);
}
}
}
```
### A.3 Advanced Authentication Patterns
#### Trusted System on Behalf of (TSOB)
For server-to-server integrations where a trusted system acts on behalf of a shopper:
```javascript
// Private client only - requires special scope: sfcc.ts_ext_on_behalf_of
async function getTSOBToken(shopperLoginId, idpOrigin = 'ecom') {
const response = await axios.post(
'https://your-shortcode.api.commercecloud.salesforce.com/shopper/auth/v1/organizations/your-org-id/oauth2/trusted-system/token',
new URLSearchParams({
'grant_type': 'client_credentials',
'hint': 'ts_ext_on_behalf_of',
'login_id': shopperLoginId,
'idp_origin': idpOrigin,
'channel_id': 'your-site-id'
}),
{
headers: {
'Authorization': `Basic ${credentials}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
return response.data;
}
```
#### Session Bridge for Hybrid Architectures
For transitioning between headless (SLAS JWT) and traditional SFRA (dwsid cookie):
```javascript
// Exchange SLAS token for SFRA session
async function bridgeToSFRA(slasAccessToken) {
const response = await fetch('/s/your-site/dw/shop/v20_4/sessions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${slasAccessToken}`,
'Content-Type': 'application/json'
},
credentials: 'include' // Important: allows cookie setting
});
// dwsid cookie is automatically set by the response
return response.json();
}
```
### A.4 Refresh Token Rotation (Public Clients)
SLAS enforces refresh token rotation for security. Each refresh token is single-use:
```javascript
class TokenManager {
async refreshToken(currentRefreshToken) {
try {
const response = await fetch(
`${this.baseUrl}/shopper/auth/v1/organizations/your-org-id/oauth2/token`,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
'grant_type': 'refresh_token',
'refresh_token': currentRefreshToken,
'client_id': this.clientId
})
}
);
const newTokens = await response.json();
// CRITICAL: Store new refresh token, old one is invalidated
this.storeTokens(newTokens);
return newTokens;
} catch (error) {
// Token may be compromised, clear all stored tokens
this.clearTokens();
throw error;
}
}
}
```