Skip to content

Web Services - Guide

Framework: KernDX Package Type: Managed Package

Target Audience:

  • Developers - Building inbound and outbound REST integrations with automatic logging and retry mechanisms
  • Architects - Designing API integration patterns with DTO serialization, mock testing, and orchestration
  • Business Analysts - Understanding API capabilities, configuration options, error handling, and monitoring

What problem does this solve?

Connecting Salesforce to an outside system in Apex means writing the same plumbing every time: build the request, send it, parse the response, log what happened, retry on failure, hide sensitive data, and make sure the callout runs before you save any records. Written by hand, that plumbing is repeated in every integration and drifts out of step.

This framework writes that plumbing once. You extend a base class, fill in the parts unique to your integration (the URL, the body, what to do with the answer), and the framework runs the rest. It handles both directions: calls Salesforce makes to other systems (outbound) and calls other systems make into Salesforce (inbound).

Developers use it to build REST integrations. Architects use it to standardise how every integration logs, retries, and protects data. Analysts use it to monitor API health and change behaviour without touching code. Use it whenever your code calls an external API or exposes an endpoint, and skip it for a single throwaway callout in a script.

Mental model

Think of the framework as a shipping department for your integrations. You write the contents of the parcel (the request body and what to do with the reply); the department handles the rest the same way every time: it stamps and addresses each parcel (endpoint, headers, credentials), keeps a copy of everything sent and received (the audit log), re-sends when delivery fails (retries), stops sending to an address that keeps bouncing (the circuit breaker), and blacks out anything sensitive on the copy it files (data masking). You decide what goes in the box; the department guarantees how it ships.

Use this when

  • The integration has to survive in production: failures must be logged and retried, not lost.
  • Sensitive data (cards, tokens, personal details) must be kept out of your logs automatically.
  • Several developers build integrations and you want them all to follow the same patterns.
  • An admin needs to call an API from a Flow, or turn an integration off during an incident, without writing or deploying code.
  • A callout has to run from a trigger, where the callout must happen before any record is saved.

Don't use this when

  • It is a one-off callout in anonymous Apex or a data-migration script: the plain Http / HttpRequest classes are simpler and lighter, and the logging, retry, and masking would be more than the job needs.
  • The integration is Salesforce-to-Salesforce and a Named Credential or External Services alone already covers it: use those.
  • A Workflow Rule, outbound message, or Platform Event can handle the outbound notification declaratively: stay with the built-in tool.
  • The API publishes an OpenAPI spec and you want admins to wire it into Flows with no Apex at all: Salesforce External Services is the better fit (see the comparison below).

Quick Start

Here is the fastest way to see the framework work. You write a small class that says what is unique about your integration, and the framework handles logging every call, retrying on failure, and tracking errors for you. You start from one of two base classes, depending on direction: API_Outbound for calls Salesforce makes out to another system, API_Inbound for calls another system makes into Salesforce.

Step-by-step walkthroughs: Fast Start - Outbound APIs and Fast Start - Inbound APIs cover implementation, testing, and common pitfalls.

apex
public inherited sharing class API_GetWeather extends API_Outbound
{
	public override void configure()
	{
		super.configure();
		requestPayload = new DTO_Request();
		responsePayload = new DTO_Response();
		defaultMockBody = '{"temperature": 72, "conditions": "Sunny"}';
	}
}

That is the whole shape of it: extend a base class, fill in the parts specific to your call, and the rest runs for you. The sections below go deeper on each piece.


Table of Contents

Expand
  1. What problem does this solve?
  2. Mental model
  3. Use this when
  4. Don't use this when
  5. Quick Start
  6. Quick Navigation
  7. What is it?
  8. How does it work?
  9. What are the moving parts?
  10. How do I use it in a managed package?
  11. Building Outbound APIs
  12. Building Inbound APIs
  13. Intra-Org API Calls
  14. Virtual Methods Reference
  15. What else can it do?
  16. How do I call it from a Flow?
  17. Logging and Monitoring
  18. Testing
  19. Capability Matrix (for Analysts)
  20. Anti-Patterns
  21. Best Practices
  22. Troubleshooting
  23. Support and Resources
  24. Related Documentation

Quick Navigation

I am a...I need to...Go to...
ArchitectUnderstand API architectureHow does it work?
ArchitectReview advanced featuresWhat else can it do?
DeveloperBuild my first APIQuick Start
DeveloperBuild outbound APIsBuilding Outbound APIs
DeveloperBuild inbound APIsBuilding Inbound APIs
AnalystConfigure API settingsCapability Matrix
AnalystMonitor API healthLogging and Monitoring

What is it?

What is the Web Services Framework?

The Web Services Framework gives you one consistent way to handle both inbound (receiving) and outbound (sending) API integrations in Salesforce. Instead of re-solving the same problems in every integration, you solve them once and reuse them. Here is what that gives you, and why each one matters:

  • One shape for every REST integration, so a developer who learns one can read any other.
  • A kept record of every call in ApiCall__c, so a production failure leaves evidence to investigate instead of an expired debug log.
  • Retries that recover on their own (linear, exponential, or custom backoff), so a brief outage at the other end does not fail the user.
  • Sensitive data hidden before it is logged, so cards, tokens, and personal details never sit in plain text.
  • Fake responses for tests and demos, so you exercise integration logic without making real network calls.
  • Flow actions for admins, so an API can be called with no Apex.
  • Failure tracking, so repeated problems surface rather than passing unnoticed.
  • Per-stage timing on every call, so you can see which integrations are slow and where the time goes.
  • Reusable architecture you can distribute as a managed package across orgs.
  • Calls back into your own org, so Composite, Tooling, and custom REST endpoints work through the same pipeline.

Web Services Framework Scope: 16 API_* classes (4 extending API_Outbound), 10 DTO_* classes, 2 named credentials, and full lifecycle tracking via ApiCall__c and ApiIssue__c. Includes UTIL_HttpClient for zero-boilerplate callouts with automatic retry, circuit breaker, and failure logging.

Responsibilities: The Web Services framework handles HTTP callout orchestration (request building, execution, response parsing, logging, and retry). It does not contain business logic; that belongs in the calling code or trigger actions. DTOs transport data; API classes orchestrate the call lifecycle.

When NOT to use this pattern:

  • Simple one-off callouts in anonymous Apex or data migration scripts
  • Salesforce-to-Salesforce integrations where External Services or Named Credentials alone suffice
  • Outbound messages that a Workflow Rule or Platform Event can handle declaratively

Why choose this over the built-in option?

What you get, and why it matters to you:

  • Consistency: All APIs follow the same patterns and conventions, so a developer who learns one integration can read any other.
  • Traceability: Every API call is logged with request/response details, so when something fails in production the evidence is still there to investigate.
  • Resilience: Automatic retries with configurable backoff periods, so a temporary outage at the other end recovers on its own instead of failing the user.
  • Security: Built-in data masking for sensitive fields, so secrets and personal data don't end up in your logs.
  • Testability: Mock frameworks for unit testing, so you test integration logic without making real network calls.
  • Monitoring: Performance metrics and error tracking, so you can see which integrations are slow or unhealthy.
  • Low-Code Integration: Direct Flow/Process Builder support, so admins can call APIs without writing Apex.
  • Callout Safety: The framework runs callouts before saving any records, which avoids the Salesforce error you hit when database work happens before a callout (see the orchestration pattern below).

UTIL_HttpClient (Fluent HTTP Client)

When you just need to make a quick HTTP call and still want retries, logging, and masking, you don't have to write a whole API class first. UTIL_HttpClient is the simplest way to make HTTP calls in KernDX. You configure the call with a few short chained calls, then one call sends it. It wraps the API_Outbound pipeline, so you still get automatic retry, a circuit breaker (after repeated failures it stops calling a failing system for a cool-off, then resumes), failure logging, performance timing, and data masking, without writing a dedicated API class.

When to use UTIL_HttpClient vs API_Outbound:

ScenarioRecommended Approach
Simple ad-hoc HTTP calls (GET, POST, PUT, DELETE, PATCH)UTIL_HttpClient
Complex integrations with DTOs, validation, or orchestrationAPI_Outbound
Quick prototypes or one-off calls that still need loggingUTIL_HttpClient
Subscriber handlers with custom processing logicUTIL_HttpClient with useHandler()

Ad-hoc mode: direct calls with a Named Credential (or ApiCredential__mdt DeveloperName) and URL path:

apex
// Simple GET
HttpResponse response = UTIL_HttpClient.get('CRM', '/accounts/{id}')
	.pathParam('id', accountId)
	.send();

// POST with retry
HttpResponse response = UTIL_HttpClient.post('PaymentGateway', '/charges')
	.body(chargeRequest)
	.withRetry(3)
	.send();

// DELETE
HttpResponse response = UTIL_HttpClient.del('CRM', '/contacts/{id}')
	.pathParam('id', contactId)
	.send();

Delegation mode: route processing through a subscriber handler via useHandler():

apex
API_Outbound handler = UTIL_HttpClient.useHandler(API_SendEmail.class)
	.credential('PaymentGateway')
	.withParameter(API_SendEmail.PARAM_RECIPIENT, email)
	.withTriggeringRecord(recordId)
	.invoke();

Caller-provided settings override the handler's ApiSetting__mdt defaults. Unspecified settings fall back to the handler's CMDT values:

Builder methodOverrides
.credential(name)Named Credential
.path(urlPath)EndpointPath__c
.withRetry(n)MaxRetryCount__c
.withRetry(n, backoff)MaxRetryCount__c + RetryBackoffSeconds__c
.withCircuitBreaker()CircuitBreakerEnabled__c
.onFailure(LOG_FAILURE)LogIssues__c

For full API details, see the UTIL_HttpClient reference.


KernDX vs OOTB: Web Services Comparison

Salesforce Out-of-the-Box Alternatives

Before reaching for the framework, it helps to know what Salesforce already gives you out of the box, so you can pick the lightest tool that fits. Salesforce provides several native web service capabilities:

  1. HttpRequest / HttpResponse - Manual HTTP callout classes for custom integrations
  2. Http.send() - Send HTTP requests directly
  3. RestContext - Handle inbound REST requests (@RestResource annotation)
  4. External Services - Import OpenAPI 2.0/3.0 schemas, auto-generate invocable actions (no code required)

Pros & Cons Comparison

This table lines up the three approaches feature by feature, so you can scan down a row to see whether something you need is built in, has to be hand-coded, or isn't available. A ✅ means the capability is provided for you, a ⚠️ means it works with caveats, and a ❌ means you'd build it yourself.

Full feature-by-feature comparison (17 rows)
FeatureKernDX Web Services FrameworkOOTB HttpRequest/ResponseOOTB External Services
Code Required⚠️ Apex class extending base class⚠️ Full Apex implementation✅ No code (OpenAPI import only)
Request/Response Logging✅ Automatic to ApiCall__c❌ Manual implementation⚠️ Limited via Debug Logs only
Retry Logic✅ Built-in linear/exponential/custom strategies❌ Manual implementation❌ No built-in retry
Data Masking✅ Regex/JSON-key/literal rules via MaskingRule__mdt + MaskingTarget__mdt❌ Manual implementation❌ No data masking
Mock Testing✅ Framework mock patternHttpCalloutMock interface⚠️ Cannot mock in unit tests
Performance Metrics✅ Automatic timing (callout, processing, total)❌ Manual tracking❌ No built-in metrics
Error TrackingApiIssue__c for retry management❌ Manual implementation⚠️ Error captured in debug logs
Callout/DML OrchestrationAPI_Dispatcher pattern❌ Manual code structure⚠️ Flow handles sequencing
DTO PatternDTO_JsonBase❌ Manual DTO creation✅ Auto-generated from OpenAPI
Named Credentials✅ Via ApiCredential__mdtcallout: syntax✅ Direct integration
Flow Integration✅ Built-in invocable methods❌ Must create invocables✅ Auto-generated invocable actions
Metadata ConfigurationApiSetting__mdt❌ Hardcoded or Custom Settings✅ External Service registration
Circuit BreakerUTIL_CircuitBreaker integration❌ Manual implementation❌ No circuit breaker
OpenAPI Support⚠️ Manual DTO creation❌ No OpenAPI support✅ OpenAPI 2.0/3.0 import
Setup Complexity⚠️ Base class + metadata config✅ Direct Apex coding✅ UI-based OpenAPI import
Flexibility✅ Full control over logic✅ Full control⚠️ Limited to OpenAPI operations
Performance⚠️ Framework overhead✅ Low overhead✅ Platform-optimized

When to Use KernDX Web Services Framework

Use the framework when the integration has to survive in production: when failures must be logged and retried, sensitive data masked, or several developers need to follow the same patterns.

  • Integrations that need detailed logging and monitoring
  • Production systems needing automatic retry and error handling
  • Compliance requirements for API audit trails
  • Multiple developers working on different API integrations
  • Sensitive data requiring automated masking
  • Complex error handling with retry logic
  • Flow-based integrations for low-code API calls
  • Trigger-based callouts needing orchestration

When to Use OOTB HttpRequest/Response

The plain Apex classes are the better choice when the call is simple and short-lived, and the logging, retry, and masking the framework adds would be more than the job needs.

  • Simple integrations with minimal logging needs
  • One-time data loads or scripts
  • Maximum performance is critical (no framework overhead)
  • Quick prototypes or POCs
  • Custom integration logic not fitting standard patterns

When to Use External Services

External Services fits when the API publishes an OpenAPI spec and you want admins to wire it into Flows without writing any Apex.

  • OpenAPI-compliant APIs (REST APIs with OpenAPI 2.0/3.0 specs)
  • No-code/low-code integrations for admins
  • Flow-first integrations (invocable actions)
  • Simple CRUD operations against external systems
  • Rapid prototyping from existing API specs
  • APIs that don't require retry logic, masking, or detailed logging

Framework Orchestration Pattern

Salesforce refuses to make an HTTP callout once you have unsaved database changes in the same transaction. It throws "Callout from triggers with uncommitted work pending is not allowed". The framework sidesteps this entirely by always doing the work in a fixed order, coordinated by API_Dispatcher:

  1. All HTTP callouts run first (so there is never unsaved work pending when a callout happens)
  2. All database changes are saved afterwards, once the callouts have completed
  3. Errors are handled cleanly at each stage

Because callouts always run before any record is saved, you never hit that "uncommitted work pending" error, because the framework keeps the two steps apart for you.

Namespace Note: Code examples in this guide omit the namespace prefix for readability. In subscriber orgs, prefix framework class references with your namespace (e.g., ClientNS.API_Outbound). See the Subscriber Context section for details.


How does it work?

Architecture Diagram

Before you read any class name, here is the shape of the whole thing. The diagram below shows where your code plugs in and what the framework does for you on each side of a call. Read it top to bottom: a call leaving Salesforce (outbound) starts at your code and ends with an audit record, retry, and circuit breaker; a call arriving at Salesforce (inbound) starts at an HTTP request and ends at your handler. The point of seeing it whole first: you can tell at a glance which box you write and which boxes the framework already gives you.

text
+---------------------------------------------------------------------------+
|                      WEB SERVICES FRAMEWORK ARCHITECTURE                  |
+---------------------------------------------------------------------------+
|                                                                           |
|  OUTBOUND FLOW (Salesforce -> External System)                            |
|  =============================================                            |
|                                                                           |
|  Your Code / Flow / Trigger                                               |
|        |                                                                  |
|        v                                                                  |
|  +-----------------------------+     +-----------------------------+      |
|  |     UTIL_HttpClient         |     |      API_Outbound          |      |
|  |  (Fluent HTTP Client)       |     |  (Full Lifecycle Handler)  |      |
|  |  .post().body().send()      |     |  configure/validate/send   |      |
|  +-------------+---------------+     +-------------+---------------+      |
|                |                                   |                      |
|                +-----------------------------------+                      |
|                                |                                          |
|                                v                                          |
|                  +-----------------------------+                          |
|                  |       API_Dispatcher         |                         |
|                  |  Phase 1: HTTP Callout       |                         |
|                  |  Phase 2: Commit DML         |                         |
|                  +-------------+---------------+                          |
|                                |                                          |
|          +---------------------+---------------------+                    |
|          v                     v                     v                    |
|  +---------------+   +----------------+   +------------------+           |
|  | ApiCall__c    |   | UTIL_Retry     |   | UTIL_Circuit     |           |
|  | (Audit Log)   |   | (Retry Logic)  |   | Breaker          |           |
|  +---------------+   +----------------+   +------------------+           |
|                                                                           |
+---------------------------------------------------------------------------+
|                                                                           |
|  INBOUND FLOW (External System -> Salesforce)                             |
|  =============================================                            |
|                                                                           |
|  HTTP Request                                                             |
|        |                                                                  |
|        v                                                                  |
|  +-----------------------------+     +-----------------------------+      |
|  |  REST_* (@RestResource)     |---->|       API_Dispatcher        |      |
|  |  REST routing only          |     |  processInboundService()    |      |
|  +-----------------------------+     +-------------+---------------+      |
|                                                    |                      |
|                                                    v                      |
|                                      +-----------------------------+      |
|                                      |       API_Inbound           |      |
|                                      |  validate -> onSuccess ->   |      |
|                                      |  updateResponseDTO          |      |
|                                      +-----------------------------+      |
|                                                                           |
+---------------------------------------------------------------------------+
|                                                                           |
|  CONFIGURATION (Custom Metadata)                                          |
|  ===============================                                          |
|  ApiSetting__mdt    - Endpoint, retry, circuit breaker                    |
|                       (outbound: HTTP method via getHttpMethod() override)|
|  ApiCredential__mdt - Named Credential references                         |
|  MaskingRule__mdt   - Shared redaction rules (regex, JSON key, literal)    |
|  MaskingTarget__mdt - Wires rules to specific fields or wildcards          |
|  ApiRuntimeSwitch__c - Emergency kill switch for all APIs                 |
|                                                                           |
+---------------------------------------------------------------------------+

Class Hierarchy

When you build an integration, you do not start from scratch. You extend one of two classes the framework already provides, and they in turn share a common base, so all the call handling lives in one place. The tree below shows where your own classes slot in: outbound (Salesforce calling out) extends API_Outbound, inbound (something calling into Salesforce) extends API_Inbound, and both sit under the shared API_Base. The benefit is that you only write the part that is unique to your integration; everything above it is handled for you.

text
API_Base (Abstract Base)
├── API_Outbound
│   ├── API_CallCurrentOrg (for intra-org calls)
│   └── Your Outbound REST APIs (e.g., API_PostExample, API_GetPwndPasswords)
└── API_Inbound
    └── Your Inbound REST APIs (e.g., API_Echo)

Key Design Patterns

How the design works in plain terms: the base classes run the whole call for you and only ask your class to fill in the parts that are specific to your integration: the URL, the body, what to do on success. Any records you want to save are collected and written together as one all-or-nothing batch (either every record commits, or if anything fails the whole set rolls back), and that save happens after the callout finishes, never before it. That ordering is the key benefit: callouts always run first, so you never hit the "uncommitted work pending" error Salesforce throws when database work happens before a callout.

For readers who recognise the patterns by name, here is how those plain-English behaviours map to the classic names:

  1. Template Method Pattern: Base classes define the flow; child classes override specific steps
  2. Unit of Work Pattern: Database changes are batched and committed together, registered as a set and saved all-or-nothing
  3. Data Transfer Object (DTO) Pattern: Separate objects for request/response serialization, a small class that holds exactly the fields to move in or out and converts itself to and from JSON
  4. Factory Pattern: Mock objects and API handlers created via factories
  5. Strategy Pattern: Different behaviors for inbound vs outbound
  6. Orchestration Pattern: Callouts execute before DML commits (see API_Dispatcher)

The Orchestration Pattern Explained

Salesforce will not let you make an HTTP callout once you have unsaved database changes in the same transaction. To avoid that, the framework always does the two kinds of work in a fixed order, and the API_Dispatcher.execute() method is what enforces it: every callout runs first, then every save runs afterwards. So you never have to think about the ordering yourself. Here are the two phases:

Phase 1: Process (Execute HTTP Callouts)

apex
for(ApiCall__c queueRecord : queueRecords)
{
	API_Base apiHandler = getHandler(queueRecord.ServiceName__c);
	apiHandler.process(queueRecord);  // Performs HTTP callout
	handlersToCommit.add(apiHandler);
}

Phase 2: Commit (Persist DML Operations)

apex
for(API_Base handlerToCommit : handlersToCommit)
{
	handlerToCommit.commitWork();  // Commits DML operations
}

This two-phase approach ensures:

  • All HTTP callouts complete before any DML
  • No "uncommitted work" errors
  • Transactional integrity across multiple APIs

What are the moving parts?

ApiCall__c (Custom Object)

Every API call leaves a record here, so when an integration misbehaves in production you have the request, the response, the timing, and the outcome on hand instead of a debug log that has already expired. Key fields:

All ApiCall__c fields
FieldDescription
ServiceName__cAPI class name (e.g., API_PostExample)
Direction__cInbound or Outbound
Status__cQueued, Completed, Failed, Aborted, Retry, Retrying, Batched
TriggeringRecordId__cRecord that triggered the API call
Request__cRequest body (auto-truncated if too large)
Response__cResponse body (auto-truncated if too large)
RequestParameters__cJSON serialized parameters
URL__cEndpoint URL
StatusCode__cHTTP status code
ErrorMessages__cError messages if call failed
NextRetry__cScheduled retry datetime (populated when retry is needed)
Retries__cNumber of retry attempts made
IsMockedResponse__cIndicates if response was mocked
CalloutDurationMs__cTime spent in HTTP callout (milliseconds)
HandlerDurationMs__cTime spent in handler logic
CommitDurationMs__cTime spent committing DML
TotalDurationMs__cTotal end-to-end time
LoggerContext__cSerialized logger context for transaction correlation

Scope: what ApiCall__c does and does not log. Know its boundary up front, so you don't expect it to answer a question it was never meant to. ApiCall__c records callouts that flow through the KernDX Web Services framework: outbound calls via API_Outbound / UTIL_HttpClient / API_Dispatcher, and inbound calls handled by API_Inbound. It is not an org-wide API-usage log. Direct Http / HttpRequest callouts, managed-package callouts, and platform API consumption never reach it. For org-wide API, login, and limit monitoring, use Salesforce Event Monitoring rather than ApiCall__c.

Large Content Handling: A big payload won't be silently cut off and lost. If request/response bodies exceed field limits, they're stored as ContentVersion files linked to the queue item, so the full body is still kept.

Automatic Transaction Correlation: An integration usually runs in the background, after the user's click has finished. The challenge is tying the background logs back to the action that started them. The LoggerContext__c field solves this by carrying a correlation ID, one tracking ID that follows a single user action across triggers, queries, callouts, and jobs, so the related logs stay linked across that async boundary. When queue items are created via TST_Factory.newOutboundApiCall() or newInboundApiCall(), the current logger context (correlation ID, transaction ID, global context) is automatically captured.

When API_Dispatcher.execute() processes the queue item asynchronously, it hydrates this context, ensuring all logs in the async transaction share the same correlation ID as the originating transaction. The benefit: you can trace one user action end to end with no extra work, since this requires zero code changes in subscriber orgs.

ApiSetting__mdt (Custom Metadata Type)

This is where you tune how each integration behaves, retries, circuit-breaker thresholds, which credential it uses, without changing code. Each row holds the configuration for one API service:

FieldDescription
ClassName__cAPI class name (must match exactly)
EndpointPath__cAPI endpoint path (appended to Named Credential)
ApiCredential__cLookup to credentials metadata
IsActive__cWhether the API service is active (default: true). Inactive services abort with an error.
RequiredFeatureFlag__cMetadataRelationship lookup to FeatureFlag__mdt, required for the service to execute
BypassFeatureFlag__cMetadataRelationship lookup to FeatureFlag__mdt, bypasses the service when enabled
MaxRetryCount__cMaximum retry attempts (e.g., 3)
RetryBackoffSeconds__cSeconds to wait before first retry (e.g., 30)
CircuitBreakerFailureThreshold__cNumber of consecutive failures before circuit opens (e.g., 5)
CircuitBreakerTimeout__cSeconds to keep circuit open before testing recovery (e.g., 60)
CircuitBreakerSuccessThreshold__cNumber of consecutive successes in half-open state to close the circuit (e.g., 2)
LogIssues__cCreate ApiIssue__c records
ResolveIssues__cAuto-resolve failures on success

Centralized Validation: Three of these settings act as gates: they decide whether the service is even allowed to run, so you can turn an integration off, or limit it to a feature flag, from configuration alone. API_Base.performValidation() automatically checks these three fields before any API handler executes:

  1. IsActive__c: If false, the service aborts with error: "API service is inactive"
  2. RequiredFeatureFlag__c: If the Feature Flag is not enabled for the running user, the service aborts with error: "Required feature flag is not enabled"
  3. BypassFeatureFlag__c: If the Feature Flag is enabled for the running user, the service aborts with error: "API service is bypassed by feature flag"

These checks run in both inbound and outbound flows, before getValidationErrors() is called.

MaskingRule__mdt + MaskingTarget__mdt

Masking (redaction) keeps secrets and personal data out of your logs, so a card number or an auth token in a request body never gets stored in plain text. The setup is split across two metadata types so that one rule (for example, "find credit card numbers") can be reused against many fields on many objects without copying the pattern into each place.

MaskingRule__mdt: the reusable what to find definition:

FieldDescription
Mode__cRegex, JsonKey (picklist label "JSON by Key"), ExactMatch, or CreditCard (the pattern match plus a Luhn checksum)
Pattern__cRegex pattern, JSON key regex, or literal string (depending on Mode)
Replacement__cReplacement text (e.g., [CARD_REDACTED])
CaseSensitive__cToggle case-insensitive matching
FailureAction__cLogAndContinue, WriteFailureMarker, or BlockDml when a pattern throws
IsActive__cEnable/disable rule globally
MinInputLength__cOptional minimum input length, rule skipped for values shorter than this (zero-cost short-circuit for short fields like URLs)
ApplicableFieldTypes__cOptional semicolon-delimited System.DisplayType.name() list (e.g., STRING;TEXTAREA;ENCRYPTEDSTRING) restricting the rule to specific field types; blank applies to every text-shaped field

MaskingTarget__mdt: the wiring that points a rule at the fields it should run on:

FieldDescription
Rule__cThe MaskingRule__mdt to apply
SObjectType__cTarget SObject (e.g., ApiCall__c)
Field__cSpecific field path, or blank for a wildcard across every text field
CallerClass__cOptional scope: only fire when the caller class name matches
IsActive__cEnable/disable this wiring without touching the rule

Default ship set (3 rules active, 12 targets): Sensible protection is already on the day you install, so common secrets are masked before you configure anything. Two rules do the work out of the box. MaskSecretKeys (JSON key redaction for password/token/apiKey/authorization/bearer and similar, scoped to STRING;TEXTAREA since JSON payloads only live in free-text fields) and MaskPaymentCard (CreditCard mode: 13-19 digit sequences passing the Luhn checksum, spaces or hyphens allowed, MinInputLength = 13, restricted to STRING;TEXTAREA;ENCRYPTEDSTRING field types), each wildcarded onto ApiCall__c, ApiIssue__c, AsyncChainExecution__c, and LogEntryEvent__e. MaskPaymentCard replaces the original MaskCreditCard rule, which still ships active (with its own four targets) for compatibility with configurations that reference it. Where both rules are wired to the same object, the payment-card rule does the work. Fifteen other rules (SSN, IBAN, SWIFT/BIC, MBI, health keywords, email, US phone, JWT, AWS access key, URL basic auth, authorization header, private IPv4, postal address, free text, and international phone) ship as inactive templates: when your org's data profile calls for one, flip IsActive__c = true and add a MaskingTarget__mdt to switch it on.

Rule-level filters override explicit target wiring. One precedence rule can surprise you, so know it before you debug a mask that never fires. If a MaskingTarget__mdt wires a rule to a specific Field__c (non-wildcard) whose DisplayType is excluded by the rule's ApplicableFieldTypes__c, the rule will not fire on that field. The rule's own field-type filter wins over what the target asks for. To help you catch this, the framework emits a one-time warn-level LogEntry__c with ClassMethod__c = 'UTIL_FrameworkMasker.filterTargetsByFieldType', surfacing the misconfiguration. The fix is to either widen the rule's ApplicableFieldTypes__c to include the field's type, or remove the target. MinInputLength__c behaves similarly but is a value-length check at mask time. It does not warn, so be careful: a rule whose minimum exceeds the field's schema max length will silently never fire.

ApiRuntimeSwitch__c (Hierarchy Custom Setting)

When an integration is causing harm in production, you need a master off-switch you can flip without a deployment. This is that kill switch: it disables all APIs at the org, profile, or user level, so you can stop the bleeding in seconds and investigate afterwards.

FieldDescription
DisableAllApis__cDisable all APIs (inbound and outbound)

That switch is deliberately broad. To turn off just one service instead of everything, use ApiSetting__mdt.IsActive__c. To disable or mock APIs through a feature flag, use UTIL_FeatureFlag with the DisableAllAPIs or MockAllInboundAPIs feature flags.

Named Credentials

Your endpoint URLs and passwords live here, kept out of code so a credential rotation never means a redeploy. A Named Credential stores the authentication details securely via External Credentials, and ApiSetting__mdt points to it by name.

The two named credentials KernDX ships. Both back the framework's outbound examples and call public, unauthenticated endpoints, so they store no secrets:

Named CredentialLabelEndpointAuthenticationUsed by
API_ExampleRestApiExample REST APIhttps://jsonplaceholder.typicode.comAnonymous (none)The outbound REST example API_PostExample
API_PwndPasswordsPwnd Passwordshttps://api.pwnedpasswords.comAnonymous (none)The breach-check example API_GetPwndPasswords

Replace or remove these when you wire up your own integrations. They are demonstrations, not production endpoints. One thing the framework does not do for you: keeping an inventory of your org's named credentials and how critical each one is. That is org configuration, so use native Setup or a posture tool such as AppOmni for it. A managed package does not catalogue your credentials for you.


How do I use it in a managed package?

CRITICAL Requirements for Managed Package Usage

When you install KernDX as a managed package, your org runs the framework code from a separate namespace. Two extra steps keep that boundary working so your integrations don't fail at runtime. This section explains what they are, why each one matters, and how to set them up.

@JsonAccess Annotation (MANDATORY)

Your data-transfer classes (DTOs, the small classes that hold exactly the fields you send or receive and convert themselves to and from JSON) live in your namespace, but the framework that turns them into JSON lives in the package namespace. Salesforce blocks one namespace from serializing another namespace's class unless you explicitly grant permission. The @JsonAccess annotation is that permission, so without it the conversion fails.

⚠️ ALL DTOs extending managed package classes MUST include @JsonAccess annotation:

apex
// ✅ CORRECT - @JsonAccess grants managed package permission to serialize/deserialize
@JsonAccess(Serializable='always' Deserializable='always')
public class DTO_Request extends DTO_JsonBase
{
	public String customerId;
	public String orderNumber;
}

// ❌ WRONG - Will fail at runtime with "Type cannot be serialized as it is not globally visible"
public class DTO_Request extends DTO_JsonBase
{
	public String customerId;
}

When to use each setting:

  • Serializable='always' - Required when the managed package calls serialize() on your DTO to turn it into JSON to send out (outbound requests)
  • Deserializable='always' - Required when the managed package calls deserialize() to read JSON back into your DTO (inbound responses)
  • Use both when data flows in and out, which is the most common case

Type Resolution (MANDATORY)

When the framework reads a JSON response back into a DTO, it has to find the right Apex class in your namespace to build. Across a namespace boundary it can't discover that class on its own, so you tell it where to look. This is type resolution, and you only need to set it up once. Pick one of the three approaches below.

⚠️ You MUST implement type resolution using ONE of these three approaches:

Option A: Make DTOs Global (simple but exposes classes)

apex
@JsonAccess(Serializable='always' Deserializable='always')
global class DTO_Request extends DTO_JsonBase
{
	public String data;
}

Option B: Implement getObjectType() in Every DTO (repetitive but explicit)

apex
@JsonAccess(Serializable='always' Deserializable='always')
public class DTO_Request extends DTO_JsonBase
{
	public String data;

	protected override Type getObjectType()
	{
		return DTO_Request.class;
	}
}

Option C: Register Type Resolver (RECOMMENDED - flexible and maintainable)

  1. Create resolver class:
apex
/**
 * @description Custom type resolver for subscriber org classes
 *
 * @see UTIL_TypeResolver
 */
global with sharing class CustomTypeResolver extends kern.UTIL_TypeResolver.BaseClassResolver
{
	/**
	 * @description Resolves a Type object from a class name
	 *
	 * @param className The name of the class to resolve
	 *
	 * @return Type The resolved Type object or null if not found
	 */
	public override Type resolveType(String className)
	{
		return getTypeForClassName(className) ?? (Type)nextResolver?.resolveType(className);
	}

	/**
	 * @description Resolves the Type for a given class name, handling namespaces and nested classes
	 *
	 * @param className The class name to resolve
	 *
	 * @return The resolved Type object, or null if not found
	 */
	private static Type getTypeForClassName(String className)
	{
		Type classType;

		if(String.isNotBlank(className))
		{
			String namespace = kern.UTIL_System.getNamespacePrefix(
				kern.UTIL_System.getClassNamespace(className),
				'.'
			);

			classType = Type.forName(namespace, className);
			// Retry without namespace for nested classes (e.g., MyParentClass.MyChildClass)
			classType = classType == null && String.isNotBlank(namespace)
				? Type.forName('', className)
				: classType;
		}

		return classType;
	}
}
  1. Register in ClassTypeResolver__mdt:

    • Label: Custom Type Resolver
    • DeveloperName: CustomTypeResolver
    • ClassName__c: CustomTypeResolver
  2. That's it. With the resolver registered you no longer implement getObjectType() in any DTO.

If you skip type resolution, a response that needs to be read back into a DTO fails at runtime with this error:

text
System.JSONException: Type cannot be deserialized as it is not globally visible - DTO_Request

Complete Example with Namespace

apex
/**
 * @description Example outbound API demonstrating subscriber namespace usage.
 * Demonstrates proper namespace usage and coding standards.
 *
 * @author your.name@company.com
 *
 * @group Web Services
 *
 * @date January 2026
 */
public inherited sharing class MyAPI_SendEmail extends API_Outbound
{
	/**
	 * @description Initializes the API handler with required DTOs and mock class.
	 * This method is called by the framework before processing begins.
	 */
	public override void configure()
	{
		super.configure();

		requestPayload = new DTO_Request();
		responsePayload = new DTO_Response();
		defaultMockBody = '{"success": true, "messageId": "12345"}';
		requiresTriggeringRecord = false;
	}

	/**
	 * @description Sets HTTP headers required for the API call.
	 * Adds a custom correlation header for request tracking.
	 */
	public override void setHeaders()
	{
		super.setHeaders();
		request.setHeader('X-Correlation-Id', UTIL_Random.randomUUID());
	}

	/**
	 * @description Registers database changes to be committed after the API call succeeds.
	 * API_Base extends DML_Transaction, so doInsert/doUpdate/doDelete are inherited directly.
	 */
	public override void onSuccess()
	{
		super.onSuccess();

		Task newTask = new Task(Subject = 'Email sent', Status = 'Completed');
		doInsert(newTask);  // Inherited from DML_Transaction
	}

	/**
	 * @description Data Transfer Object for the API request payload.
	 */
	@JsonAccess(Serializable='always')
	public class DTO_Request extends DTO_JsonBase
	{
		public String recipient;
		public String subject;
		public String body;
	}

	/**
	 * @description Data Transfer Object for the API response payload.
	 */
	@JsonAccess(Deserializable='always')
	public class DTO_Response extends DTO_JsonBase
	{
		public Boolean success;
		public String messageId;

		/**
		 * @description Returns the type of this DTO for proper deserialization.
		 *
		 * @return The DTO_Response class type
		 */
		public override Type getObjectType()
		{
			return DTO_Response.class;
		}
	}

}

Calling Framework Methods

Because the framework lives in its own namespace, your org needs to know which package a class belongs to. You signal that by putting the package namespace in front of every framework class and utility you call, so the reference resolves to the installed package:

apex
// ❌ WRONG — missing namespace prefix
String abbreviated = UTIL_String.abbreviate(input, 100);
Id recordId = UTIL_SObject.validateId(stringId);

// ✅ CORRECT — use kern namespace prefix
String abbreviated = kern.UTIL_String.abbreviate(input, 100);
Id recordId = kern.UTIL_SObject.validateId(stringId);

API Dispatcher Usage

apex
// Create queue item
ApiCall__c queueItem = TST_Factory.newOutboundApiCall(
	'MyAPI_SendEmail',
	null,
	new Map<String, String>{ 'recipient' => 'test@example.com' }
);

// Execute via factory
List<API_Base> handlers = API_Dispatcher.execute(
	new List<ApiCall__c>{ queueItem }
);

// Access results
MyAPI_SendEmail handler = (MyAPI_SendEmail)handlers[0];

if(handler.result.isSuccess)
{
	// Process success - e.g., update UI, return result to caller
}

Building Outbound APIs

When your Salesforce code needs to call an external system, that is an outbound API: Salesforce sends the HTTP request out. This section walks you through building one step by step, from picking a base class to running the call. You write only the parts unique to your integration; the framework handles the request, retries, logging, and saving any records afterward.

Step 1: Choose Your Base Class

Step 2: Override Virtual Methods

The base class already does most of the work, so you only override the methods where its default behaviour isn't what you need. For a simple call that can mean overriding nothing beyond the configuration. Each overridable method is explained in the Virtual Methods Reference section.

Minimal Implementation

This is the smallest outbound API you can write. It overrides only configure() to point at a response DTO (a small class that holds the fields you expect back and converts itself to and from JSON), so that the framework can turn the outgoing request into JSON and the incoming reply back into Apex for you.

apex
/**
 * @description Minimal outbound API implementation showing required overrides only.
 *
 * @author your.name@company.com
 *
 * @group Web Services
 *
 * @date January 2025
 */
public inherited sharing class API_MinimalExample extends API_Outbound
{
	/**
	 * @description Initializes the API handler with response DTO.
	 * The framework will auto-serialize requests and deserialize responses.
	 */
	public override void configure()
	{
		super.configure();
		responsePayload = new DTO_Response();
	}

	/**
	 * @description Data Transfer Object for API response.
	 */
	@JsonAccess(Deserializable='always')
	public class DTO_Response extends DTO_JsonBase
	{
		public String result;

		/**
		 * @description Returns the type of this DTO for proper deserialization.
		 *
		 * @return The DTO_Response class type
		 */
		public override Type getObjectType()
		{
			return DTO_Response.class;
		}
	}
}

Complete Real-World Example: REST GET with Parameters

For a fully worked GET call, look at API_GetPwndPasswords in the framework source. It shows the common pieces in one place: encoding URL parameters, setting custom headers, and checking that required parameters were supplied before the call runs.

Complete Real-World Example: REST POST with DML

This example sends email through an external service and then records each successful send as a Task. It is worth reading in full because it shows every common override together: declaring which input parameters are required, adding a tracking header, validating and parsing the incoming JSON, and creating records after the call. Notice that the Task inserts happen in onSuccess(), which the framework runs after the callout completes, so the database work never blocks the callout.

apex
/**
 * @description Sends email via external service API and logs activity as Task.
 * Demonstrates POST requests, parameter validation, and database operations.
 *
 * @see API_SendMail_TEST
 *
 * @author your.name@company.com
 *
 * @group Communications
 *
 * @date January 2025
 */
public inherited sharing class API_SendMail extends API_Outbound
{
	/**
	 * @description The required parameter name for send mail request JSON.
	 */
	public static final String REQUIRED_PARAMETER_REQUEST_JSON = 'sendMailRequests';

	/**
	 * @description Initializes the API handler with request/response DTOs and mock.
	 */
	public override void configure()
	{
		super.configure();
		requestPayload = new DTO_Request();
		responsePayload = new DTO_Response();
		defaultMockBody = '{"results": [{"success": true}]}';
		requiresTriggeringRecord = false;
	}

	/**
	 * @description Specifies required request parameters for validation.
	 *
	 * @return Set containing required parameter names
	 */
	public override Set<String> getRequiredInputs()
	{
		Set<String> requiredParameters = super.getRequiredInputs();
		requiredParameters.add(REQUIRED_PARAMETER_REQUEST_JSON);
		return requiredParameters;
	}

	/**
	 * @description Adds custom correlation header for request tracking.
	 */
	public override void setHeaders()
	{
		super.setHeaders();
		request.setHeader('X-Correlation-Id', UTIL_Random.randomUUID());
	}

	/**
	 * @description Bypasses sharing rules for this API to allow system-wide email operations.
	 */
	public override void commitWork()
	{
		commitWork(false);
	}

	/**
	 * @description Validates and deserializes the send mail request parameter.
	 *
	 * @return List of validation error messages
	 */
	public override List<String> getValidationErrors()
	{
		List<String> errors = new List<String>();
		DTO_Request dtoRequest = (DTO_Request)requestPayload;

		try
		{
			dtoRequest.dtoSendMailRequests =
				(List<DTO_SendMailRequest>)JSON.deserialize(
					inputs.get(REQUIRED_PARAMETER_REQUEST_JSON),
					List<DTO_SendMailRequest>.class
				);

			if(dtoRequest.dtoSendMailRequests == null || dtoRequest.dtoSendMailRequests.isEmpty())
			{
				errors.add('No send mail requests provided');
			}
		}
		catch(Exception error)
		{
			errors.add('Invalid JSON: ' + error.getMessage());
		}

		return errors;
	}

	/**
	 * @description Creates Task records for each successfully sent email.
	 * DML operations are committed after all callouts complete.
	 */
	public override void onSuccess()
	{
		super.onSuccess();

		DTO_Response dtoResponse = (DTO_Response)responsePayload;

		for(Integer i = 0; i < dtoResponse.results.size(); i++)
		{
			if(dtoResponse.results[i].success)
			{
				Task newTask = new Task
				(
					Subject = 'Email sent',
					Status = 'Completed',
					Type = 'Email'
				);
				doInsert(newTask);
			}
		}
	}

	/**
	 * @description Data Transfer Object for send mail request.
	 */
	@JsonAccess(Serializable='always')
	private class DTO_Request extends DTO_JsonBase
	{
		private List<DTO_Mail> messages = new List<DTO_Mail>();
		private transient List<DTO_SendMailRequest> dtoSendMailRequests;

		/**
		 * @description Populates the mail messages from the request list.
		 *
		 * @param recordId The record that triggered this API call (unused in this implementation)
		 */
		public override void populate(Id recordId)
		{
			super.populate(recordId);

			for(DTO_SendMailRequest request : dtoSendMailRequests)
			{
				DTO_Mail mail = new DTO_Mail();
				mail.toAddress = request.toAddress;
				mail.subject = request.subject;
				mail.body = request.body;
				messages.add(mail);
			}
		}
	}

	/**
	 * @description Data Transfer Object for individual mail message.
	 */
	@JsonAccess(Serializable='always')
	private class DTO_Mail
	{
		public String toAddress;
		public String subject;
		public String body;
	}

	/**
	 * @description Data Transfer Object for API response.
	 */
	@JsonAccess(Deserializable='always')
	private class DTO_Response extends DTO_JsonBase
	{
		public List<DTO_SendMessageOutcome> results;

		/**
		 * @description Returns the type of this DTO for proper deserialization.
		 *
		 * @return The DTO_Response class type
		 */
		public override Type getObjectType()
		{
			return DTO_Response.class;
		}
	}

	/**
	 * @description Data Transfer Object for individual send outcome.
	 */
	public class DTO_SendMessageOutcome
	{
		public Boolean success;
		public List<String> errors;
	}

	/**
	 * @description Data Transfer Object for send mail request parameters.
	 */
	public class DTO_SendMailRequest
	{
		public String toAddress;
		public String subject;
		public String body;
	}

}

Step 3: Create ApiSetting__mdt Record

With the Apex class written, the last setup step is a configuration record that tells the framework how to run it: which class, which endpoint, which stored credential, and how to behave on failure. Because these live in metadata, you can change the retry count or turn logging on or off without touching code. Create a custom metadata record with these values:

FieldValue
LabelAPI Send Mail
ClassName__cAPI_SendMail
EndpointPath__c/api/v1/send
ApiCredential__c(Lookup to credential)
MaxRetryCount__c3
RetryBackoffSeconds__c30
LogIssues__ctrue
ResolveIssues__ctrue

Step 4: Execute Your API

To run the call, you build a queue item describing the request, hand it to the dispatcher, and read the result. The dispatcher coordinates the callout and any record saves in the right order. The handler it returns carries the outcome, so you can check whether the call succeeded and act on it:

apex
// Create queue item
ApiCall__c queueItem = TST_Factory.newOutboundApiCall(
	'API_SendMail',
	null,
	new Map<String, String>
	{
		'sendMailRequests' => JSON.serialize(new List<API_SendMail.DTO_SendMailRequest>
		{
			new API_SendMail.DTO_SendMailRequest()
		})
	}
);

// Execute synchronously
List<API_Base> handlers = API_Dispatcher.execute(
	new List<ApiCall__c>{ queueItem }
);

API_SendMail handler = (API_SendMail)handlers[0];

if(handler.result.isSuccess)
{
	// Process success - e.g., update UI, return result to caller
}

Building Inbound APIs

An inbound API is an endpoint you expose so that an outside system can call into Salesforce over HTTP. This is the receiving direction.

IMPORTANT: The framework splits each inbound endpoint into two classes: one that handles the URL and HTTP verb, and one that holds your actual logic. Keeping those apart means a change to the URL never touches your logic, and the same logic can be reused at more than one endpoint.

Architecture Overview

Each inbound REST endpoint is made of two classes, each with one job:

  1. REST Routing Class (REST_* prefix): carries the @RestResource annotation and decides which HTTP method (GET, POST, and so on) goes where. It does no business logic.
  2. API Implementation Class (API_* prefix): holds your business logic and extends API_Inbound.

Why splitting the two helps you:

  • Separation of Concerns: routing stays separate from logic, so each class is shorter and easier to read.
  • Multiple Operations: one REST endpoint can answer several HTTP methods, so related operations live at one URL.
  • Reusability: the same API implementation can be exposed at more than one endpoint, so you write the logic once.
  • Maintainability: changing the URL never touches your logic, so a URL rename is a low-risk edit.
  • Flexibility: each HTTP method can hand off to a different API implementation, so one URL can do several things.

Minimal Inbound Example

Step 1: Create the REST Routing Class

apex
/**
 * @description REST Endpoint wrapper class for the inbound echo test service.
 * This class serves as a RESTFUL endpoint that listens for POST requests to the `/echo/*` URL mapping.
 *
 * @see API_Echo
 * @see API_Echo_TEST
 *
 * @author your.name@company.com
 *
 * @group Web Services
 *
 * @date January 2025
 */
@RestResource(UrlMapping='/echo/*')
global inherited sharing class REST_Echo
{
	/**
	 * @description Handles the POST method for the echo service (inbound).
	 * Delegates processing to the API_Echo implementation class via the framework factory.
	 *
	 * @example
	 * POST /services/apexrest/echo
	 * Body: {"message": "Hello, Echo!"}
	 */
	@HttpPost
	global static void echo()
	{
		API_Dispatcher.processInboundService(API_Echo.class.getName());
	}
}

Key Points for REST Routing Classes:

  • Annotate the class with @RestResource and set its UrlMapping, which is the URL the endpoint answers on.
  • Each method must be global static and carry the matching HTTP method annotation (@HttpPost, @HttpGet, and so on).
  • Hand off to API_Dispatcher.processInboundService(), passing the name of the API class that holds the logic.
  • Use the global access modifier (@RestResource requires it).
  • Keep this class to routing only. No business logic belongs here.

Step 2: Create the API Implementation Class

apex
/**
 * @description An example Inbound API call that will echo exactly what was sent.
 * Contains the business logic for processing echo requests.
 *
 * @see REST_Echo
 * @see API_Echo_TEST
 *
 * @author your.name@company.com
 *
 * @group Web Services
 *
 * @date January 2025
 */
public inherited sharing class API_Echo extends API_Inbound
{
	@TestVisible
	private static final String ERROR_NO_BODY_RECEIVED = 'No request body received';

	/**
	 * @description Validates that a request body was provided.
	 *
	 * @return List of validation error messages
	 */
	public override List<String> getValidationErrors()
	{
		List<String> errors = new List<String>();

		if(String.isEmpty(requestBody))
		{
			errors.add(ERROR_NO_BODY_RECEIVED);
		}

		return errors;
	}

	/**
	 * @description Sets the response to echo back the request body.
	 */
	public override void updateCallResult()
	{
		super.updateCallResult();
		result.responseBody = requestBody;  // Echo back exactly what was sent
	}
}

Key Points for API Implementation Classes:

  • Extend API_Inbound (or your own base class that extends it).
  • Do NOT add the @RestResource annotation here. That belongs on the URL routing class.
  • Use the public access modifier, not global.
  • Override the virtual methods to plug in your behaviour.
  • This is where all the business logic and data processing lives.

Advanced Example: Multiple Operations on One URL

A single URL often needs to do more than one thing: read a record and update it, for instance. This example shows how one REST routing class answers multiple HTTP methods, with each method handing off to a different API implementation class. So you keep one tidy endpoint instead of a separate URL per operation.

Step 1: Create the REST Routing Class

apex
/**
 * @description REST API endpoint for managing person profile details.
 * Supports GET (retrieve) and PATCH (update) operations on the same URL endpoint.
 *
 * @see API_PersonRetrieve
 * @see API_PersonUpdate
 *
 * @author your.name@company.com
 *
 * @group Person APIs
 *
 * @date January 2025
 */
@RestResource(UrlMapping='/v1/identity/persons/*')
global inherited sharing class REST_Person
{
	/**
	 * @description Handles HTTP GET requests to retrieve person profile details.
	 * Extracts identityId from URL path and delegates to API_PersonRetrieve.
	 *
	 * @example
	 * GET /services/apexrest/v1/identity/persons/f47ac10b-58cc-4372-a567-0e02b2c3d479
	 *
	 * Response (200 OK):
	 * {
	 *   "identityId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
	 *   "firstName": "John",
	 *   "lastName": "Doe",
	 *   "email": "john.doe@company.com"
	 * }
	 */
	@HttpGet
	global static void getPerson()
	{
		API_Dispatcher.processInboundService(API_PersonRetrieve.class.getName());
	}

	/**
	 * @description Handles HTTP PATCH requests to update person profile details.
	 * Delegates to API_PersonUpdate for processing.
	 *
	 * @example
	 * PATCH /services/apexrest/v1/identity/persons/
	 * Body:
	 * {
	 *   "identityId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
	 *   "firstName": "John",
	 *   "lastName": "Smith"
	 * }
	 */
	@HttpPatch
	global static void editPerson()
	{
		API_Dispatcher.processInboundService(API_PersonUpdate.class.getName());
	}
}

Step 2: Create API Implementation for GET Operation

apex
/**
 * @description Processes person profile retrieval.
 * Retrieves person details based on identityId provided in URL path.
 *
 * @see REST_Person
 * @see API_PersonRetrieve_TEST
 *
 * @author your.name@company.com
 *
 * @group Person APIs
 *
 * @date January 2025
 */
public inherited sharing class API_PersonRetrieve extends API_Inbound
{
	/**
	 * @description The Contact record found based on the provided identity ID.
	 */
	protected Contact foundPerson;

	/**
	 * @description Initializes the response DTO.
	 */
	public override void configure()
	{
		super.configure();
		responsePayload = new DTO_Response();
	}

	/**
	 * @description Validates the identity ID and retrieves person data.
	 *
	 * @return List of validation error messages
	 */
	public override List<String> getValidationErrors()
	{
		List<String> errors = new List<String>();
		String identityId = request.requestURI.substringAfterLast('/');

		if(String.isBlank(identityId))
		{
			errors.add('Identity Id not provided');
		}
		else
		{
			foundPerson = SEL_Contact.findByExternalReference(identityId);

			if(foundPerson == null)
			{
				errors.add('No person found with the provided details');
			}
			else
			{
				DTO_Response dtoResponse = (DTO_Response)responsePayload;
				dtoResponse.identityId = foundPerson.CMN_ExternalReference__c;
				dtoResponse.birthdate = foundPerson.Birthdate;
				dtoResponse.email = foundPerson.Email;
				dtoResponse.firstName = foundPerson.FirstName;
				dtoResponse.lastName = foundPerson.LastName;
				dtoResponse.mobile = foundPerson.MobilePhone;
			}
		}

		return errors;
	}

	/**
	 * @description Data Transfer Object for person response.
	 */
	@JsonAccess(Serializable='always')
	public class DTO_Response extends DTO_JsonBase
	{
		public String identityId;
		public Date birthdate;
		public String email;
		public String firstName;
		public String lastName;
		public String mobile;
	}
}

Step 3: Create API Implementation for PATCH Operation

apex
/**
 * @description Processes person profile updates.
 * Updates person details and commits changes to database.
 *
 * @see REST_Person
 * @see API_PersonUpdate_TEST
 *
 * @author your.name@company.com
 *
 * @group Person APIs
 *
 * @date January 2025
 */
public inherited sharing class API_PersonUpdate extends API_Inbound
{
	/**
	 * @description The Contact record to be updated.
	 */
	protected Contact upsertContact;

	/**
	 * @description Initializes the request and response DTOs.
	 */
	public override void configure()
	{
		super.configure();
		requestPayload = new DTO_Request();
		responsePayload = new DTO_Response();
	}

	/**
	 * @description Validates the request and finds the Contact to update.
	 *
	 * @return List of validation error messages
	 */
	public override List<String> getValidationErrors()
	{
		List<String> errors = new List<String>();
		DTO_Request dtoRequest = (DTO_Request)requestPayload;

		if(String.isBlank(dtoRequest.identityId))
		{
			errors.add('Identity Id not provided');
		}
		else
		{
			upsertContact = SEL_Contact.findByExternalReference(dtoRequest.identityId);

			if(upsertContact == null)
			{
				errors.add('No Person found for the identityId provided');
			}
		}

		return errors;
	}

	/**
	 * @description Updates the Contact record with data from the request.
	 * API_Base inherits DML methods from DML_Transaction, so doUpdate is called directly.
	 */
	public override void onSuccess()
	{
		super.onSuccess();

		DTO_Request dtoRequest = (DTO_Request)requestPayload;

		upsertContact.FirstName = dtoRequest.firstName;
		upsertContact.LastName = dtoRequest.lastName;
		upsertContact.Email = dtoRequest.email;

		doUpdate(upsertContact);
	}

	/**
	 * @description Updates the response DTO with committed data.
	 * This method is called after database commit completes.
	 */
	public override void updateResponseDTO()
	{
		super.updateResponseDTO();

		DTO_Response dtoResponse = (DTO_Response)responsePayload;
		dtoResponse.message = 'Person updated successfully';
		dtoResponse.identityId = upsertContact.CMN_ExternalReference__c;
	}

	/**
	 * @description Data Transfer Object for person update request.
	 */
	@JsonAccess(Deserializable='always')
	public class DTO_Request extends DTO_JsonBase
	{
		public String identityId;
		public String firstName;
		public String lastName;
		public String email;
	}

	/**
	 * @description Data Transfer Object for update response.
	 */
	@JsonAccess(Serializable='always')
	public class DTO_Response extends DTO_JsonBase
	{
		public String message;
		public String identityId;
	}
}

Multi-method routing

When one URL responds to several HTTP verbs (the REST_Person example above handles both GET and PATCH), each verb routes to its own inbound handler class. Salesforce picks the right method for the incoming verb based on the @HttpGet/@HttpPost/@HttpPut/@HttpPatch/@HttpDelete annotation on each method of the REST_* routing class. Each annotated method then hands off to a different API_* handler by passing that handler's class name to processInboundService():

Custom Metadata RecordClassName__cRouting Method
ApiSetting.GetPersonAPI_PersonRetrieve@HttpGet getPerson()
ApiSetting.EditPersonAPI_PersonUpdate@HttpPatch editPerson()

You create one ApiSetting__mdt record per handler, and its ClassName__c field ties that configuration record to the handler class. When a request arrives, Salesforce selects the routing method that matches the verb, and that method calls API_Dispatcher.processInboundService(API_PersonRetrieve.class.getName()) (or whichever handler applies). The dispatcher then runs the framework's pipeline for you: pre-validation, authorisation, and the onSuccess() step. So your handler only fills in what is unique to the request.

Naming Conventions for Inbound APIs

REST Routing Classes:

  • Pattern: REST_*
  • Examples:
    • REST_Echo - Simple routing class
    • REST_Person - Resource routing class
    • REST_Invoices - Domain-specific routing class

API Implementation Classes:

  • Pattern: API_* or [Domain]_API_[Operation][Resource]
  • Examples:
    • API_Echo - Simple implementation
    • API_PersonRetrieve - Domain + Operation + Resource
    • API_PersonUpdate - Domain + Operation + Resource
    • DOMAIN_API_CreateCase - Domain + Operation + Resource

Intra-Org API Calls

What is it for?

Sometimes the system you need to call is your own org. You might want to run several record changes in one Composite API request, read schema through the Tooling API, or hit a custom REST endpoint you built. Doing this by hand means looking up your org's URL, fetching a session ID, and formatting the authorization header every time.

API_CallCurrentOrg is a base class that handles all of that for you. You extend it to make API calls back into the same Salesforce org, and it works out the org URL and authenticates as the current user automatically, so you write only the call itself.

What you get

  • No hardcoded org URL. It works out your org's base URL at runtime via URL.getOrgDomainURL(), so you do not hardcode it or set up a Named Credential (a stored, reusable connection definition).
  • Runs as the current user. It authenticates with the current user's session ID, so the call runs with exactly the permissions that user has.
  • The auth header is built for you. It puts the Authorization header in the correct Bearer token format.
  • Everything an outbound call gets. You still get the logging, retries, mocking, and error handling that every outbound call gets.

When to Use

Use API_CallCurrentOrg when you need to:

  • Call Salesforce REST APIs from within the same org
  • Use the Composite API to perform multiple operations in a single call
  • Access Tooling API endpoints
  • Call custom REST endpoints within your org with user context

Basic Usage

apex
/**
 * @description Calls Salesforce Composite API to perform multiple DML operations in a single call.
 * Demonstrates intra-org API usage with automatic session management.
 *
 * @see API_CompositeExample_TEST
 *
 * @author your.name@company.com
 *
 * @group Web Services
 *
 * @date January 2025
 */
public inherited sharing class API_CompositeExample extends API_CallCurrentOrg
{
	/**
	 * @description Initializes the API handler with request and response DTOs.
	 */
	public override void configure()
	{
		super.configure();
		requestPayload = new DTO_Request();
		responsePayload = new DTO_Response();
		defaultMockBody = '{"compositeResponse": [{"httpStatusCode": 201, "referenceId": "ref1", "body": {"id": "001000000000001"}}]}';
		requiresTriggeringRecord = false;
	}

	/**
	 * @description Data Transfer Object for Composite API request.
	 */
	@JsonAccess(Serializable='always')
	public class DTO_Request extends DTO_JsonBase
	{
		public Boolean allOrNone = true;
		public List<CompositeSubRequest> compositeRequest;
	}

	/**
	 * @description Data Transfer Object for individual composite sub-request.
	 */
	@JsonAccess(Serializable='always')
	public class CompositeSubRequest
	{
		public String method;
		public String url;
		public String referenceId;
		public Map<String, Object> body;
	}

	/**
	 * @description Data Transfer Object for Composite API response.
	 */
	@JsonAccess(Deserializable='always')
	public class DTO_Response extends DTO_JsonBase
	{
		public List<CompositeSubResponse> compositeResponse;

		/**
		 * @description Returns the type of this DTO for proper deserialization.
		 *
		 * @return The DTO_Response class type
		 */
		public override Type getObjectType()
		{
			return DTO_Response.class;
		}
	}

	/**
	 * @description Data Transfer Object for individual composite sub-response.
	 */
	@JsonAccess(Deserializable='always')
	public class CompositeSubResponse
	{
		public Integer httpStatusCode;
		public String referenceId;
		public Map<String, Object> body;
	}

}

How It Works

You do not need to write any of this, but here is how the convenience is delivered: API_CallCurrentOrg overrides three methods so the URL is resolved at runtime and the call authenticates as the current user.

apex
// 1. configure() — sets baseUrl to the current org domain
global protected virtual override void configure()
{
	super.configure();
	baseUrl = Url.getOrgDomainUrl().toExternalForm();
}

// 2. getAuthorisationToken() — returns Bearer token with session ID
global protected virtual override String getAuthorisationToken()
{
	return HTTP_AUTH_TOKEN_BEARER + UTIL_System.getApiEnabledSessionId();
}

// 3. getWebServiceEndPoint() — combines org domain URL with endpoint path
global protected virtual override String getWebServiceEndPoint()
{
	String orgBaseUrl = Url.getOrgDomainUrl().toExternalForm();
	String endpointPath = setting?.EndpointPath__c;

	return orgBaseUrl + (String.isNotBlank(endpointPath) ? endpointPath : UTIL_String.EMPTY);
}

How do I configure this?

In your ApiSetting__mdt record:

FieldValue
ClassName__cAPI_CompositeExample
EndpointPath__c/services/data/v67.0/composite
ApiCredential__c(Leave blank - session ID handles auth)

Note: You can leave the credential blank for intra-org calls. The session ID handles authentication, and the org base URL is resolved at runtime via URL.getOrgDomainURL(), so there is no Named Credential (a stored, reusable connection definition) to set up.

Subscriber Setup: Salesforce treats a call back into your own org as an outbound callout, so it needs to be allowed first. Add a Remote Site Setting for your My Domain URL (Setup > Remote Site Settings > New). Without this, callouts to the current org will fail with a System.CalloutException.

Only works from a live user session. API_CallCurrentOrg signs each call with the current user's session ID, and that session ID exists only while a real user session is active. Asynchronous Apex has none: a scheduled job, a queueable, a batch, a future method, and therefore any step running inside an async chain all run without a user session. The call then goes out with no usable session and your org rejects it, every time, with an HTTP 401 (Unauthorised). To call back into your own org from a background job, authenticate with a stored connection instead: extend API_Outbound with a Named Credential (a stored, reusable connection definition) or an ApiCredential__mdt record, rather than API_CallCurrentOrg.

Do not reach for retry to paper over that 401 inside a chain. The 401 comes back as an ordinary HTTP response, so the step records a failed result with no thrown exception attached. withRetry cannot heal a session that was never going to exist: the call is deterministic, so every attempt returns the same 401, and because there is no exception object, an exception-filtered retry has nothing to match and cannot single the 401 out to skip it. Each attempt simply spends another slice of your retry budget on the same failure. See Retrying a failed step and ApiStep: Web Service Integration in the Async Processing Guide.


Virtual Methods Reference

The base classes do most of the work for you, but they leave specific steps open so you can plug in the parts unique to your integration. Each of those steps is a method you can override. This section walks through every one: what it is for, what it does if you leave it alone, when you would want to change it, and a worked example.

Common Base Methods (API_Base)

These methods are available in both inbound and outbound APIs.


getValidationErrors()

Purpose: Checks the request before any work starts and stops it cleanly if something is wrong, so a bad request fails fast with a clear message instead of part-way through.

Signature: global protected virtual List<String> getValidationErrors()

Default Behavior: Returns an empty list (no validation errors). Framework-level checks (disabled APIs, feature flags, runtime switches) are handled internally by performValidation(), so you do not need to call super.

When to Override:

  • Validating required parameters
  • Checking user permissions
  • Validating request payload structure
  • Querying and validating related data

Return Value: List of error message strings. An empty list means validation passed; non-empty aborts the request.

Example:

apex
/**
 * @description Validates required parameters before proceeding with API call.
 *
 * @return List of validation error messages, empty if validation passed
 */
public override List<String> getValidationErrors()
{
	List<String> errors = new List<String>();

	String userId = inputs.get('userId');
	if(String.isBlank(userId))
	{
		errors.add('userId parameter is required');
	}

	return errors;
}

getBody()

Purpose: Builds the body that gets sent in the HTTP request. Override it when the framework's default formatting does not match what the other system expects.

Signature: global protected virtual String getBody()

Default Behavior: Serializes requestPayload to JSON or XML.

When to Override:

  • Custom body formatting (multi-part form data, binary content)
  • Non-standard serialization
  • Custom XML structure

Return Value: String containing the request body

Note: Rarely needs to be overridden.


getServiceName()

Purpose: Tells the framework which name to use when it logs the call and looks up its configuration, so the right settings and log entries line up with this integration.

Signature: protected virtual String getServiceName()

Default Behavior: Uses the class name.

When to Override: Only if the configuration name differs from the class name (rare).

Return Value: String containing the service name


onSuccess()

Purpose: This is where you say what records should change once the callout has come back successfully, for example creating an Account from the response. You register the changes here and the framework saves them for you, after the callout, so you never trip the "callout with uncommitted work pending" error.

Signature: global protected virtual void onSuccess()

Default Behavior: No operations registered (empty method).

When to Override:

  • Creating records from API responses
  • Updating records based on API results
  • Logging activity (Tasks, Events, etc.)
  • Managing relationships

Important: API_Base extends DML_Transaction, so the database methods (doInsert(), doUpdate(), doDelete(), doUpsert()) are already available to you. Call them inside onSuccess() to register the records you want changed: the framework saves them as one batch after all callouts finish. Never run a plain DML statement (insert/update/delete) directly, because that would save outside the framework's ordering and could break the callout-before-save rule.

Example:

apex
/**
 * @description Creates Account and updates Contact based on API response.
 */
public override void onSuccess()
{
	super.onSuccess();

	DTO_Response response = (DTO_Response)responsePayload;

	// Create Account from API response
	Account newAccount = new Account
	(
		Name = response.companyName,
		Industry = response.industry,
		ExternalId__c = response.externalId
	);
	doInsert(newAccount);

	// Update existing Contact
	Contact existingContact = queryContact();
	existingContact.Email = response.email;
	doUpdate(existingContact);
}

setUnitOfWorksObjectTypes()

Purpose: An early setup hook the framework calls while it configures your handler, in case you need to prepare something before processing starts. Most handlers leave it alone.

Signature: protected virtual void setUnitOfWorksObjectTypes()

Default Behavior: Empty method (no-op).

When to Override: Rarely needed. You register record changes directly through the inherited doInsert(), doUpdate(), doDelete(), doUpsert() methods in onSuccess(), and the framework works out the right save order (parents before children) on its own during commitWork().


Outbound-Specific Methods (API_Outbound)

These methods are only available in outbound APIs.


getAuthorisationToken()

Purpose: Supplies the authorization value the call needs to prove who it is. Override it when a stored Named Credential is not enough and you have to build the token yourself.

Signature: global protected virtual String getAuthorisationToken()

Default Behavior: Returns empty string (authentication handled by Named Credential).

When to Override:

  • Custom authentication (OAuth, API keys, session-based)
  • Dynamic token management
  • Custom auth schemes

Return Value: Authorization header value (e.g., Bearer {token})

Example:

apex
/**
 * @description Provides OAuth token for API authentication.
 *
 * @return Bearer token for authorization
 */
public override String getAuthorisationToken()
{
	// Custom OAuth token logic
	return 'Bearer ' + getOAuthToken();
}

getHttpMethod()

Purpose: Sets which HTTP verb the call uses. The framework assumes POST, so override this when your endpoint expects a different verb.

Signature: global protected override virtual HttpMethod getHttpMethod()

Default Behavior: Returns HttpMethod.POST.

When to Override: When using GET, PUT, PATCH, or DELETE methods.

Return Value: HttpMethod enum value (HttpMethod.GET, HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH, HttpMethod.DELETE)

Example:

apex
/**
 * @description Specifies GET method for this API.
 *
 * @return GET HTTP method
 */
public override HttpMethod getHttpMethod()
{
	return HttpMethod.GET;
}

getQueryParameters()

Purpose: Adds the ?key=value parameters that get tacked onto the end of the URL, for cases where the other system reads options from the query string.

Signature: global protected virtual String getQueryParameters()

Default Behavior: Returns empty string.

When to Override: When adding URL query parameters.

Return Value: Query string (without leading ?)

Example:

apex
/**
 * @description Builds query parameters for the API call.
 *
 * @return Query string with format and version
 */
public override String getQueryParameters()
{
	List<String> params = new List<String>();
	params.add('format=json');
	params.add('version=2');
	return String.join(params, '&');
}

getRequiredInputs()

Purpose: Lists the parameters the call cannot run without, so the framework rejects an incomplete request up front rather than failing later with a confusing error.

Signature: global protected virtual Set<String> getRequiredInputs()

Default Behavior: Returns empty set.

When to Override: When your API requires specific parameters.

Return Value: Set of required parameter names

Example:

apex
/**
 * @description Defines required parameters for validation.
 *
 * @return Set of required parameter names
 */
public override Set<String> getRequiredInputs()
{
	Set<String> required = super.getRequiredInputs();
	required.add('userId');
	required.add('accountId');
	return required;
}

getResponseBody()

Purpose: Pulls out the part of the HTTP response you actually want to read. Override it when the useful data is buried inside a wrapper or arrives encoded.

Signature: global protected virtual String getResponseBody()

Default Behavior: Returns response.getBody().

When to Override:

  • Unwrapping nested response structure
  • Decoding encoded content

Return Value: Response body string


getResponseReplacementTokens()

Purpose: Rewrites awkward bits of the response JSON before Apex tries to read it, so keys that Apex cannot handle (reserved words, hyphens) become valid field names and the response parses cleanly.

Signature: global protected virtual void getResponseReplacementTokens(List<String> searchTokens, List<String> replaceTokens)

Default Behavior: No replacements.

When to Override: When response contains:

  • Reserved Apex keywords (class, type)
  • Hyphens in JSON keys (user-iduserId)
  • Special characters incompatible with Apex

Parameters:

  • searchTokens - List to populate with strings to find
  • replaceTokens - List to populate with replacement strings

Example:

apex
/**
 * @description Replaces reserved keywords and hyphens in response JSON.
 *
 * @param searchTokens List to populate with tokens to find
 * @param replaceTokens List to populate with replacement tokens
 */
public override void getResponseReplacementTokens(List<String> searchTokens, List<String> replaceTokens)
{
	super.getResponseReplacementTokens(searchTokens, replaceTokens);
	searchTokens.add('"user-id"');
	replaceTokens.add('"userId"');
	searchTokens.add('"class"');
	replaceTokens.add('"className"');
}

getTimeout()

Purpose: Sets how long the call waits for a reply before giving up. Lower it when a slow endpoint should fail quickly rather than tie up the transaction.

Signature: global protected virtual Integer getTimeout()

Default Behavior: Returns 120000 milliseconds (2 minutes).

When to Override: When a different timeout is needed.

Return Value: Timeout in milliseconds (max 120000)

Example:

apex
/**
 * @description Sets timeout to 1 minute.
 *
 * @return Timeout in milliseconds
 */
public override Integer getTimeout()
{
	return 60000; // 1 minute
}

getWebServiceEndPoint()

Purpose: Decides the exact URL the call goes to. Override it when the address changes per request, for example when a record ID has to be slotted into the path.

Signature: global protected virtual String getWebServiceEndPoint()

Default Behavior: Combines Named Credential URL with path from ApiSetting__mdt.EndpointPath__c.

When to Override:

  • Dynamic URL construction
  • URL parameter substitution
  • Path variables
  • Custom routing logic

Return Value: Complete endpoint URL

Example:

apex
/**
 * @description Builds endpoint URL with userId path parameter.
 *
 * @return Complete endpoint URL
 */
public override String getWebServiceEndPoint()
{
	String userId = inputs.get('userId');
	String basePath = super.getWebServiceEndPoint();
	return String.format(basePath, new List<String>{ userId });
}

prepareRequest()

Purpose: Fills in the data you want to send before the call goes out. Override it when the request needs more than the default fields, such as data from a related query or a computed value.

Signature: global protected virtual void prepareRequest()

Default Behavior: Calls requestPayload.populate(recordId, inputs).

When to Override:

  • Complex data transformations
  • Querying related data
  • Combining multiple data sources
  • Parameter processing

Example:

apex
/**
 * @description Populates request with timestamp and user info.
 */
public override void prepareRequest()
{
	super.prepareRequest();

	DTO_Request request = (DTO_Request)requestPayload;
	request.timestamp = Datetime.now();
	request.username = UserInfo.getUserName();
}

setHeaders()

Purpose: Adds the HTTP headers the request carries. Override it to send extra headers the other system expects, such as an API key or a tracking ID.

Signature: global protected virtual void setHeaders()

Default Behavior: Sets Content-Type and Accept headers to JSON.

When to Override:

  • Custom headers (API keys, tokens, correlation IDs)
  • Different content types
  • Additional authentication headers

Example:

apex
/**
 * @description Adds custom headers for API call.
 */
public override void setHeaders()
{
	super.setHeaders();
	request.setHeader('X-API-Key', getClientId());
	request.setHeader('X-Correlation-ID', UTIL_Random.randomUUID());
	request.setHeader('X-Client-Version', '2.0');
}

requiresTriggeringRecord

Purpose: Says whether the call must be tied to a specific Salesforce record. Leave it on for record-driven integrations; turn it off when the call stands on its own, so the framework does not insist on a record ID it will never use.

Signature: global protected Boolean requiresTriggeringRecord

Default Behavior: true.

When to Set to false:

  • API doesn't need a specific record context (GET requests)
  • Uses only parameters
  • Operates on multiple records
  • Utility/system operation

Example:

apex
public override void configure()
{
	super.configure();
	requestPayload = new DTO_Request();
	responsePayload = new DTO_Response();
	requiresTriggeringRecord = false;
}

Inbound-Specific Methods (API_Inbound)

These methods are only available in inbound APIs.


processRequest()

Purpose: This is the heart of an inbound endpoint: where you read what the caller sent, look up whatever Salesforce data you need, and run your business logic. The framework hands you the request here and lets you decide what to do with it.

Signature: global protected virtual void processRequest()

Default Behavior: No-op (does nothing).

When to Override:

  • Parse request body
  • Query Salesforce data
  • Perform business logic
  • Validate complex rules

Example:

apex
/**
 * @description Processes incoming request and queries related data.
 */
public override void processRequest()
{
	super.processRequest();

	// Parse request
	DTO_Request request = (DTO_Request)requestPayload;

	// Query related data
	Account account = (Account)QRY_Builder.selectFrom(Account.SObjectType)
		.fields(new List<SObjectField>{Account.Id, Account.Name})
		.condition(Account.ExternalId__c).equals(request.accountId)
		.getFirst();

	// Perform business logic
	if(account != null)
	{
		// Process account
	}
}

updateCallResult()

Purpose: Shapes the data that goes into the call result the caller receives. Override it when you want the response formatted a particular way, such as pretty-printed JSON.

Signature: global protected virtual void updateCallResult()

Default Behavior: Sets result.responseBody from responsePayload.

When to Override: When custom response formatting is needed.

Example:

apex
/**
 * @description Formats response as pretty-printed JSON.
 */
public override void updateCallResult()
{
	super.updateCallResult();

	DTO_Response response = (DTO_Response)responsePayload;
	result.responseBody = JSON.serializePretty(response, true);
}

updateResponseDTO()

Purpose: Lets you add information to the reply that only exists after records are saved, such as the new record's ID. The framework runs this once the save has finished, so the values are real.

Signature: global protected virtual void updateResponseDTO()

Default Behavior: No-op (does nothing).

When to Override:

  • Include committed record IDs in response
  • Add calculated fields from committed data
  • Include related data from database

Example:

apex
/**
 * @description Updates response with committed Account ID.
 */
public override void updateResponseDTO()
{
	super.updateResponseDTO();

	DTO_Response response = (DTO_Response)responsePayload;
	response.accountId = committedAccount.Id;
	response.timestamp = Datetime.now();
}

writeResponse()

Purpose: Sends the final HTTP response back to whoever called your endpoint. Override it when you need to control the exact output: a custom format, a different content type, an error shape, or extra response headers.

Signature: global protected virtual void writeResponse()

Default Behavior: Serializes responsePayload to JSON or writes errors.

When to Override:

  • Custom response formatting
  • Different content types
  • Custom error structures
  • Response headers

Example:

apex
/**
 * @description Writes response with custom headers.
 */
public override void writeResponse()
{
	super.writeResponse();

	// Add custom response headers
	response.setHeader('X-API-Version', '2.0');
	response.setHeader('X-Request-ID', result.Id);
}

What else can it do?

Automatic Retries

The retry strategies themselves (backoff shapes, jitter, and exception filters) are documented in depth in the Resilience - Guide. This section covers how the web services framework applies retry to outbound calls via ApiSetting__mdt.

When an outbound call fails because the other system was briefly unavailable, you don't want the user's action to fail with it. The framework can retry the call for you on a schedule, so a temporary outage recovers on its own. You turn this on by configuring ApiSetting__mdt; no extra code is needed.

How It Works:

When an API call fails, the framework:

  1. Checks if MaxRetryCount__c is configured and not exceeded
  2. Calculates the next retry time using linear backoff (default strategy):
    • Each retry uses RetryBackoffSeconds__c seconds as the delay
    • Example: With RetryBackoffSeconds__c = 30, all retries wait 30 seconds
  3. Sets NextRetry__c datetime field
  4. Sets Status__c to 'Retry'
  5. Increments Retries__c counter

Configuration:

In ApiSetting__mdt:

  • MaxRetryCount__c: Maximum number of retry attempts (e.g., 3)
  • RetryBackoffSeconds__c: Backoff in seconds between retries (e.g., 30)

Example Retry Schedule (with RetryBackoffSeconds__c = 30):

  • Original call fails at 10:00:00
  • First retry scheduled for 10:00:30 (30 seconds later)
  • If first retry fails, second retry scheduled for 10:01:00 (30 seconds later)
  • If second retry fails, third retry scheduled for 10:01:30 (30 seconds later)

Automatic Retry Processing:

The framework ships with a built-in record-triggered flow (RetryOutboundApiCall) that automatically processes retries. When an API call fails and the framework sets Status__c = 'Retry' with a NextRetry__c datetime, the flow uses a time-based scheduled path to wait until the scheduled time, then invokes FLOW_CallApiAsync to re-execute the callout and sets Status__c to 'Retrying' to prevent re-triggering.

No custom scheduled jobs or platform events are needed. Retry processing is fully automated out of the box.


Custom Retry Strategies

The fixed-wait retry above is fine for most cases, but a rate-limited service often needs the waits to grow between attempts (and to vary a little so callers don't all retry at the same instant). You can give a handler its own retry behaviour for cases like that. By default, API_Outbound retries with a linear backoff built from RetryBackoffSeconds__c and MaxRetryCount__c. To use a different strategy on a handler, override createRetryStrategy() to return any UTIL_Retry.Strategy:

apex
public inherited sharing class API_SendGridEmail extends API_Outbound
{
	global override UTIL_Retry.Strategy createRetryStrategy()
	{
		// Exponential backoff with jitter for a rate-limited API
		return UTIL_Retry.exponential()
			.withBaseBackoff(10)
			.withMaximumBackoff(300)
			.withMaxRetries(5)
			.withJitter(true);
	}
}

The full retry API is documented in the Resilience - Guide: exponential vs linear backoff, jitter, exception allowlists and denylists, and writing a custom UTIL_Retry.Strategy (for example, HTTP-status-aware backoff).


Circuit Breaker Pattern

When an external system keeps failing, there is no point hammering it with more requests, since every one just waits for a timeout and slows your users down. The framework guards against this with a circuit breaker: after a system fails repeatedly, the framework stops calling it for a short cool-off period (so later calls fail fast instead of piling up timeouts), then quietly tries again to see if it has recovered. This protection is applied automatically to every outbound API call.

The breaker has three states it moves between on its own: normal operation (CLOSED), tripped and refusing calls during the cool-off (OPEN), and cautiously testing whether the system is back (HALF_OPEN).

The circuit breaker concept is covered in depth in the Resilience - Guide: the three states and their transitions, the execute() helpers, metrics, and Platform Cache persistence. This section covers how the web services framework wires it onto outbound calls via ApiSetting__mdt.


How It Works in the Framework

Turning this on means a failing service can no longer drag down every other call, since the framework starts rejecting calls to it quickly once it sees a run of failures.

Automatic Activation: Every outbound API automatically gets circuit breaker protection when you configure ApiSetting__mdt.

Configuration via ApiSetting__mdt:

FieldDescriptionExample
CircuitBreakerFailureThreshold__cConsecutive failures before opening circuit5
CircuitBreakerTimeout__cSeconds to keep circuit open before testing recovery60
CircuitBreakerSuccessThreshold__cConsecutive successes in half-open state to close circuit2

Example Configuration:

text
DeveloperName: SendGrid_Email_API
ClassName__c: API_SendGridEmail
CircuitBreakerFailureThreshold__c: 5
CircuitBreakerTimeout__c: 60
CircuitBreakerSuccessThreshold__c: 2

Behavior:

  1. After 5 consecutive failures, circuit opens
  2. For the next 60 seconds, all calls immediately fail with CircuitOpenException
  3. After 60 seconds, circuit enters half-open state
  4. Test requests are allowed to verify recovery
  5. After 2 consecutive successes, circuit closes (normal operation)
  6. If a test fails → circuit re-opens for another 60 seconds

State Transitions Example

Scenario: External email API experiencing issues

text
Time    Event                           Circuit State    Action
------  ------------------------------  ---------------  ----------------------------------
10:00   Call fails (timeout)            CLOSED           Failure count: 1
10:01   Call fails (503 error)          CLOSED           Failure count: 2
10:02   Call fails (timeout)            CLOSED           Failure count: 3
10:03   Call fails (503 error)          CLOSED           Failure count: 4
10:04   Call fails (timeout)            CLOSED → OPEN    Failure count: 5 (threshold reached)
10:05   Call attempted                  OPEN             Immediately rejected (CircuitOpenException)
10:06   Call attempted                  OPEN             Immediately rejected
10:07   Call attempted                  OPEN             Immediately rejected
...
11:04   Call attempted                  OPEN → HALF_OPEN Timeout expired, allow test requests
11:04   Test call 1 succeeds            HALF_OPEN        Test 1/2 passed
11:05   Test call 2 succeeds            HALF_OPEN → CLOSED  Test 2/2 passed, circuit closes
11:06   Call succeeds                   CLOSED           Normal operation resumed

Circuit State Persistence

Because the breaker is shared org-wide rather than reset for each request, one user's run of failures protects everyone else right away, instead of each transaction having to discover the outage on its own.

Circuit state is persisted in Platform Cache (org-wide), ensuring:

  • State maintained across transaction boundaries
  • All users see the same circuit state
  • Protection applies org-wide (not per-user)

Example:

apex
// User A makes call at 10:04, circuit opens
API_SendGridEmail api = new API_SendGridEmail();
api.run(); // 5th failure, circuit opens

// User B makes call at 10:05 (different transaction, different user)
API_SendGridEmail api2 = new API_SendGridEmail();
api2.run(); // Immediately fails with CircuitOpenException (state loaded from cache)

Default Behavior (No Configuration)

You don't have to set anything up to get this protection: if you leave the fields blank, the framework falls back to safe starting values.

If circuit breaker fields are not configured in ApiSetting__mdt:

  • Default Threshold: 3 consecutive failures
  • Default Timeout: 60 seconds
  • Default Half-Open Requests: 1

So circuit-breaker protection is on even before you configure anything, using the values above.


Best Practices

The right settings depend on the service you're calling, so the guidance below helps you tune the breaker to match how critical each integration is and how fast it tends to recover.

1. Choose Appropriate Thresholds by Service Type

Service TypeThresholdTimeoutRationale
Critical Payment APIs2-3300s (5 min)Low tolerance, long recovery
Email/Notification5-1060s (1 min)Higher tolerance, quick retry
Internal Salesforce3-530sFast recovery expected
Rate-Limited APIs560sMatch rate limit window

2. Combine Circuit Breakers with Retry Strategies

Circuit breakers and retry strategies work together:

  • Retry Strategy: Handles transient failures (1-3 attempts for network blips)
  • Circuit Breaker: Handles sustained failures (service is down)
apex
public inherited sharing class API_SendGridEmail extends API_Outbound
{
	// Retry handles transient errors (network timeouts, 429 rate limits)
	global override UTIL_Retry.Strategy createRetryStrategy()
	{
		return UTIL_Retry.exponential()
			.withMaxRetries(3)
			.withBaseBackoff(10);
	}

	// Circuit breaker handles sustained failures (service outage)
	// Configuration loaded from ApiSetting__mdt automatically
}

Request Flow:

  1. Circuit breaker checks state (CLOSED = proceed, OPEN = reject immediately)
  2. If CLOSED, retry strategy attempts call (up to 3 times with backoff)
  3. If all retries fail, circuit breaker increments failure count
  4. When threshold reached, circuit opens

3. Monitor Circuit State

Check ApiCall__c for circuit-related failures:

soql
SELECT Id, ServiceName__c, Status__c, ErrorMessages__c, CreatedDate
FROM ApiCall__c
WHERE ErrorMessages__c LIKE '%Circuit breaker is OPEN%'
  AND CreatedDate = TODAY
ORDER BY CreatedDate DESC

4. Environment-Specific Configuration

Use different thresholds per environment:

Development/Sandbox (more tolerant for testing):

text
CircuitBreakerFailureThreshold__c: 10
CircuitBreakerTimeout__c: 30

Production (stricter for reliability):

text
CircuitBreakerFailureThreshold__c: 3
CircuitBreakerTimeout__c: 300

5. Handle Circuit Open Exceptions Gracefully

When circuit is open, the framework sets Status__c = 'Aborted' and logs the error. Implement fallback logic:

apex
// In your calling code
ApiCall__c queueItem = TST_Factory.newOutboundApiCall('API_SendGridEmail', emailId);
DML_Builder.newTransaction().doInsert(queueItem).execute();

// Check result after processing
queueItem = (ApiCall__c)QRY_Builder.selectFrom(ApiCall__c.SObjectType)
	.addFields(new List<SObjectField>{ApiCall__c.Status__c, ApiCall__c.ErrorMessages__c})
	.condition(ApiCall__c.Id).equals(queueItem.Id)
	.getFirst();

if(queueItem.Status__c == 'Aborted' && queueItem.ErrorMessages__c.contains('Circuit breaker is OPEN'))
{
	// Circuit is open - use alternative approach
	// Option 1: Queue for later retry
	// Option 2: Use backup email service
	// Option 3: Store in offline queue
}

Advanced: Custom Circuit Breaker Configuration

Circuit breaker behaviour is configured per service via ApiSetting__mdt. For standalone use outside the web services framework, use the UTIL_CircuitBreaker API directly:

apex
// Standalone circuit breaker with custom thresholds
UTIL_CircuitBreaker.Breaker breaker = UTIL_CircuitBreaker.monitor('MyExternalService')
	.withFailureThreshold(10)       // More tolerant
	.withTimeout(600)                // Longer recovery (10 minutes)
	.withHalfOpenMaxAttempts(5);     // More confident recovery test

breaker.execute(new MyProtectedAction());

When to use standalone circuit breakers:

  • Custom non-API callout scenarios
  • Programmatic circuit control based on runtime conditions
  • Protecting non-HTTP operations (e.g., platform event publishing)

Troubleshooting

When the breaker isn't behaving the way you expect, the symptom usually points to one setting to adjust, so here is how to read each one back to its cause.

Problem: Circuit opens too frequently

Solution: Increase CircuitBreakerFailureThreshold__c (e.g., from 3 to 5 or 10)

Problem: Circuit stays open too long

Solution: Decrease CircuitBreakerTimeout__c (e.g., from 300s to 60s)

Problem: Circuit closes prematurely after recovery

Solution: Increase CircuitBreakerSuccessThreshold__c (e.g., from 1 to 3) to require more successful tests

Problem: All calls showing "Circuit breaker is OPEN"

Cause: Service is genuinely down or experiencing issues

Solution:

  1. Check external service status
  2. Wait for timeout period to expire
  3. Circuit will automatically test recovery
  4. If needed, manually clear circuit state (see Utilities guide)

Data Masking

Every API call is logged, which is good for troubleshooting but risky if the request or response contained a password, a token, or a card number. Data masking redacts those sensitive values before the log row is saved, so secrets and personal data don't end up sitting in ApiCall__c and ApiIssue__c where anyone with read access could see them.

It uses the shared data masking framework. The payload fields (Request__c, Response__c, URL__c, RequestParameters__c, ErrorMessages__c) are redacted on the trigger dispatcher's before-insert / before-update pre-step, so the persisted row never holds the raw sensitive value.

Default coverage out of the box: MaskSecretKeys redacts JSON keys named password, token, apiKey, authorization, bearer, client_secret, private_key, access_token, refresh_token (and common variants). MaskPaymentCard redacts 13–19 digit sequences that pass the Luhn checksum (all major card brands; spaces or hyphens allowed). No admin work required for those.

Example: activate US-SSN masking on API payloads

Add a MaskingTarget__mdt wiring the shipped-but-inactive MaskSsn rule onto ApiCall__c. First flip MaskingRule.MaskSsn.IsActive__c = true, then create the target:

FieldValue
Rule__cMaskSsn
SObjectType__cApiCall__c
Field__cblank (wildcard across every text field)
IsActive__ctrue

Example: scope a rule to one service

Use CallerClass__c on a MaskingTarget__mdt to fire a rule only when a specific API class is invoking the mask, useful when one rule (e.g., a credit-card format your processor uses) should only apply to payment-service payloads and not bleed into unrelated callouts.

How it works: the framework reads the populated text fields of the record, caches the resolved rule-to-field plan for (SObjectType, callerClassName) once per transaction, then applies each rule in rule-order with JSON-key rules batched into a single deserialize/serialize pass. The actual HTTP request/response going over the wire is unaffected; only the logged copy is redacted.


Mock Mode and API_MockFactory

You can't make real network calls from a unit test, and you often don't want to in a sandbox or demo either. Mock mode lets you stand in a fake response so your integration logic runs without touching the live external system. The framework checks three places for a mock, in order, and uses the first one it finds:

  1. Memory mocks: API_MockFactory.forService() / API_MockFactory.registerErrorMock() (highest priority)
  2. Metadata mocks: ApiMock__mdt custom metadata records
  3. defaultMockBody: JSON string set on the handler class in configure()

defaultMockBody (Handler-Level Mocking)

The simplest option is to put a canned response right on the handler, so every test of that handler gets it automatically with no per-test setup. Set defaultMockBody in configure():

apex
public override void configure()
{
	super.configure();
	requestPayload = new DTO_Request();
	responsePayload = new DTO_Response();
	defaultMockBody = '{"success": true, "messageId": "msg-12345"}';
}

In tests, the framework automatically returns this JSON as the mock HTTP response (200 OK) when no higher-priority mock is registered.

API_MockFactory (Programmatic Mocking)

When a single canned response isn't enough (for example, different responses in different tests, or simulated errors), register a mock in code. API_MockFactory lets you set this up with short chained calls:

apex
// Register a custom mock response
API_MockFactory.forService(API_SendEmail.class.getName())
	.body('{"messageId":"msg-123"}')
	.statusCode(200)
	.register();

// Register a 500 error mock
API_MockFactory.registerErrorMock(API_SendEmail.class.getName());

// Register a parse-fail mock (200 OK with unparseable body)
API_MockFactory.registerParseFailMock(API_SendEmail.class.getName());

// Clear all registered mocks
API_MockFactory.clearMocks();

MockBuilder methods (all return MockBuilder for chaining):

MethodDescription
.body(String)Response body (supports &#123;&#123;request.field&#125;&#125; interpolation)
.statusCode(Integer)HTTP status code
.status(String)HTTP status text
.withHeader(String, String)Add a response header
.withHeaders(Map<String, String>)Add multiple response headers
.fromResponse(HttpResponse)Populate from an existing HttpResponse
.withFailureRate(Integer)Percentage of requests to fail randomly (0-100)
.register()Register the mock in the factory

Call Verification

As well as returning a fake response, the factory records what your code actually sent, so a test can assert the call happened and carried the right data (the same style of check Mockito offers for Java):

apex
// Check if a service was called
Assert.isTrue(API_MockFactory.wasCalled(API_SendEmail.class.getName()), 'Service should be called');

// Verify the service was never called
Assert.isTrue(API_MockFactory.wasNeverCalled(API_SendEmail.class.getName()), 'Should not be called');

// Verify last request contained specific data
Assert.isTrue(
	API_MockFactory.lastRequestContains(API_SendEmail.class.getName(), '"recipient"'),
	'Request should contain recipient'
);

Response Interpolation

Sometimes a realistic mock needs to reflect the request back, for example echoing an id the caller sent. Drop &#123;&#123;request.field&#125;&#125; placeholders into the mock body and the framework fills them from the actual request:

apex
API_MockFactory.forService(API_EchoService.class.getName())
	.body('{"echoId":"{{request.id}}","echoName":"{{request.data.name}}"}')
	.statusCode(200)
	.register();

// When request contains {"id":"12345","data":{"name":"Acme"}}
// Response becomes: {"echoId":"12345","echoName":"Acme"}

Supported patterns:

  • &#123;&#123;request.fieldName&#125;&#125;: Top-level field
  • &#123;&#123;request.nested.field&#125;&#125;: Nested field access via dot notation

Fault Injection

Code that only ever sees success in tests can hide weak error handling. You can make a mock fail a chosen percentage of the time, so you can prove your retry and fallback paths actually work:

apex
// 50% of requests fail randomly with 500
API_MockFactory.forService(API_UnreliableService.class.getName())
	.body('{"success":true}')
	.statusCode(200)
	.withFailureRate(50)
	.register();

Declarative Mock Mode

The options above are written in Apex. You can also switch mocking on through configuration alone, which is handy for sandbox and demo orgs where you'd rather not deploy code. Three ways to do it:

Via Feature Flag: Enable the MockAllInboundAPIs feature flag to force mock mode for all inbound APIs.

Per-Service: Set MockingEnabled__c = true on the service's ApiSetting__mdt record.

Metadata Mocks (via ApiMock__mdt):

Configure mock responses in custom metadata for sandbox and demo environments without deploying code changes. ApiMock__mdt records support response bodies, status codes, headers, request matching patterns, delay simulation, and failure rates.


Disabling APIs

If an integration starts misbehaving in production, you want to turn it off immediately, not wait for a deployment. The framework lets you switch APIs off through configuration, so you can stop a problem call during an incident and turn it back on later the same way. You can disable everything or just one service:

All APIs (hierarchy): ApiRuntimeSwitch__c.DisableAllApis__c = true (per user/profile/org)

All APIs (feature flag): Enable the DisableAllAPIs feature flag

Per-Service: Set IsActive__c = false on the service's ApiSetting__mdt record

Per-Service (feature flag): Set BypassFeatureFlag__c on ApiSetting__mdt to a feature flag name; when enabled, the service is bypassed


Performance Monitoring

When an integration feels slow, you need to know where the time went: waiting on the other system, your own processing, or the database save. The framework times each stage of every call and records it, so you can spot which integrations are slow and why without adding any instrumentation of your own:

FieldDescription
CalloutDurationMs__cTime spent in HTTP callout (milliseconds)
HandlerDurationMs__cTime spent in handler logic
CommitDurationMs__cDatabase commit time
TotalDurationMs__cTotal end-to-end time

Access in code:

apex
Long calloutTime = handler.calloutProcessingTime.getTime();
Long handlerTime = handler.serviceProcessingTime.getTime();
Long commitTime = handler.commitProcessingTime.getTime();
Long totalTime = handler.fullProcessingTime.getTime();

Batched Outbound Calls

Sometimes you don't want to make a callout right now. You'd rather queue up many calls and let them run later in the background, for example to spread load or to send them on a schedule. The framework supports this: you mark calls as queued, and a scheduled job works through them for you.

Scheduled Processing:

The framework includes SCHED_PerformBatchedCallouts to process the queued API calls. Schedule it to run as often as you need:

apex
// Schedule batched callout processing.
System.schedule
(
	'Process Batched API Calls',
	'0 */5 * * * ?',
	new SCHED_PerformBatchedCallouts()
);

How It Works:

  1. Create queue items with status 'Batched'
  2. Scheduled job changes status to 'Queued'
  3. Platform event or async processor picks up queued items
  4. Factory manager executes the calls

Safe Mode

Sometimes you want to run an API handler to see what it would do without actually changing any data, for instance to replay a failed request or to try a new handler before it goes live. Safe Mode does exactly that: it runs the handler as a dry run, then rolls back every database change at the end so nothing is left behind. The framework uses it internally for testing, debugging, and validating API behaviour without persisting changes.

Dry-run testing of your own APIs. To exercise an inbound API without persisting changes, write @IsTest methods with Test.startTest() / Test.stopTest() and let the test-mode rollback do the work. To exercise an outbound API without making a real callout, mock it via kern.API_MockFactory.forService(serviceName).body(json).register(). Both give you a clean dry run, and both run in your own namespace. The framework relies on API_Base.enterSafeMode(), API_Base.isSafeModeActive(), and API_Base.SafeModeContext internally for the same effect (shown below for reference).

Framework usage (reference)

apex
// Internal framework pattern — shown for documentation only.
API_Base.SafeModeContext safeMode = API_Base.enterSafeMode();
try
{
	API_Dispatcher.processInboundService('API_MyHandler');
}
finally
{
	safeMode.close();
}

Framework-internal methods:

MethodVisibilityDescription
API_Base.enterSafeMode()publicReturns a SafeModeContext, must be closed via try/finally
API_Base.isSafeModeActive()publicCheck if any Safe Mode scope is currently active
safeMode.close()publicClose the scope (idempotent, safe to call multiple times)

Behavior

AspectBehavior
DML OperationsExecuted then rolled back
TriggersFire normally
Validation RulesExecute and can block
Workflows/FlowsExecute but rolled back
HTTP CalloutsBlocked with mock response
EmailsNOT rolled back
Platform EventsRolled back (deferred publishing)
Governor LimitsConsumed as normal
ApiCall__c logsRolled back (not persisted)

Use Cases

  • Debugging failed APIs: replay a request with Safe Mode to inspect behaviour without side effects
  • Validating new API handlers: test end-to-end flow before enabling in production
  • Demo environments: execute APIs without creating real records
  • Test Harness: the apiTestHarnessForm LWC uses Safe Mode by default

API Test Harness (LWC and Tab)

You don't always want to write Apex just to try an integration. The API Test Harness is a point-and-click screen where you pick a service, fill in parameters, run it, and read the response, so you can exercise an inbound or outbound API by hand. It's a Lightning Web Component, apiTestHarnessForm, presented as a full-page workspace. It ships with a dedicated ApiTestHarness FlexiPage + Custom Tab, so you reach it from the Kern Home page (or any nav menu) without any App Builder configuration.

Features

  • Two-column page layout: configuration on the left, response preview on the right
  • Service discovery: auto-populates available services from ApiSetting__mdt filtered by direction
  • Direction toggle: switch between inbound and outbound APIs, reloads the service list automatically
  • Key-value parameter grid: dynamic rows for outbound parameters (add/remove/replacement-row), serialised to the Apex controller as a structured List<DTO_NameValue> inside a single JSON request payload (avoids the comma/equals footgun of delimited strings)
  • Execution Settings safety bar: 4-state combined indicator for Safe Mode and Mocking:
    • Safe Mode (blue info): DML rolled back, real callouts
    • Full Sandbox (green success): DML rolled back AND responses mocked
    • Live DML · Mocked callouts (amber warning): persistent DML but mocked external calls
    • LIVE (red error + SLDS alert-texture stripes): fully persistent, maximum risk
  • Destructive Execute variant: when Safe Mode is off, the Execute button turns red with a utility:warning icon and "Execute (Live)" label
  • Reset button: clears direction, service, parameters, toggles, and result in one click
  • Response preview: status badge (dynamically themed by isSuccess / isAborted / HTTP 4xx-5xx), metrics grid, Request/Response/Errors tabs with JSON viewers
  • API Call Id hyperlink: when Safe Mode was off on execution, the persisted ApiCall__c record Id renders as a clickable link that navigates to the record page; when Safe Mode was on (no persisted record), the Id is plain text
  • Sticky config column: on wide viewports, the left-side configuration panel stays pinned while the user scrolls the response preview, so Execute and Reset stay visible
  • Capped scroll regions: inner JSON viewers and header tables scroll internally rather than stretching the whole page, so very large responses stay manageable

Deployment

Ships as part of Kern with three metadata artifacts, no App Builder wiring required:

  • ApiTestHarness.flexipage-meta.xml: the App Page that hosts the form
  • ApiTestHarness.tab-meta.xml: the Custom Tab referencing the FlexiPage
  • Administrator.permissionset-meta.xml: grants tab visibility via <tabSettings>

Users navigate to /lightning/n/ApiTestHarness or click the API Test Harness tool card on Kern Home to open it. The apiTestHarnessForm LWC is also exposed to lightning__AppPage and lightning__HomePage if you want to embed it elsewhere.


Idempotency (Inbound APIs)

Here is the problem this solves. A caller sends you a request, the network drops before they get the answer, and they don't know whether you processed it, so they retry. Without protection, that retry runs a second time and you get a duplicate record. Idempotency means a repeated request is recognised and not re-run: the caller can retry safely. There are two cases, and the framework treats them differently:

  • Same request sent twice (a genuine retry): the framework returns the answer it already produced the first time, with HTTP 200. It does not run the work again, so no duplicate.
  • Same key, but a different request body (usually a caller bug, where they reused the key but changed the payload): the framework refuses with HTTP 409 instead of silently masking the change, so the mismatch surfaces rather than hiding.

(So "a retry can't create a duplicate" is true only for the genuine same-body retry; a different body under the same key is rejected, not quietly accepted.)

Turn it on per-service with kern__ApiSetting__mdt.IdempotencyEnabled__c = true. Once enabled, callers send a unique Idempotency-Key HTTP header on each request (a tracking value the caller picks for that one request), and the framework matches it against kern__ApiCall__c.IdempotencyKey__c to spot a repeat.

What gets stored

When idempotency is enabled and a caller sends an Idempotency-Key header, the framework stores three fields on ApiCall__c after it has processed the first call:

  • IdempotencyKey__c: the raw header value, indexed as an external ID so a repeat can be looked up quickly
  • IdempotencyKeyBodyHash__c: a short fingerprint of the request body (a SHA-256 hex digest), used to tell whether a later request that reuses the same key actually carries the same body or a different one (see "Replay behaviour" below)
  • IsIdempotencyHit__c: true if the answer was returned from an earlier call's record without re-running the handler; false for first calls

The body fingerprint is computed once per call. Outbound calls leave IdempotencyKeyBodyHash__c blank: for an outbound call, the caller decides the key explicitly by stamping it via UTIL_HttpClient's withIdempotencyKey() (stored on IdempotencyKey__c), rather than the framework computing a fingerprint from the body.

Replay behaviour

The framework's replay decision is a three-way branch on the inbound request:

Replay scenarioStored hash on existing recordNew request body hashFramework response
Same key, same body (network retry, idempotent replay)matchesmatchesHTTP 200 + cached response from Response__c
Same key, different body (caller bug or stale-edit retry)populateddiffersHTTP 409 with JSON body referencing the original ApiCall.Id
Same key, legacy record (created before body-hash detection shipped)nullanyHTTP 200 + cached response (legacy passthrough, see "Backward compatibility")

The HTTP 409 response body shape:

json
{
  "error": "Idempotency-Key reused with different request body",
  "originalApiCallId": "a01ABC0000xyz123"
}

Callers can parse originalApiCallId and query the original ApiCall__c record to reconcile what their first request actually committed.

Handling 409 in callers

When a caller receives HTTP 409 from an inbound endpoint, the framework is signalling that the same Idempotency-Key was previously used to commit a different request body. Three reasonable client-side responses:

  1. Investigate the divergence: fetch the original record via kern__ApiCall__c.Id = :originalApiCallId and compare the stored Request__c against the new payload. Most often the caller has buggy retry logic that mutates the request body before retrying.
  2. Use a new key for the new payload: if the caller legitimately wants to send a different request, generate a fresh Idempotency-Key. Reusing the same key with a different body is a contract violation per the IETF idempotency-key draft.
  3. Surface the conflict to the caller's user: for human-driven workflows (e.g. a form submission), present the original response so the user knows their first attempt succeeded and the second was a duplicate.

Note: HTTP 409 is only returned when both bodies hash differently. Same-body replays continue to return HTTP 200 with the cached response. This is the standard idempotent-replay path and is unchanged.

Backward compatibility

ApiCall__c records created before the body-hash field shipped have IdempotencyKeyBodyHash__c = null. The framework cannot detect divergence on these records, so it falls through to the cached-response path (HTTP 200) regardless of whether the new request body matches the original. This preserves pre-existing behaviour for legacy data.

Forward-going records (created after the body-hash check shipped) get the full 409 detection. There is no migration to backfill the hash on legacy records; the field stays null and those records are treated as legacy passthroughs forever.

To audit which records have the hash populated:

apex
// Records with body-hash detection enabled
List<kern__ApiCall__c> withHash = [
    SELECT Id, kern__ServiceName__c, kern__IdempotencyKey__c
    FROM kern__ApiCall__c
    WHERE kern__IdempotencyKey__c != null
    AND kern__IdempotencyKeyBodyHash__c != null
    AND kern__Direction__c = 'Inbound'
];

// Legacy records (no body hash) — replay behaviour falls through to cached response
List<kern__ApiCall__c> legacyRecords = [
    SELECT Id, kern__ServiceName__c, kern__IdempotencyKey__c
    FROM kern__ApiCall__c
    WHERE kern__IdempotencyKey__c != null
    AND kern__IdempotencyKeyBodyHash__c = null
    AND kern__Direction__c = 'Inbound'
];

How do I call it from a Flow?

Admins build integrations in Flow without writing Apex. The framework gives Flow two ready-made actions for calling an API, so a screen flow or a record-triggered flow can reach an external system the same way your Apex does, with the same logging and retry behind it. One action waits for the answer (synchronous, an immediate response); the other hands the work off to run in the background (asynchronous, background processing). The rest of this section shows when to pick each one and how to wire it up.

Understanding Synchronous vs Asynchronous Callouts

The choice comes down to one question: does the person running the flow need the answer right now, on screen? If yes, use a synchronous call and the flow waits. If the answer can arrive later (or no person is watching), use an asynchronous call so nothing is held up. The checklists below help you decide.

When to Use Synchronous Callouts

Reach for a synchronous call when the person running the flow is sitting there waiting and needs the answer on screen before they can carry on.

Use synchronous callouts when:

  • ✅ You need an immediate API response to display to the user
  • ✅ The API call is fast (typically < 5 seconds)
  • ✅ The flow needs the API response to make decisions
  • ✅ Running from a Screen Flow where the user is waiting
  • ✅ The flow is invoked manually (not from a record trigger)

Example Scenarios:

  • Data enrichment in a screen flow
  • Credit card verification during checkout
  • Real-time inventory check
  • Currency conversion for display

⚠️ Important Limitations:

  • Synchronous callouts block the flow execution until the API responds
  • Maximum callout time: 120 seconds (Salesforce limit)
  • Cannot be used in before-save record-triggered flows
  • Counts against synchronous transaction limits

When to Use Asynchronous Callouts

Reach for an asynchronous call when nobody is waiting on the result, so the work can run in the background without holding up the save or the user.

Use asynchronous callouts when:

  • ✅ The API response is not needed immediately
  • ✅ Running from a Record-Triggered Flow (after-save)
  • ✅ The API call might be slow or unreliable
  • ✅ You want to prevent blocking user interactions
  • ✅ Processing large volumes of API calls

Example Scenarios:

  • Notifying external systems of record changes
  • Syncing data to external databases
  • Sending emails or notifications via third-party services
  • Background data enrichment

✅ Benefits:

  • Does not block user interface or flow execution
  • Automatic retry and error handling
  • Better for unreliable or slow APIs
  • Can handle higher volumes via batch processing

Synchronous Callouts from Screen Flows

Use Case: External API Call from Screen Flow

Picture a screen where the user types something in, the flow calls an API, and the answer has to come back before the user can move on. That is the synchronous pattern: the user waits a moment and sees the result.

Flow Type: Screen Flow Invocable Action: Invoke Callout SynchronouslyFramework Class: FLOW_CallApi

Flow Configuration:

  1. Create Screen - Collect user input

    Screen: Enter Details
    - Text Input: Title → {!title}
  2. Add Action Element - Invoke API Synchronously

    Action: Call External API
    Type: Invoke Callout Synchronously
    
    Inputs:
    - webServiceClassName: "API_PostExample"
    - recordId: {!recordId}
    - parameters: "foo={!title}"
    - inputDelimiter: "," (default)
    - extractPath: (optional - to extract specific JSON element)
    
    Store Outputs:
    - success → {!apiSuccess}
    - responseBody → {!apiResponse}
    - queueItemId → {!queueId}
    - extractedValue → {!extractedValue}
  3. Add Decision Element - Check API Result

    Decision: API Succeeded?
    Outcome 1: Success
      Condition: {!apiSuccess} Equals {!$GlobalConstant.True}
      → Go to next screen
    
    Outcome 2: Failed
      Condition: {!apiSuccess} Equals {!$GlobalConstant.False}
      → Show error screen
  4. Screen: Success - Display result

    Display Text:
    "API Response: {!apiResponse}"
  5. Screen: Error - Display error message

    Display Text:
    "API call failed: {!apiResponse}"

Complete Example Flow Metadata:

See FLOW_ApiTestHarness (force-app/main/default/flows/FLOW_ApiTestHarness.flow-meta.xml) for a complete working example of synchronous API calls from a screen flow with response handling.

Input Parameters:

ParameterTypeRequiredDescriptionExample
webServiceClassNameStringAPI class nameAPI_PostExample
recordIdStringRecord ID (if API needs record context){!recordId} or blank
parametersStringComma-separated name=value pairsfoo=Test Title
inputDelimiterStringDelimiter for parameters (default: ,), or ;
extractPathStringJSON path to extract specific elementtitle

Output Variables:

OutputTypeDescriptionExample Value
successBooleanWhether API call succeededtrue or false
responseBodyStringFull API response or error message{"body":"Some Random Body","id":101,"title":"Test Title","userId":9}
queueItemIdStringID of logged ApiCall__c recorda0X5g000000AbCD
extractedValueStringExtracted JSON element (if extractPath provided)Test Title

Asynchronous Callouts from Record-Triggered Flows

Use Case: Notify External System When Account is Created

Say you want to tell an outside system every time a new Account is created. The save should not wait for that outside system to answer, so you fire the call in the background and let the user's save finish straight away. That is the asynchronous pattern.

Flow Type: Record-Triggered Flow (After Save) Trigger: Account Created

How It Works:

You do not build the callout yourself. The framework ships with two built-in record-triggered flows on ApiCall__c that do the work for you:

FlowTriggerPurpose
ResetOutboundApiCallBefore SaveClears execution output fields (zero DML) when an ApiCall__c is set to Queued
InvokeOutboundApiCallAfter SaveInvokes FLOW_CallApiAsync to process the callout asynchronously

Your flow only needs to create the ApiCall__c record with Status__c = 'Queued'. The framework handles the rest, so your flow stays a single create step.

Why Asynchronous?

Running the call in the background buys you several things, each of which protects either the user or the integration:

  • The InvokeOutboundApiCall flow calls FLOW_CallApiAsync, which enqueues a Queueable job (a background task Salesforce runs after the current transaction finishes)
  • Job size is configurable via AsynchronousJobSetting__mdt (default: 20, hard cap: 100 per the callout governor limit)
  • Callout budget is monitored at runtime; remaining items are deferred if the limit is reached
  • Automatic retry if external system is temporarily unavailable
  • Better error handling and logging

Flow Configuration:

  1. Flow Properties

    Object: Account
    Trigger: A record is created
    Entry Conditions: All Conditions Are Met (Boolean) AND
      - Account Type Equals "Customer"
    Optimize the Flow For: Actions and Related Records
  2. Create Records Element - Build API Queue Item

    Create Records: Build API Queue Item
    How Many Records: One
    Object: ApiCall__c
    
    Set Field Values:
    - ServiceName__c: "API_NotifyAccountCreated"
    - TriggeringRecordId__c: {!$Record.Id}
    - Direction__c: "Outbound"
    - Status__c: "Queued"

That is the whole flow. You do not add an action element: the framework's InvokeOutboundApiCall flow triggers automatically when the ApiCall__c record is created with Status__c = 'Queued' and Direction__c = 'Outbound'. So an admin gets a background API call from a flow with one create step and no code.

Important Notes:

  • The flow completes immediately after creating the queue item
  • The API call executes asynchronously in the background via a Queueable job
  • Check ApiCall__c.Status__c to monitor progress
  • Framework automatically retries failed calls based on ApiSetting__mdt configuration
  • Cannot use the API response in the same flow (it happens later)

Comparison Matrix: Sync vs Async Callouts

Side by side, here is how the two patterns differ on the things that usually decide which one you pick:

AspectSynchronousAsynchronous
ExecutionBlocks until completeReturns immediately
Use FromScreen Flows, Manual InvocationRecord-Triggered Flows (after-save), Scheduled Flows
Response AvailableYes, immediatelyNo (logged to ApiCall__c)
Max Duration120 secondsNo practical limit (runs in Queueable/Batch)
Error HandlingMust handle in flowAutomatic retry via framework
User ExperienceUser waits for responseUser not blocked
Governor LimitsCounts against transaction limitsSeparate async limits
Best ForReal-time validation, Interactive formsBackground sync, Notifications, Data enrichment
Retry SupportManual onlyAutomatic via framework

Best Practices for Flow Integration

Always Check Success Flag

A callout can fail for reasons outside your control, so branch on the success flag rather than assuming the call worked.

text
Decision: API Call Successful?
Outcome 1: Success
  - Condition: {!apiSuccess} Equals true
  - Action: Proceed with success path
Outcome 2: Failure
  - Condition: {!apiSuccess} Equals false
  - Action: Display error message: {!apiResponse}

Provide User Feedback

Tell the user what is happening so a wait does not look like a freeze and a background job does not look like nothing happened.

For Synchronous Calls:

  • Show spinner/progress indicator during API call
  • Display success or error message based on response
  • Allow user to retry on failure

For Asynchronous Calls:

  • Inform user the action will complete in background
  • Provide reference number (queueItemId) for tracking
  • Set up email notifications for completion/errors

Handle Timeouts

An external API can be slow or unreachable, so add a fault path that shows a clear message instead of letting the flow error out.

text
Add Fault Path to Action Element:
- Store Error Message: {!$Flow.FaultMessage}
- Show Error Screen: "The API request timed out. Please try again."

Use Meaningful Parameter Names

Clear parameter names make the flow readable later and help whoever maintains the API class match values to fields.

text
✅ GOOD: parameters = "accountId={!recordId},accountName={!accountName}"
❌ BAD:  parameters = "id={!recordId},name={!accountName}"

Log Queue Item IDs

Store the queueItemId in a custom field for later tracking, so you can always trace a record back to the API call it triggered:

text
Update Record: Update Account
- WebserviceQueueId__c = {!queueItemId}

Testing Flow Callouts

You can write Apex tests for a flow callout the same way you test any flow, so a flow-driven integration is covered by your test suite rather than only checked by hand. The two examples below show one test for each pattern.

Test Synchronous Flow Callout

apex
@IsTest
private static void testSynchronousFlowCallout()
{
	Test.startTest();

	Map<String, Object> flowInputs = new Map<String, Object>
	{
		'webServiceClassName' => 'API_PostExample',
		'parameters' => 'foo=Test Title'
	};

	Flow.Interview flow = Flow.Interview.createInterview('YourFlowAPIName', flowInputs);
	flow.start();

	Boolean success = (Boolean)flow.getVariableValue('apiSuccess');
	String payload = (String)flow.getVariableValue('apiResponse');

	Test.stopTest();

	Assert.isTrue(success, 'API call should succeed');
	Assert.isNotNull(payload, 'Response payload should not be null');
}

Test Asynchronous Flow Callout

apex
@IsTest
private static void testAsynchronousFlowCallout()
{
	Account testAccount = (Account)TST_Builder.of(Account.SObjectType)
		.withOverride(Account.Name, 'Test Account')
		.build();

	Test.startTest();

	ApiCall__c queueItem = TST_Factory.newOutboundApiCall(
		'API_NotifyAccountCreated',
		testAccount.Id,
		new Map<String, String>()
	);
	FLOW_CallApiAsync.invokeApiCallAsynchronously(
		new List<ApiCall__c>{ queueItem }
	);

	Test.stopTest();

	SEL_ApiCall.assertServiceCompleted('API_NotifyAccountCreated');
}

Logging and Monitoring

When an integration misbehaves in production, the first question is always "what actually happened?" The framework answers that for you: it records every call, every error, and every payload automatically, so the evidence is waiting when you need it instead of being lost. This section shows you where each kind of record lands and how to read it.

Where Things Are Logged

ComponentLocationDetails
All API CallsApiCall__cRequest, response, status, timing
Large PayloadsContentVersionFiles linked to queue item
ErrorsApiCall__c.ErrorMessages__cError messages and stack traces
FailuresApiIssue__cPersistent failure records (if enabled)
Application LogsLogEntryEvent__e Platform EventVia LOG_Builder fluent API

Automatic Web Service Context

Every log you write during a call is automatically stamped with details about that call (which service, which endpoint, which HTTP method), so a log line never leaves you guessing which integration produced it. The framework adds this context to all LOG_Builder calls during API execution, and it shows up in the ContextData__c field of LogEntry__c records.

Automatic Context Fields:

FieldWhen SetDescription
serviceNameconfigure()The API handler class name
endpointconfigure()The endpoint URL being called
httpMethodconfigure()The HTTP method (GET, POST, etc.)
statusCodeAfter calloutHTTP response status code (outbound only)
calloutDurationMsAfter calloutCallout duration in milliseconds (outbound only)

How It Works:

You log normally. Any LOG_Builder call made during API processing picks up the web service context for you, so you do not have to pass those fields in by hand:

apex
public inherited sharing class API_CustomerSync extends API_Outbound
{
	public override void onSuccess()
	{
		super.onSuccess();

		// This log entry automatically includes web service context
		LOG_Builder.build().info('Processing customer sync').emitAt('API_CustomerSync.onSuccess');
		// ContextData__c includes: serviceName, endpoint, httpMethod, statusCode, calloutDurationMs
	}
}

Correlation with Other Logs:

A single user action often spans several steps: your own code, the API call, and whatever runs afterwards. Correlation gives all of those logs one shared correlation ID, so you can pull up the whole story of one request instead of hunting through unrelated log lines:

apex
// Before calling the API
LOG_Builder.startCorrelation();
LOG_Builder.build().info('Initiating customer sync').emitAt('MyService.syncCustomer');

// Execute API - context is automatically maintained
API_Dispatcher.execute(queueItems);

// All logs share the same correlationId for easy debugging

Context Cleanup:

Once a call finishes, the framework clears the web service context for you. That way later logs in the same transaction don't accidentally pick up the previous call's details and point you at the wrong integration.

Monitoring API Health

Because every call is recorded as an ApiCall__c record, you can watch the health of your integrations with ordinary Salesforce reports and list views, no extra tooling required. Here is a query to list recent calls and a starting set of metrics worth charting.

List View: Recent API Calls

soql
SELECT Name, ServiceName__c, Status__c, StatusCode__c, CreatedDate, ErrorMessages__c
FROM ApiCall__c
WHERE CreatedDate = LAST_N_DAYS:7
ORDER BY CreatedDate DESC

Dashboard Metrics:

  • Success rate by service
  • Average response time
  • Failed calls requiring retry
  • Most common error messages

Accessing Large Payloads

A request or response body can be too big to fit in a text field. When that happens the framework stores the full body as a file (a ContentVersion) linked to the queue item, so nothing is truncated or lost. Here is how to fetch that full body back when you need to inspect it:

apex
// Get related files
List<ContentDocumentLink> links = QRY_Builder.selectFrom(ContentDocumentLink.SObjectType)
	.fields(new List<String>{'ContentDocumentId', 'ContentDocument.LatestPublishedVersionId'})
	.condition(ContentDocumentLink.LinkedEntityId).equals(queueItem.Id)
	.toList();

Set<Id> versionIds = new Set<Id>();
for(ContentDocumentLink link : links)
{
	versionIds.add((Id)link.ContentDocument.get('LatestPublishedVersionId'));
}

List<ContentVersion> versions = QRY_Builder.selectFrom(ContentVersion.SObjectType)
	.fields(new List<SObjectField>{ContentVersion.VersionData, ContentVersion.Title})
	.condition(ContentVersion.Id).isIn(versionIds)
	.toList();

for(ContentVersion version : versions)
{
	if(version.Title.contains('RequestBody'))
	{
		String fullRequest = version.VersionData.toString();
	}
}

Testing

Overview of Test Helper Classes

Testing a web service by hand means a lot of setup: building the request, creating the queue record, running the call, then checking the status and the logged result. The framework gives you a set of test helper classes that take that boilerplate out of web-service tests, so each test says what it verifies instead of how to wire it up:

Helper ClassPurposeUsed For
API_OutboundTestHelperTest outbound REST APIsSuccessful calls, failed calls, aborted calls, parse failures
API_InboundTestHelperTest inbound REST APIsSetting up REST context, validating aborted calls
SEL_ApiCallAssert queue item statusVerifying service completion, abortion, or failure
TST_FactoryCreate test dataGenerating ApiCall__c records for testing
TST_BuilderBuild test recordsCreating test data with proper relationships

Key Benefits:

  • ✅ Reduce test code by 60-80%
  • ✅ Consistent testing patterns across all APIs
  • ✅ Automatic queue item creation and assertion
  • ✅ Built-in validation of framework behaviour
  • ✅ Cleaner, more maintainable tests

Testing Outbound APIs

Using API_OutboundTestHelper

These methods let you verify that an outbound call succeeded, failed, or was aborted, without writing the setup-and-assert plumbing yourself. The API_OutboundTestHelper class provides them.

Key Methods:

MethodPurposeReturns
assertCallSuccessful(serviceName, recordId)Test successful API executionAPI handler instance
assertCallSuccessful(serviceName, recordId, parameters)Test with custom parametersAPI handler instance
assertCallSuccessfulWithTransformation(serviceName, recordId, transformerClassName)Test with response transformerAPI handler instance
assertCallFailed(queueItems)Test failed API callsList of API handlers
assertCallAborted(serviceName, recordId)Test aborted callsAPI handler instance

What These Methods Do Automatically:

  1. Create ApiCall__c queue item using TST_Factory
  2. Execute the API via API_Dispatcher
  3. Assert the expected status (completed, failed, aborted)
  4. Return the handler for additional assertions

Complete Outbound Test Example

apex
/**
 * @description Unit tests for API_PostExample outbound API.
 *
 * @see API_PostExample
 */
@IsTest(SeeAllData=false IsParallel=true)
private class API_PostExample_TEST
{
	/**
	 * @description Validates successful API call with parameters
	 */
	@IsTest
	private static void successfulCallReturnsResponse()
	{
		API_Base handler = API_OutboundTestHelper.assertCallSuccessful(API_PostExample.class.getName(), UserInfo.getUserId());

		Assert.isNotNull(handler, 'Handler should not be null');

		// Verify queue item was created and marked as completed
		List<ApiCall__c> queueItems = SEL_ApiCall.assertServiceCompleted(API_PostExample.class.getName());
		Assert.areEqual(1, queueItems.size(), 'Should have one queue item');
		Assert.isNotNull(queueItems[0].Response__c, 'Response should be logged');
	}

	/**
	 * @description Validates API call with triggering Account record
	 */
	@IsTest
	private static void apiCallWithTriggeringObjectReferencesRecord()
	{
		Account testAccount = (Account)TST_Builder.of(Account.SObjectType)
			.withOverride(Account.Name, 'Test Company')
			.build();

		API_Base handler = API_OutboundTestHelper.assertCallSuccessful
		(
			API_PostExample.class.getName(),
			testAccount.Id
		);

		List<ApiCall__c> queueItems = new SEL_ApiCall().findByServiceName
		(
			API_PostExample.class.getName()
		);
		Assert.areEqual(testAccount.Id, queueItems[0].TriggeringRecordId__c, 'Should reference account');
	}

	/**
	 * @description Validates API abortion when all outbound calls are disabled
	 */
	@IsTest
	private static void disabledApiAbortsWithError()
	{
		TST_Factory.newFeatureFlag('DisableAllAPIs');

		API_Base handler = API_OutboundTestHelper.assertCallAborted
		(
			API_PostExample.class.getName(),
			null
		);

		Assert.isTrue(handler.result.isAborted, 'Call should be aborted');
		Assert.isFalse(handler.result.errors.isEmpty(), 'Should have error messages');

		// Verify queue item shows aborted status
		List<ApiCall__c> queueItems = SEL_ApiCall.assertServiceAborted
		(
			API_PostExample.class.getName(),
			'API has been disabled'
		);
		Assert.areEqual(1, queueItems.size());
	}

	/**
	 * @description Validates bulk processing of multiple API calls
	 */
	@IsTest
	private static void bulkApiCallsProcessAllRecords()
	{
		List<ApiCall__c> queueItems = new List<ApiCall__c>();
		for(Integer i = 0; i < 5; i++)
		{
			queueItems.add
			(
				TST_Factory.newOutboundApiCall
				(
					API_PostExample.class.getName(),
					UserInfo.getUserId()
				)
			);
		}

		Test.startTest();
		List<API_Base> handlers = API_Dispatcher.execute(queueItems);
		Test.stopTest();

		Assert.areEqual(5, handlers.size(), 'Should process all 5 API calls');

		List<ApiCall__c> completedItems = SEL_ApiCall.assertServiceCompleted
		(
			API_PostExample.class.getName()
		);
		Assert.areEqual(5, completedItems.size(), 'All calls should complete successfully');
	}
}

Using TST_Factory for Queue Items

When you need to build the queue item yourself rather than let a helper do it, use the factory. It lets you set exactly the service name, triggering record, and parameters your test needs:

apex
/**
 * @description Creates outbound queue item with basic service name
 */
ApiCall__c queueItem1 = TST_Factory.newOutboundApiCall(
	'API_PostExample'
);

/**
 * @description Creates queue item with triggering object
 */
ApiCall__c queueItem2 = TST_Factory.newOutboundApiCall(
	'API_PostExample',
	accountId
);

/**
 * @description Creates queue item with parameters map
 */
ApiCall__c queueItem3 = TST_Factory.newOutboundApiCall(
	'API_PostExample',
	accountId,
	new Map<String, String>{ 'foo' => 'Test Title' }
);

/**
 * @description Creates queue item with single parameter
 */
ApiCall__c queueItem4 = TST_Factory.newOutboundApiCall(
	'API_PostExample',
	accountId,
	'foo',
	'Test Title'
);

Testing Inbound APIs

Using API_InboundTestHelper

An inbound test has to pretend a real HTTP request arrived: it needs a request and response object in place before your endpoint runs. Setting that up by hand is fiddly. The API_InboundTestHelper class does it for you in one call, so your test can get straight to verifying the response.

Key Methods:

MethodPurpose
setupRestContext()Initializes RestContext.request and RestContext.response with default values
setupRestContext(DTO_Base dtoRequest)Sets up context and populates request body with serialized DTO
assertCallAborted(serviceName)Executes API and asserts it was aborted
assertCallAborted(serviceName, exceptionMessage)Asserts abortion with specific error message
assertCallAborted(serviceName, dtoRequest, exceptionMessage)Full setup with DTO and error validation

What setupRestContext() Does:

  1. Creates new RestRequest and RestResponse objects
  2. Sets Content-Type header to application/json
  3. Sets HTTP method to POST
  4. Sets default request URI to https://example.com/test
  5. Assigns to RestContext.request and RestContext.response

Complete Inbound Test Example

apex
/**
 * @description Unit tests for API_PersonRetrieve inbound API.
 *
 * @see API_PersonRetrieve
 * @see REST_Person
 */
@IsTest(SeeAllData=false IsParallel=true)
private class API_PersonRetrieve_TEST
{
	/**
	 * @description Creates test Contact record for use in tests
	 */
	@TestSetup
	private static void setupTestData()
	{
		TST_Builder.of(Contact.SObjectType).withOverrides(new Map<SObjectField, Object>
		{
			Contact.LastName => 'Test Person',
			Contact.FirstName => 'John',
			Contact.Email => 'john.test@example.com',
			Contact.MobilePhone => '555-0100',
			Contact.CMN_ExternalReference__c => 'test-guid-12345'
		}).build();
	}

	/**
	 * @description Validates successful person retrieval with valid identity ID
	 */
	@IsTest
	private static void successfulRetrievalReturnsPersonData()
	{
		Contact testContact = (Contact)QRY_Builder.selectFrom(Contact.SObjectType)
			.addFields(new List<SObjectField>{Contact.Id, Contact.CMN_ExternalReference__c})
			.withLimit(1)
			.getFirst();

		API_InboundTestHelper.setupRestContext();
		RestContext.request.requestURI = RestContext.request.requestURI + '/' + testContact.CMN_ExternalReference__c;

		Test.startTest();
		System.runAs(TST_Factory.newUser(SEL_Profile.API_ONLY_USER))
		{
			REST_Person.getPerson();
		}
		Test.stopTest();
		String responseBody = RestContext.response.responseBody.toString();
		Map<String, Object> responseMap = (Map<String, Object>)JSON.deserializeUntyped(responseBody);

		Assert.areEqual('Test Person', responseMap.get('lastName'), 'Should return lastName');
		Assert.areEqual('John', responseMap.get('firstName'), 'Should return firstName');
		Assert.areEqual('john.test@example.com', responseMap.get('email'), 'Should return email');
		Assert.areEqual(200, RestContext.response.statusCode, 'Should return 200 OK');

		// Verify queue item was logged with completed status
		SEL_ApiCall.assertServiceCompleted(API_PersonRetrieve.class.getName());
	}

	/**
	 * @description Validates error handling when person is not found
	 */
	@IsTest
	private static void personNotFoundReturnsErrorResponse()
	{
		API_InboundTestHelper.setupRestContext();
		RestContext.request.requestURI = RestContext.request.requestURI + '/invalid-guid-99999';

		Test.startTest();
		System.runAs(TST_Factory.newUser(SEL_Profile.API_ONLY_USER))
		{
			REST_Person.getPerson();
		}
		Test.stopTest();

		Assert.areEqual(400, RestContext.response.statusCode, 'Should return 400 Bad Request');
		String responseBody = RestContext.response.responseBody.toString();
		Assert.isTrue(responseBody.contains('No person found'), 'Should contain error message');

		// Verify queue item was logged with aborted status
		SEL_ApiCall.assertServiceAborted(API_PersonRetrieve.class.getName(), 'No person found');
	}

	/**
	 * @description Validates missing identity ID returns validation error
	 */
	@IsTest
	private static void missingIdentityIdReturnsValidationError()
	{
		API_InboundTestHelper.setupRestContext();

		Test.startTest();
		REST_Person.getPerson();
		Test.stopTest();

		SEL_ApiCall.assertServiceAborted(API_PersonRetrieve.class.getName(), 'Identity Id not provided');
	}

	/**
	 * @description Validates update person with DTO modifies contact record
	 */
	@IsTest
	private static void updatePersonWithDtoModifiesContactRecord()
	{
		Contact testContact = (Contact)QRY_Builder.selectFrom(Contact.SObjectType)
			.addFields(new List<SObjectField>{Contact.Id, Contact.CMN_ExternalReference__c})
			.withLimit(1)
			.getFirst();

		API_PersonUpdate.DTO_Request updateRequest = new API_PersonUpdate.DTO_Request();
		updateRequest.identityId = testContact.CMN_ExternalReference__c;
		updateRequest.firstName = 'Jane';
		updateRequest.lastName = 'Updated';
		updateRequest.email = 'jane.updated@example.com';

		API_InboundTestHelper.setupRestContext(updateRequest);

		Test.startTest();
		REST_Person.editPerson();
		Test.stopTest();

		Contact updatedContact = (Contact)QRY_Builder.selectFrom(Contact.SObjectType)
			.addFields(new List<SObjectField>{Contact.Id, Contact.FirstName, Contact.LastName, Contact.Email})
			.condition(Contact.Id).equals(testContact.Id)
			.getFirst();
		Assert.areEqual('Jane', updatedContact.FirstName, 'FirstName should be updated');
		Assert.areEqual('Updated', updatedContact.LastName, 'LastName should be updated');
		Assert.areEqual('jane.updated@example.com', updatedContact.Email, 'Email should be updated');

		// Verify queue item shows successful completion
		SEL_ApiCall.assertServiceCompleted(API_PersonUpdate.class.getName());
	}

	/**
	 * @description Validates assertCallAborted helper detects missing required fields
	 */
	@IsTest
	private static void assertCallAbortedHelperDetectsMissingFields()
	{
		API_PersonUpdate.DTO_Request invalidRequest = new API_PersonUpdate.DTO_Request();

		Test.startTest();
		API_Base handler = API_InboundTestHelper.assertCallAborted(API_PersonUpdate.class.getName(), invalidRequest, 'Identity Id not provided');
		Test.stopTest();

		Assert.isTrue(handler.result.isAborted, 'Should be aborted');
		Assert.isTrue(handler.result.errors.contains('Identity Id not provided'), 'Should have validation error');
	}
}

Using SEL_ApiCall for Assertions

After a call runs, you want to confirm it ended in the state you expected and that the framework logged it. The SEL_ApiCall class gives you assertion methods to check API execution status, so a single line confirms the call completed, was aborted, or failed.

Key Assertion Methods:

apex
/**
 * @description Asserts API completed successfully
 */
List<ApiCall__c> completedItems = SEL_ApiCall.assertServiceCompleted(
	'API_PersonRetrieve'
);

/**
 * @description Asserts API was aborted
 */
List<ApiCall__c> abortedItems = SEL_ApiCall.assertServiceAborted(
	'API_PersonRetrieve'
);

/**
 * @description Asserts API was aborted with specific error message
 */
List<ApiCall__c> abortedItems = SEL_ApiCall.assertServiceAborted(
	'API_PersonRetrieve',
	'No person found'
);

/**
 * @description Asserts API failed
 */
List<ApiCall__c> failedItems = SEL_ApiCall.assertServiceFailed(
	'API_PersonRetrieve'
);

/**
 * @description Asserts API failed with specific error phrase
 */
List<ApiCall__c> failedItems = SEL_ApiCall.assertServiceFailed(
	'API_PersonRetrieve',
	'Timeout'
);

What These Methods Do:

  1. Query ApiCall__c for the specified service name
  2. Assert that at least one queue item exists
  3. Assert that all queue items have the expected status
  4. Optionally assert error message contains the specified phrase
  5. Return the queue items for additional assertions

Query Methods:

apex
/**
 * @description Retrieves all queue items for a service
 */
List<ApiCall__c> items = new SEL_ApiCall().findByServiceName(
	'API_PersonRetrieve'
);

/**
 * @description Retrieves batched outbound queue items
 */
List<ApiCall__c> batchedItems = new SEL_ApiCall().findByBatchedOutbound();

Testing Best Practices

  1. Always use test helper classes - API_OutboundTestHelper and API_InboundTestHelper eliminate boilerplate
  2. Use TST_Builder for test data - Cleaner than manual record creation
  3. Test bulk scenarios - Create and process multiple queue items
  4. Use assertion methods - assertServiceCompleted(), assertServiceAborted(), assertServiceFailed()
  5. Verify queue items - Always check that ApiCall__c records were created correctly
  6. Test error scenarios - Missing parameters, invalid data, disabled APIs
  7. Use @TestSetup - Create common test data once for multiple test methods
  8. Test with different users - Use System.runAs() to test with API users
  9. Enable logging when needed - Set LOG_Builder.ignoreTestMode = true for log testing
  10. Test mock responses - Verify that mock classes return expected data

Common Test Patterns

Pattern 1: Simple Successful Outbound Call

apex
@IsTest
private static void successfulOutboundCallCompletesWithResponse()
{
	Test.startTest();
	API_Base handler = API_OutboundTestHelper.assertCallSuccessful(API_MyService.class.getName(), recordId);
	Test.stopTest();

	// Additional assertions on handler.result or handler.responsePayload
}

Pattern 2: Outbound Call with Parameters

apex
@IsTest
private static void outboundCallWithParametersExecutesCorrectly()
{
	Map<String, String> params = new Map<String, String>{ 'key' => 'value' };

	Test.startTest();
	API_Base handler = API_OutboundTestHelper.assertCallSuccessful(API_MyService.class.getName(), null, params);
	Test.stopTest();
}

Pattern 3: Inbound Call Setup

apex
@IsTest
private static void inboundCallProcessesRequestSuccessfully()
{
	API_InboundTestHelper.setupRestContext();
	RestContext.request.requestURI = RestContext.request.requestURI + '/someId';

	Test.startTest();
	REST_MyAPI.httpMethodName();
	Test.stopTest();

	SEL_ApiCall.assertServiceCompleted(API_MyImplementation.class.getName());
}

Pattern 4: Inbound Call with DTO

apex
@IsTest
private static void inboundCallWithDtoProcessesRequest()
{
	API_MyService.DTO_Request request = new API_MyService.DTO_Request();
	request.fieldName = 'value';

	API_InboundTestHelper.setupRestContext(request);

	Test.startTest();
	REST_MyAPI.httpMethodName();
	Test.stopTest();

	SEL_ApiCall.assertServiceCompleted(API_MyService.class.getName());
}

Pattern 5: Testing Abortion

apex
@IsTest
private static void abortedCallLogsErrorMessage()
{
	Test.startTest();
	API_Base handler = API_OutboundTestHelper.assertCallAborted(API_MyService.class.getName(), null);
	Test.stopTest();

	SEL_ApiCall.assertServiceAborted(API_MyService.class.getName(), 'Expected error message');
}

Capability Matrix (for Analysts)

Most of what this framework does is controlled by configuration records, not code. That means you can change how an integration behaves (turn on retries, switch a credential, mask a field, or hit an emergency off switch) without asking a developer to edit and redeploy Apex. This table is the quick reference for that: each row is one thing you can adjust, where you adjust it (which configuration record), and what it does. If you run or monitor integrations and want to know which knobs are yours to turn, start here.

CapabilityCustom MetadataField/ClassNotes
API endpoint configuration (inbound)ApiSetting__mdtEndpointPath__cDeclarative endpoint configuration for inbound REST handlers
API endpoint configuration (outbound)ApiSetting__mdtEndpointPath__c (HTTP method via handler's getHttpMethod() override)Declarative endpoint; HTTP verb is set in the handler class, not the metadata record
Credential managementApiCredential__mdtNamed Credential referenceSecure credential storage and rotation
Data maskingMaskingRule__mdt + MaskingTarget__mdtMode__c (Regex / JsonKey / ExactMatch / CreditCard), Pattern__c, Replacement__c, MinInputLength__c, ApplicableFieldTypes__cShared redaction framework; ships with secrets + credit-card rules active; you opt additional rules in per field
Mock modeApiSetting__mdtMockingEnabled__cEnable mock responses without callouts
API disable switchApiRuntimeSwitch__cDisableAllApis__cEmergency kill switch for all API calls (hierarchy)
Retry strategyApiSetting__mdtMaxRetryCount__c, RetryBackoffSeconds__cConfigurable retry with linear or exponential backoff
Circuit breakerApiSetting__mdtCircuitBreakerEnabled__cAutomatic circuit breaker protection
Performance loggingAutomaticApiCall__c.TotalDurationMs__cAll API calls are automatically timed and logged
Batched calloutsScheduledJob__cSCHED_PerformBatchedCalloutsProcess queued API calls in scheduled batches

Anti-Patterns

Each habit below quietly breaks something the framework was built to give you: a searchable record of every call, automatic retries, hidden sensitive data, testability, or a callout that runs in the right order. Spotting these early saves you from debugging a production failure later. Each row names the mistake, why it hurts, and what to do instead.

Anti-PatternWhy It's WrongInstead
Raw HttpRequest/HttpResponse without the frameworkNo logging, no retry, no data masking, no mock supportExtend API_Outbound or API_Inbound and let the orchestrator handle the lifecycle
DML before callout in the same transactionSalesforce throws CalloutException; you cannot make callouts after DMLUse the framework's orchestration pattern: callout first, then onSuccess() for DML registration
Hardcoding endpoints or credentials in ApexCannot change per environment, fails security review, blocks testingUse ApiSetting__mdt + Named Credentials; override getWebServiceEndPoint() only when dynamic
Overriding every virtual methodMakes the class brittle and harder to maintain when the framework evolvesOverride only what differs from the defaults (see Override Only What You Need)
Skipping @JsonAccess on DTOs in subscriber orgsSerialization fails at runtime with a security error in managed package contextAlways add @JsonAccess(Serializable='always' Deserializable='always') to every DTO

Best Practices

Code Standards

When everyone writes integrations the same way, any developer can read and maintain any of them. Follow these conventions so your code matches the rest:

  • Use tabs for indentation (indent size: 3, tab size: 3)
  • Braces on new lines (Allman style)
  • No space before control structure parentheses
  • Keywords (else, while, catch) on new lines
  • ApexDoc for all public/global methods and classes
  • Meaningful variable names in camelCase

Naming Conventions

Consistent class names make it obvious at a glance what each class does and which direction it points, so you spend less time hunting through code. Use these prefixes:

Outbound APIs:

  • REST: API_Get*, API_Post*, API_Put*, API_Patch*, API_Delete*
  • Intra-Org: API_* (extends API_CallCurrentOrg)
  • Mocks: API_*Mock

Inbound APIs (Two-Class Pattern):

  • REST Routing Classes: REST_*
    • Examples: REST_Echo, REST_Person, REST_Invoices
  • API Implementation Classes: API_*
    • Examples: API_Echo, API_PersonRetrieve, API_PersonUpdate

Common:

  • DTOs: DTO_Request, DTO_Response, DTO_* (nested)

Override Only What You Need

The base class already sets a default for most settings. Only override a method when you actually need to change its default, so your class stays short and the parts you did change stand out:

apex
// ❌ BAD - Unnecessary overrides
public override String getEncoding()
{
	return HTTP_ENCODING_JSON;  // Already the default!
}

public override HttpMethod getHttpMethod()
{
	return HttpMethod.POST;  // Already the default!
}

// ✅ GOOD - Only override when changing defaults
public override HttpMethod getHttpMethod()
{
	return HttpMethod.GET;  // Different from default
}

Error Handling

When you need to react to a failure (alert an admin, log to another system, open a follow-up case), override handleError and call super.handleError(error) first so the framework's own error handling still runs. Then add your extra steps:

apex
/**
 * @description Handles errors with custom notification logic.
 *
 * @param error The exception that occurred
 */
public override void handleError(Exception error)
{
	super.handleError(error);

	// Send notification to administrators
	// Log to external system
	// Create case for follow-up
}

Logging Best Practices

Good logs are what let you investigate a failure after it happens, without leaking anything you shouldn't. Three habits cover most cases:

  • Always use the LOG_Builder fluent API (configure the entry with short chained calls, then one call emits it) for application errors:

    apex
    LOG_Builder.build().error(error).emitAt('API_PostExample.prepareRequest');
  • Don't log sensitive data. Use data masks instead, so secrets and personal data never land in your logs.

  • Use the right log level so urgent problems stand out from routine noise:

    • LOG_Builder.build().error().emitAt() - Errors requiring attention
    • LOG_Builder.build().warn().emitAt() - Potential issues
    • LOG_Builder.build().info().emitAt() - Informational messages
    • LOG_Builder.build().debug().emitAt() - Detailed debugging (disabled in prod)

Inbound API Architecture

For endpoints that other systems call into Salesforce, keep the routing in one class and the business logic in another. That split is what lets one endpoint serve several operations and lets you test your logic on its own. Use the two-class URL prefix pattern:

DO:

  • Separate REST routing (REST_* classes) from business logic (API_* classes)
  • Use @RestResource only on REST routing classes
  • Delegate to API_Dispatcher.processInboundService()
  • Keep REST routing classes minimal (only routing, no business logic)
  • One REST class can handle multiple HTTP methods (GET, POST, PATCH, etc.)

DON'T:

  • Put @RestResource directly on API implementation classes
  • Mix routing logic with business logic in the same class
  • Manually instantiate API classes (e.g., new API_Echo().handleRequest())
  • Create separate REST classes for each HTTP method on the same endpoint

Benefits:

  • Better separation of concerns
  • Easier to maintain and test
  • One REST endpoint can support multiple operations
  • API implementations can be reused at different endpoints

Security Considerations

A few habits keep integrations safe by default and out of the headlines:

  • Never hardcode credentials. Use Named Credentials, so secrets live in secure configuration, not in your source.
  • Validate all inputs in getValidationErrors(), so bad or malicious data is rejected before it reaches your logic.
  • Use HTTPS for all external endpoints, so traffic is encrypted in transit.
  • Mask sensitive fields, so personal data and secrets stay out of your logs.
  • Choose the right sharing model (with sharing vs without sharing) so each integration sees only the records it should.

Performance Optimization

These habits keep integrations fast and clear of governor limits as data volumes grow:

  • Use asynchronous callouts for long-running operations, so users aren't left waiting.
  • Batch multiple API calls when possible, so you make fewer round trips.
  • Add pagination for large result sets, so a single response never has to carry everything at once.
  • Set sensible timeouts, so a slow endpoint fails fast instead of hanging.
  • Avoid SOQL in loops in prepareRequest(), so you don't exhaust query limits.

Understanding the Orchestration

The framework always runs the steps in a fixed order, and that order is what keeps you clear of the "uncommitted work pending" error Salesforce throws when a database change happens before a callout:

  1. Callouts happen first (no uncommitted work)
  2. DML happens after all callouts complete
  3. Errors are handled at each stage

Because of that order, you should:

  • Never call DML directly in process() method
  • Always use doInsert(), doUpdate(), doDelete() within onSuccess()
  • Let the framework handle commit timing

Troubleshooting

Common Issues

Each entry below starts with the symptom you are seeing, then walks you to the fix.

API Call is Aborted

Symptom: Queue item shows Status__c = 'Aborted'

Possible Causes:

  • API disabled via feature flag or ApiRuntimeSwitch__c
  • Missing required parameters
  • Missing ApiSetting__mdt record
  • No triggering object when required

Solutions:

  • Check ErrorMessages__c field for specific error
  • Verify ApiRuntimeSwitch__c and feature flag settings
  • Confirm ApiSetting__mdt exists with correct ClassName__c
  • Provide required parameters

Namespace Errors

Symptom: "Type cannot be constructed: API_Outbound"

Possible Cause: You left off the namespace prefix in a managed package context, so the base class can't be found. The same code snippet also trips a second, separate framework convention: every Apex class must declare its sharing mode (with sharing, inherited sharing, or without sharing). Both rules apply independently, so fix both at once.

Solution:

apex
// ❌ WRONG — missing sharing modifier AND namespace prefix (both required in subscriber org)
public class MyAPI extends API_Outbound
{
}

// ✅ CORRECT — explicit sharing modifier AND namespace-prefixed base class
public inherited sharing class MyAPI extends kern.API_Outbound
{
}

Callout Not Executing

Symptom: No HTTP callout occurring

Possible Causes:

  • Mock mode enabled
  • Test.setMock() not called in test
  • Named Credential misconfigured

Solutions:

  • Check IsMockedResponse__c field
  • Verify ApiSetting__mdt.MockingEnabled__c and MockAllInboundAPIs feature flag
  • Ensure Named Credential exists and is accessible

Database Changes Not Committing

Symptom: Records not saved despite successful API call

Possible Causes:

  • Not calling doInsert() / doUpdate() within onSuccess()
  • Calling DML directly instead of using framework
  • Exception during commit

Solutions:

  • Use doInsert(), doUpdate(), doDelete() within onSuccess() instead of direct DML
  • Check debug logs for commit errors
  • Verify object permissions (CRUD/FLS)

Callout from Triggers Error

Symptom: "Callout from triggers with uncommitted work pending is not allowed"

Possible Cause: Attempting to make callout with uncommitted DML

Solution: The framework already handles this for you. Make sure you're going through API_Dispatcher.execute(), which runs the callouts before saving any records, so the uncommitted-work error never fires.


Support and Resources

When you need a worked example to copy, or you hit a failure and want to understand what went wrong, start here. This section points you to the example classes that ship with the framework and the records and logs that show you what actually happened on each call.

Code Examples in Framework

The framework ships with example API classes you can read and adapt. Each one shows a different direction and complexity, so you can find the closest match to what you are building:

  • API_Echo - Simple inbound example
  • API_PostExample - Outbound POST with DTOs
  • API_GetPwndPasswords - Outbound GET with parameters
  • API_CallCurrentOrg - Intra-org API base class
  • API_SendMail - Complex outbound with nested DTOs (in examples)
  • API_PersonRetrieve - Inbound GET with validation (in examples)
  • API_PersonUpdate - Inbound PATCH with DML (in examples)

Getting Help

When an integration misbehaves, the evidence is usually already captured for you. Work through these in order:

  • Check debug logs for detailed error messages
  • Review ApiCall__c records for request/response details, so you can see exactly what was sent and what came back
  • Search for similar patterns in existing API classes
  • Consult with the platform team

Document Version History

VersionDateAuthorChanges
1.0November 2024Framework TeamInitial guide
2.0January 2025Framework TeamAdded managed package namespace, orchestration pattern, real-world examples
2.1January 2025Framework TeamApplied code standards with ApexDoc, detailed method explanations, intra-org API documentation, factual retry mechanism, removed fictional references
2.2January 2025Framework TeamAdded custom retry strategy documentation, clarified LINEAR_BACKOFF default behavior, documented createRetryStrategy() extensibility pattern
3.0February 2026Framework TeamUpdated all class names to current framework conventions (API_Outbound, API_Inbound, API_Dispatcher, TST_Builder, TST_Factory, SEL_ApiCall, etc.). Added reference links, Salesforce doc links, and rebuilt TOC.
3.1March 2026Framework TeamPre-release sweep: fixed REST routing class prefix (URL_* to REST_*), corrected all ApiCall__c and ApiSetting__mdt field names, fixed UTIL_Retry.Strategy and UTIL_CircuitBreaker.Breaker interface names, fixed SEL_ApiCall instance method calls, corrected TOC anchors and numbering, added missing sharing declarations and @IsTest annotations.
3.2March 2026Framework TeamThird-pass review: replaced fictitious abortRequest() method with actual getValidationErrors() throughout (Virtual Methods Reference, all code examples), fixed requestParameters property references to inputs, fixed UTIL_HttpClient delegation mode example (useHandler is static entry point), renamed populateRequestDTO to prepareRequest (actual method name), corrected getHttpMethod return type from String to HttpMethod enum, fixed HTTP_VERB_GET references to HttpMethod.GET, corrected getServiceName signature (protected, not global), removed non-existent API_PostExampleMock from flow test, fixed missing private modifiers on test patterns, replaced inline DML with DML_Builder, corrected API_PersonUpdate description from PUT to PATCH.
3.3March 2026Framework TeamFourth-pass review: added missing H4 TOC entries for Safe Mode (Usage, Behavior, Use Cases), API Test Harness (Features, Deployment), and Best Practices for Flow Integration (5 sub-headings). Removed fictional API_NotifyAccountCreated_Mock and unnecessary Test.setMock from async flow test example.
3.4March 2026Framework TeamDocumented delegation mode caller override precedence for UTIL_HttpClient: credential, path, retry, circuit breaker, and failure logging settings provided by the caller take precedence over the handler's ApiSetting__mdt defaults.

  • Logging - Guide - Automatic API logging, correlation tracking, and performance monitoring
  • Triggers - Guide - Trigger-based API callout orchestration via API_Dispatcher
  • DML - Guide - Unit of Work pattern used in onSuccess()
  • Selectors - Guide - Query patterns for DTO population and validation
  • Validation - Guide - Input validation patterns via getValidationErrors()
  • UTIL_HttpClient - Fluent HTTP client for ad-hoc calls with automatic retry, logging, and circuit breaker