# Api Client Development

> Creating typed API clients with OpenAPI specs, authentication, and OAuth scopes for SCAPI and similar APIs. Use when adding a new SCAPI client, generating types from an OpenAPI spec, setting up OAuth middleware, or integrating a new Commerce API endpoint.

- **Type:** Skill
- **Install:** `agentstack add skill-salesforcecommercecloud-b2c-developer-tooling-api-client-development`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [SalesforceCommerceCloud](https://agentstack.voostack.com/s/salesforcecommercecloud)
- **Installs:** 0
- **Category:** [Security](https://agentstack.voostack.com/c/security)
- **Latest version:** 0.1.0
- **License:** Apache-2.0
- **Upstream author:** [SalesforceCommerceCloud](https://github.com/SalesforceCommerceCloud)
- **Source:** https://github.com/SalesforceCommerceCloud/b2c-developer-tooling/tree/main/.claude/skills/api-client-development
- **Website:** https://salesforcecommercecloud.github.io/b2c-developer-tooling/

## Install

```sh
agentstack add skill-salesforcecommercecloud-b2c-developer-tooling-api-client-development
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# API Client Development

This skill covers creating typed API clients using OpenAPI specifications, with proper authentication and OAuth scope handling. It builds on the patterns in [SDK Module Development](../sdk-module-development/SKILL.md).

## Overview

API clients in this project use:
- **openapi-fetch**: Type-safe HTTP client generated from OpenAPI specs
- **openapi-typescript**: Generates TypeScript types from OpenAPI specs
- **Middleware pattern**: Auth and logging injected via openapi-fetch middleware

## Creating a New API Client

### 1. Add the OpenAPI Spec

Place the spec in `packages/b2c-tooling-sdk/specs/`:

```
specs/
├── custom-apis-v1.yaml    # YAML or JSON
├── slas-admin-v1.yaml
└── ods-api-v1.json
```

### 2. Update Type Generation Script

In `packages/b2c-tooling-sdk/package.json`, add to the generate script:

```json
{
  "scripts": {
    "generate:types": "openapi-typescript specs/data-api.json -o src/clients/ocapi.generated.ts && openapi-typescript specs/newapi-v1.yaml -o src/clients/newapi.generated.ts"
  }
}
```

Run generation:

```bash
pnpm --filter @salesforce/b2c-tooling-sdk run generate:types
```

### 3. Create the Client Module

```typescript
// src/clients/newapi.ts
import createClient, {type Client} from 'openapi-fetch';
import type {AuthStrategy} from '../auth/types.js';
import type {paths, components} from './newapi.generated.js';
import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js';

// Re-export generated types for consumers
export type {paths, components};

// Client type alias
export type NewApiClient = Client;

// Config interface
export interface NewApiClientConfig {
  hostname: string;
  // Add API-specific config here
}

// Factory function
export function createNewApiClient(
  config: NewApiClientConfig,
  auth: AuthStrategy
): NewApiClient {
  const client = createClient({
    baseUrl: `https://${config.hostname}/api/v1`,
  });

  // Middleware order: auth first (runs last), logging last (sees complete request)
  client.use(createAuthMiddleware(auth));
  client.use(createLoggingMiddleware('NEWAPI'));

  return client;
}
```

### 4. Export from Clients Barrel

```typescript
// src/clients/index.ts
export {createNewApiClient, type NewApiClient, type NewApiClientConfig} from './newapi.js';
export type {paths as NewApiPaths, components as NewApiComponents} from './newapi.js';
```

---

## SCAPI Client Pattern (OAuth Scope Injection)

SCAPI APIs require specific OAuth scopes. Instead of requiring CLI commands to manage scopes, **encapsulate scope logic in the client factory**.

### The Problem

Without encapsulation, CLI commands leak auth implementation details:

```typescript
// BAD: CLI command manages scopes
class MyCommand extends OAuthCommand {
  protected override loadConfiguration(): ResolvedConfig {
    const config = super.loadConfiguration();
    config.scopes = ['sfcc.custom-apis', `SALESFORCE_COMMERCE_API:${tenantId}`];
    return config;
  }
}
```

### The Solution

Use `OAuthStrategy.withAdditionalScopes()` in the client factory:

```typescript
// GOOD: Client encapsulates scope requirements
import {OAuthStrategy} from '../auth/oauth.js';
import type {AuthStrategy} from '../auth/types.js';

/** Default OAuth scopes required for this API */
export const MY_API_DEFAULT_SCOPES = ['sfcc.my-api'];

export interface MyApiClientConfig {
  shortCode: string;
  tenantId: string;           // Required for tenant-specific scope
  scopes?: string[];          // Optional override
}

export function createMyApiClient(
  config: MyApiClientConfig,
  auth: AuthStrategy
): MyApiClient {
  const client = createClient({
    baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/my-api/v1`,
  });

  // Build required scopes: domain scope + tenant-specific scope
  const requiredScopes = config.scopes ?? [
    ...MY_API_DEFAULT_SCOPES,
    buildTenantScope(config.tenantId),
  ];

  // If OAuth strategy, add required scopes; otherwise use as-is (e.g., for testing)
  const scopedAuth = auth instanceof OAuthStrategy
    ? auth.withAdditionalScopes(requiredScopes)
    : auth;

  client.use(createAuthMiddleware(scopedAuth));
  client.use(createLoggingMiddleware('MY-API'));

  return client;
}
```

This pattern:
1. Keeps scope knowledge in the SDK, not the CLI
2. Allows scope override for special cases via `config.scopes`
3. Works with non-OAuth auth strategies (for testing/mocking)
4. CLI commands just pass the auth strategy through unchanged

---

## SCAPI Tenant ID Utilities

SCAPI APIs use an `organizationId` path parameter with the `f_ecom_` prefix, but OAuth scopes use the raw tenant ID. Use these utilities:

```typescript
// From @salesforce/b2c-tooling-sdk (or clients/custom-apis.ts)
import {toOrganizationId, normalizeTenantId, buildTenantScope} from '@salesforce/b2c-tooling-sdk';

// Convert tenant ID to organization ID (normalizes + adds f_ecom_ prefix)
toOrganizationId('zzxy_prd')        // Returns 'f_ecom_zzxy_prd'
toOrganizationId('f_ecom_zzxy_prd') // Returns 'f_ecom_zzxy_prd' (unchanged)
toOrganizationId('zzxy-prd')        // Returns 'f_ecom_zzxy_prd' (hyphen normalized)

// Normalize any tenant/org ID form to canonical underscore format
normalizeTenantId('f_ecom_zzxy_prd')                              // Returns 'zzxy_prd'
normalizeTenantId('zzxy-prd')                                     // Returns 'zzxy_prd'
normalizeTenantId('zzxy-prd.dx.commercecloud.salesforce.com')     // Returns 'zzxy_prd'

// Build tenant-specific OAuth scope (normalizes input)
buildTenantScope('zzxy_prd')        // Returns 'SALESFORCE_COMMERCE_API:zzxy_prd'
buildTenantScope('f_ecom_zzxy_prd') // Returns 'SALESFORCE_COMMERCE_API:zzxy_prd'
buildTenantScope('zzxy-prd')        // Returns 'SALESFORCE_COMMERCE_API:zzxy_prd'
```

### Constants

```typescript
/** Prefix required for SCAPI organizationId path parameter */
export const ORGANIZATION_ID_PREFIX = 'f_ecom_';

/** Prefix for tenant-specific SCAPI OAuth scopes */
export const SCAPI_TENANT_SCOPE_PREFIX = 'SALESFORCE_COMMERCE_API:';
```

---

## OAuthStrategy.withAdditionalScopes()

The `OAuthStrategy` class has a method for scope injection:

```typescript
// Creates a new OAuthStrategy with merged scopes
const scopedAuth = auth.withAdditionalScopes(['sfcc.custom-apis', 'SALESFORCE_COMMERCE_API:zzxy_prd']);
```

Key behaviors:
- Returns a **new** `OAuthStrategy` instance (immutable pattern)
- Merges scopes with deduplication (uses `Set`)
- The new strategy shares token cache with the original (keyed by clientId)
- If cached token doesn't have required scopes, it re-authenticates

---

## Complete SCAPI Client Example

Reference implementation: `packages/b2c-tooling-sdk/src/clients/custom-apis.ts`

```typescript
/*
 * Copyright (c) 2025, Salesforce, Inc.
 * SPDX-License-Identifier: Apache-2
 */
import createClient, {type Client} from 'openapi-fetch';
import type {AuthStrategy} from '../auth/types.js';
import {OAuthStrategy} from '../auth/oauth.js';
import type {paths, components} from './custom-apis.generated.js';
import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js';

export type {paths, components};
export type CustomApisClient = Client;

/** Default OAuth scopes required for Custom APIs */
export const CUSTOM_APIS_DEFAULT_SCOPES = ['sfcc.custom-apis'];

export interface CustomApisClientConfig {
  shortCode: string;
  tenantId: string;
  scopes?: string[];
}

export function createCustomApisClient(
  config: CustomApisClientConfig,
  auth: AuthStrategy
): CustomApisClient {
  const client = createClient({
    baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/dx/custom-apis/v1`,
  });

  // Build required scopes: domain scope + tenant-specific scope
  const requiredScopes = config.scopes ?? [
    ...CUSTOM_APIS_DEFAULT_SCOPES,
    buildTenantScope(config.tenantId),
  ];

  // If OAuth strategy, add required scopes; otherwise use as-is
  const scopedAuth = auth instanceof OAuthStrategy
    ? auth.withAdditionalScopes(requiredScopes)
    : auth;

  client.use(createAuthMiddleware(scopedAuth));
  client.use(createLoggingMiddleware('CUSTOM-APIS'));

  return client;
}

// Tenant ID utilities
export const ORGANIZATION_ID_PREFIX = 'f_ecom_';
export const SCAPI_TENANT_SCOPE_PREFIX = 'SALESFORCE_COMMERCE_API:';

export function normalizeTenantId(value: string): string {
  let id = value.trim();
  if (id.includes('.')) id = id.split('.')[0];
  if (id.startsWith(ORGANIZATION_ID_PREFIX)) id = id.slice(ORGANIZATION_ID_PREFIX.length);
  return id.replaceAll('-', '_');
}

export function toOrganizationId(tenantId: string): string {
  return `${ORGANIZATION_ID_PREFIX}${normalizeTenantId(tenantId)}`;
}

export function buildTenantScope(tenantId: string): string {
  return `${SCAPI_TENANT_SCOPE_PREFIX}${normalizeTenantId(tenantId)}`;
}
```

---

## CLI Command Integration

With scope encapsulation in the client, CLI commands become simple:

```typescript
// packages/b2c-cli/src/commands/scapi/custom/status.ts
import {OAuthCommand} from '@salesforce/b2c-tooling-sdk/cli';
import {createCustomApisClient, toOrganizationId} from '@salesforce/b2c-tooling-sdk';

export default class ScapiCustomStatus extends OAuthCommand {
  static flags = {
    ...OAuthCommand.baseFlags,
    'tenant-id': Flags.string({
      description: 'Organization/tenant ID',
      env: 'SFCC_TENANT_ID',
      required: true,
    }),
  };

  async run() {
    this.requireOAuthCredentials();

    const {'tenant-id': tenantId} = this.flags;
    const {shortCode} = this.resolvedConfig;

    // Auth strategy from base class - no scope configuration needed!
    const oauthStrategy = this.getOAuthStrategy();

    // Client handles scope injection internally
    const client = createCustomApisClient({shortCode, tenantId}, oauthStrategy);

    const {data, error} = await client.GET('/organizations/{organizationId}/endpoints', {
      params: {
        path: {organizationId: toOrganizationId(tenantId)},
      },
    });

    // Handle response...
  }
}
```

---

## Testing API Clients

Use MSW (Mock Service Worker) to mock API responses:

```typescript
import {http, HttpResponse} from 'msw';
import {setupServer} from 'msw/node';
import {createCustomApisClient} from '@salesforce/b2c-tooling-sdk';

const mockAuth: AuthStrategy = {
  async fetch(url, init) {
    return fetch(url, init);
  },
  async getAuthorizationHeader() {
    return 'Bearer mock-token';
  },
};

const server = setupServer(
  http.get('https://test.api.commercecloud.salesforce.com/dx/custom-apis/v1/organizations/*/endpoints', () => {
    return HttpResponse.json({
      data: [{apiName: 'test', status: 'active'}],
      total: 1,
      limit: 10,
    });
  })
);

beforeAll(() => server.listen());
afterAll(() => server.close());

it('fetches endpoints', async () => {
  const client = createCustomApisClient(
    {shortCode: 'test', tenantId: 'zzxy_prd'},
    mockAuth
  );

  const {data} = await client.GET('/organizations/{organizationId}/endpoints', {
    params: {path: {organizationId: 'f_ecom_zzxy_prd'}},
  });

  expect(data?.data).toHaveLength(1);
});
```

---

## Error Handling

When API requests fail, use `getApiErrorMessage()` to extract clean, user-friendly error messages. This utility handles multiple error formats and ensures HTML response bodies (like error pages from stopped sandboxes) are never shown to users.

### Using getApiErrorMessage

```typescript
import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk/clients';

const {data, error, response} = await client.GET('/sites', {...});

if (error) {
  // Returns structured error message or "HTTP 521 Web Server Is Down"
  const message = getApiErrorMessage(error, response);
  this.error(`Failed to fetch sites: ${message}`);
}
```

### Supported Error Patterns

The utility extracts messages from these patterns in priority order:

| API | Error Structure | Message Location |
|-----|-----------------|------------------|
| ODS/SLAS | `{ error: { message } }` | `error.error.message` |
| OCAPI | `{ fault: { message } }` | `error.fault.message` |
| SCAPI/Problem+JSON | `{ title, detail }` | `error.detail` or `error.title` |
| Standard Error | `{ message }` | `error.message` |
| Fallback | Any | `HTTP {status} {statusText}` |

### Why This Matters

**Without `getApiErrorMessage`:**
```
ERROR: Failed to fetch sites: 521 - Sandbox Down...
```

**With `getApiErrorMessage`:**
```
ERROR: Failed to fetch sites: HTTP 521 Web Server Is Down
```

### Important: Always Destructure `response`

When making API calls, always destructure the `response` object alongside `error`:

```typescript
// GOOD: Include response for error handling
const {data, error, response} = await client.GET('/endpoint', {...});

// BAD: Missing response - can't get clean error message
const {data, error} = await client.GET('/endpoint', {...});
```

---

## Troubleshooting

**OAuth scope errors (401/403 from SCAPI)**: Ensure the client factory calls `auth.withAdditionalScopes()` with both the domain scope (e.g., `sfcc.custom-apis`) and the tenant-specific scope (`SALESFORCE_COMMERCE_API:`). Use `buildTenantScope()` which normalizes any tenant ID form (hyphenated, hostname, org ID) to canonical underscores before building scopes.

**Type generation failures**: Check that the OpenAPI spec in `specs/` is valid YAML/JSON. Run `pnpm --filter @salesforce/b2c-tooling-sdk run generate:types` and inspect the output. Common issues: spec references external files that aren't present, or uses OpenAPI features not supported by openapi-typescript.

**Middleware ordering issues**: Auth middleware should be added first (`client.use(createAuthMiddleware(...))`), then logging. In openapi-fetch, middleware runs in reverse registration order for requests, so auth registered first means it runs last — ensuring the logging middleware sees the final request with auth headers.

**`organizationId` mismatch**: SCAPI path parameters need the `f_ecom_` prefix (use `toOrganizationId()`), while OAuth scopes need the raw tenant ID (use `normalizeTenantId()`). Both functions accept any parseable form (hyphenated, hostname, org ID). Mixing these up causes 404s or scope errors.

## Checklist: New SCAPI Client

1. Add OpenAPI spec to `specs/`
2. Update `generate:types` script in `package.json`
3. Run `pnpm --filter @salesforce/b2c-tooling-sdk run generate:types`
4. Create client module with:
   - Config interface including `tenantId`
   - Default scopes constant
   - Factory function with scope injection pattern
   - Tenant ID utilities (or import from existing)
5. Export from `src/clients/index.ts`
6. Add to main `src/index.ts` if needed
7. Write tests with MSW mocks
8. Build: `pnpm --filter @salesforce/b2c-tooling-sdk run build`
9. Test: `pnpm --filter @salesforce/b2c-tooling-sdk run test`

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [SalesforceCommerceCloud](https://github.com/SalesforceCommerceCloud)
- **Source:** [SalesforceCommerceCloud/b2c-developer-tooling](https://github.com/SalesforceCommerceCloud/b2c-developer-tooling)
- **License:** Apache-2.0
- **Homepage:** https://salesforcecommercecloud.github.io/b2c-developer-tooling/

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** yes
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-salesforcecommercecloud-b2c-developer-tooling-api-client-development
- Seller: https://agentstack.voostack.com/s/salesforcecommercecloud
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
