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/HttpRequestclasses 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.
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
- What problem does this solve?
- Mental model
- Use this when
- Don't use this when
- Quick Start
- Quick Navigation
- What is it?
- How does it work?
- What are the moving parts?
- How do I use it in a managed package?
- Building Outbound APIs
- Building Inbound APIs
- Intra-Org API Calls
- Virtual Methods Reference
- What else can it do?
- How do I call it from a Flow?
- Logging and Monitoring
- Testing
- Capability Matrix (for Analysts)
- Anti-Patterns
- Best Practices
- Troubleshooting
- Support and Resources
- Related Documentation
Quick Navigation
| I am a... | I need to... | Go to... |
|---|---|---|
| Architect | Understand API architecture | How does it work? |
| Architect | Review advanced features | What else can it do? |
| Developer | Build my first API | Quick Start |
| Developer | Build outbound APIs | Building Outbound APIs |
| Developer | Build inbound APIs | Building Inbound APIs |
| Analyst | Configure API settings | Capability Matrix |
| Analyst | Monitor API health | Logging 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 extendingAPI_Outbound), 10DTO_*classes, 2 named credentials, and full lifecycle tracking viaApiCall__candApiIssue__c. IncludesUTIL_HttpClientfor 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:
| Scenario | Recommended Approach |
|---|---|
| Simple ad-hoc HTTP calls (GET, POST, PUT, DELETE, PATCH) | UTIL_HttpClient |
| Complex integrations with DTOs, validation, or orchestration | API_Outbound |
| Quick prototypes or one-off calls that still need logging | UTIL_HttpClient |
| Subscriber handlers with custom processing logic | UTIL_HttpClient with useHandler() |
Ad-hoc mode: direct calls with a Named Credential (or ApiCredential__mdt DeveloperName) and URL path:
// 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():
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 method | Overrides |
|---|---|
.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:
- HttpRequest / HttpResponse - Manual HTTP callout classes for custom integrations
- Http.send() - Send HTTP requests directly
- RestContext - Handle inbound REST requests (@RestResource annotation)
- 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)
| Feature | KernDX Web Services Framework | OOTB HttpRequest/Response | OOTB 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 pattern | ✅ HttpCalloutMock interface | ⚠️ Cannot mock in unit tests |
| Performance Metrics | ✅ Automatic timing (callout, processing, total) | ❌ Manual tracking | ❌ No built-in metrics |
| Error Tracking | ✅ ApiIssue__c for retry management | ❌ Manual implementation | ⚠️ Error captured in debug logs |
| Callout/DML Orchestration | ✅ API_Dispatcher pattern | ❌ Manual code structure | ⚠️ Flow handles sequencing |
| DTO Pattern | ✅ DTO_JsonBase | ❌ Manual DTO creation | ✅ Auto-generated from OpenAPI |
| Named Credentials | ✅ Via ApiCredential__mdt | ✅ callout: syntax | ✅ Direct integration |
| Flow Integration | ✅ Built-in invocable methods | ❌ Must create invocables | ✅ Auto-generated invocable actions |
| Metadata Configuration | ✅ ApiSetting__mdt | ❌ Hardcoded or Custom Settings | ✅ External Service registration |
| Circuit Breaker | ✅ UTIL_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:
- All HTTP callouts run first (so there is never unsaved work pending when a callout happens)
- All database changes are saved afterwards, once the callouts have completed
- 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.
+---------------------------------------------------------------------------+
| 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.
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:
- Template Method Pattern: Base classes define the flow; child classes override specific steps
- Unit of Work Pattern: Database changes are batched and committed together, registered as a set and saved all-or-nothing
- 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
- Factory Pattern: Mock objects and API handlers created via factories
- Strategy Pattern: Different behaviors for inbound vs outbound
- 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)
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)
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
| Field | Description |
|---|---|
ServiceName__c | API class name (e.g., API_PostExample) |
Direction__c | Inbound or Outbound |
Status__c | Queued, Completed, Failed, Aborted, Retry, Retrying, Batched |
TriggeringRecordId__c | Record that triggered the API call |
Request__c | Request body (auto-truncated if too large) |
Response__c | Response body (auto-truncated if too large) |
RequestParameters__c | JSON serialized parameters |
URL__c | Endpoint URL |
StatusCode__c | HTTP status code |
ErrorMessages__c | Error messages if call failed |
NextRetry__c | Scheduled retry datetime (populated when retry is needed) |
Retries__c | Number of retry attempts made |
IsMockedResponse__c | Indicates if response was mocked |
CalloutDurationMs__c | Time spent in HTTP callout (milliseconds) |
HandlerDurationMs__c | Time spent in handler logic |
CommitDurationMs__c | Time spent committing DML |
TotalDurationMs__c | Total end-to-end time |
LoggerContext__c | Serialized 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:
| Field | Description |
|---|---|
ClassName__c | API class name (must match exactly) |
EndpointPath__c | API endpoint path (appended to Named Credential) |
ApiCredential__c | Lookup to credentials metadata |
IsActive__c | Whether the API service is active (default: true). Inactive services abort with an error. |
RequiredFeatureFlag__c | MetadataRelationship lookup to FeatureFlag__mdt, required for the service to execute |
BypassFeatureFlag__c | MetadataRelationship lookup to FeatureFlag__mdt, bypasses the service when enabled |
MaxRetryCount__c | Maximum retry attempts (e.g., 3) |
RetryBackoffSeconds__c | Seconds to wait before first retry (e.g., 30) |
CircuitBreakerFailureThreshold__c | Number of consecutive failures before circuit opens (e.g., 5) |
CircuitBreakerTimeout__c | Seconds to keep circuit open before testing recovery (e.g., 60) |
CircuitBreakerSuccessThreshold__c | Number of consecutive successes in half-open state to close the circuit (e.g., 2) |
LogIssues__c | Create ApiIssue__c records |
ResolveIssues__c | Auto-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:
IsActive__c: Iffalse, the service aborts with error: "API service is inactive"RequiredFeatureFlag__c: If the Feature Flag is not enabled for the running user, the service aborts with error: "Required feature flag is not enabled"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:
| Field | Description |
|---|---|
Mode__c | Regex, JsonKey (picklist label "JSON by Key"), ExactMatch, or CreditCard (the pattern match plus a Luhn checksum) |
Pattern__c | Regex pattern, JSON key regex, or literal string (depending on Mode) |
Replacement__c | Replacement text (e.g., [CARD_REDACTED]) |
CaseSensitive__c | Toggle case-insensitive matching |
FailureAction__c | LogAndContinue, WriteFailureMarker, or BlockDml when a pattern throws |
IsActive__c | Enable/disable rule globally |
MinInputLength__c | Optional minimum input length, rule skipped for values shorter than this (zero-cost short-circuit for short fields like URLs) |
ApplicableFieldTypes__c | Optional 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:
| Field | Description |
|---|---|
Rule__c | The MaskingRule__mdt to apply |
SObjectType__c | Target SObject (e.g., ApiCall__c) |
Field__c | Specific field path, or blank for a wildcard across every text field |
CallerClass__c | Optional scope: only fire when the caller class name matches |
IsActive__c | Enable/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.
| Field | Description |
|---|---|
DisableAllApis__c | Disable 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 Credential | Label | Endpoint | Authentication | Used by |
|---|---|---|---|---|
API_ExampleRestApi | Example REST API | https://jsonplaceholder.typicode.com | Anonymous (none) | The outbound REST example API_PostExample |
API_PwndPasswords | Pwnd Passwords | https://api.pwnedpasswords.com | Anonymous (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:
// ✅ 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 callsserialize()on your DTO to turn it into JSON to send out (outbound requests)Deserializable='always'- Required when the managed package callsdeserialize()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)
@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)
@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)
- Create resolver class:
/**
* @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;
}
}Register in
ClassTypeResolver__mdt:- Label: Custom Type Resolver
- DeveloperName: CustomTypeResolver
- ClassName__c: CustomTypeResolver
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:
System.JSONException: Type cannot be deserialized as it is not globally visible - DTO_RequestComplete Example with Namespace
/**
* @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:
// ❌ 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
// 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
- REST API: Extend
API_Outbound - Intra-Org REST API: Extend
API_CallCurrentOrg(for calling Salesforce APIs from same org)
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.
/**
* @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.
/**
* @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:
| Field | Value |
|---|---|
| Label | API Send Mail |
| ClassName__c | API_SendMail |
| EndpointPath__c | /api/v1/send |
| ApiCredential__c | (Lookup to credential) |
| MaxRetryCount__c | 3 |
| RetryBackoffSeconds__c | 30 |
| LogIssues__c | true |
| ResolveIssues__c | true |
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:
// 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:
- REST Routing Class (
REST_*prefix): carries the@RestResourceannotation and decides which HTTP method (GET, POST, and so on) goes where. It does no business logic. - API Implementation Class (
API_*prefix): holds your business logic and extendsAPI_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
/**
* @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
@RestResourceand set itsUrlMapping, which is the URL the endpoint answers on. - Each method must be
global staticand 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
globalaccess modifier (@RestResource requires it). - Keep this class to routing only. No business logic belongs here.
Step 2: Create the API Implementation Class
/**
* @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
@RestResourceannotation here. That belongs on the URL routing class. - Use the
publicaccess 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
/**
* @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
/**
* @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
/**
* @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 Record | ClassName__c | Routing Method |
|---|---|---|
ApiSetting.GetPerson | API_PersonRetrieve | @HttpGet getPerson() |
ApiSetting.EditPerson | API_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 classREST_Person- Resource routing classREST_Invoices- Domain-specific routing class
API Implementation Classes:
- Pattern:
API_*or[Domain]_API_[Operation][Resource] - Examples:
API_Echo- Simple implementationAPI_PersonRetrieve- Domain + Operation + ResourceAPI_PersonUpdate- Domain + Operation + ResourceDOMAIN_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
/**
* @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.
// 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:
| Field | Value |
|---|---|
| ClassName__c | API_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_CallCurrentOrgsigns 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: extendAPI_Outboundwith a Named Credential (a stored, reusable connection definition) or anApiCredential__mdtrecord, rather thanAPI_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.
withRetrycannot 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:
/**
* @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:
/**
* @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:
/**
* @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:
/**
* @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:
/**
* @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:
/**
* @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-id→userId) - Special characters incompatible with Apex
Parameters:
searchTokens- List to populate with strings to findreplaceTokens- List to populate with replacement strings
Example:
/**
* @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:
/**
* @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:
/**
* @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:
/**
* @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:
/**
* @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:
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:
/**
* @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:
/**
* @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:
/**
* @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:
/**
* @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:
- Checks if
MaxRetryCount__cis configured and not exceeded - Calculates the next retry time using linear backoff (default strategy):
- Each retry uses
RetryBackoffSeconds__cseconds as the delay - Example: With
RetryBackoffSeconds__c = 30, all retries wait 30 seconds
- Each retry uses
- Sets
NextRetry__cdatetime field - Sets
Status__cto'Retry' - Increments
Retries__ccounter
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:
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 viaApiSetting__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:
| Field | Description | Example |
|---|---|---|
CircuitBreakerFailureThreshold__c | Consecutive failures before opening circuit | 5 |
CircuitBreakerTimeout__c | Seconds to keep circuit open before testing recovery | 60 |
CircuitBreakerSuccessThreshold__c | Consecutive successes in half-open state to close circuit | 2 |
Example Configuration:
DeveloperName: SendGrid_Email_API
ClassName__c: API_SendGridEmail
CircuitBreakerFailureThreshold__c: 5
CircuitBreakerTimeout__c: 60
CircuitBreakerSuccessThreshold__c: 2Behavior:
- After 5 consecutive failures, circuit opens
- For the next 60 seconds, all calls immediately fail with
CircuitOpenException - After 60 seconds, circuit enters half-open state
- Test requests are allowed to verify recovery
- After 2 consecutive successes, circuit closes (normal operation)
- If a test fails → circuit re-opens for another 60 seconds
State Transitions Example
Scenario: External email API experiencing issues
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 resumedCircuit 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:
// 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 Type | Threshold | Timeout | Rationale |
|---|---|---|---|
| Critical Payment APIs | 2-3 | 300s (5 min) | Low tolerance, long recovery |
| Email/Notification | 5-10 | 60s (1 min) | Higher tolerance, quick retry |
| Internal Salesforce | 3-5 | 30s | Fast recovery expected |
| Rate-Limited APIs | 5 | 60s | Match 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)
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:
- Circuit breaker checks state (CLOSED = proceed, OPEN = reject immediately)
- If CLOSED, retry strategy attempts call (up to 3 times with backoff)
- If all retries fail, circuit breaker increments failure count
- When threshold reached, circuit opens
3. Monitor Circuit State
Check ApiCall__c for circuit-related failures:
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 DESC4. Environment-Specific Configuration
Use different thresholds per environment:
Development/Sandbox (more tolerant for testing):
CircuitBreakerFailureThreshold__c: 10
CircuitBreakerTimeout__c: 30Production (stricter for reliability):
CircuitBreakerFailureThreshold__c: 3
CircuitBreakerTimeout__c: 3005. Handle Circuit Open Exceptions Gracefully
When circuit is open, the framework sets Status__c = 'Aborted' and logs the error. Implement fallback logic:
// 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:
// 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:
- Check external service status
- Wait for timeout period to expire
- Circuit will automatically test recovery
- 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:
| Field | Value |
|---|---|
Rule__c | MaskSsn |
SObjectType__c | ApiCall__c |
Field__c | blank (wildcard across every text field) |
IsActive__c | true |
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:
- Memory mocks:
API_MockFactory.forService()/API_MockFactory.registerErrorMock()(highest priority) - Metadata mocks:
ApiMock__mdtcustom metadata records defaultMockBody: JSON string set on the handler class inconfigure()
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():
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:
// 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):
| Method | Description |
|---|---|
.body(String) | Response body (supports {{request.field}} 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):
// 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 {{request.field}} placeholders into the mock body and the framework fills them from the actual request:
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:
{{request.fieldName}}: Top-level field{{request.nested.field}}: 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:
// 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:
| Field | Description |
|---|---|
CalloutDurationMs__c | Time spent in HTTP callout (milliseconds) |
HandlerDurationMs__c | Time spent in handler logic |
CommitDurationMs__c | Database commit time |
TotalDurationMs__c | Total end-to-end time |
Access in code:
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:
// Schedule batched callout processing.
System.schedule
(
'Process Batched API Calls',
'0 */5 * * * ?',
new SCHED_PerformBatchedCallouts()
);How It Works:
- Create queue items with status
'Batched' - Scheduled job changes status to
'Queued' - Platform event or async processor picks up queued items
- 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
@IsTestmethods withTest.startTest()/Test.stopTest()and let the test-mode rollback do the work. To exercise an outbound API without making a real callout, mock it viakern.API_MockFactory.forService(serviceName).body(json).register(). Both give you a clean dry run, and both run in your own namespace. The framework relies onAPI_Base.enterSafeMode(),API_Base.isSafeModeActive(), andAPI_Base.SafeModeContextinternally for the same effect (shown below for reference).
Framework usage (reference)
// 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:
| Method | Visibility | Description |
|---|---|---|
API_Base.enterSafeMode() | public | Returns a SafeModeContext, must be closed via try/finally |
API_Base.isSafeModeActive() | public | Check if any Safe Mode scope is currently active |
safeMode.close() | public | Close the scope (idempotent, safe to call multiple times) |
Behavior
| Aspect | Behavior |
|---|---|
| DML Operations | Executed then rolled back |
| Triggers | Fire normally |
| Validation Rules | Execute and can block |
| Workflows/Flows | Execute but rolled back |
| HTTP Callouts | Blocked with mock response |
| Emails | NOT rolled back |
| Platform Events | Rolled back (deferred publishing) |
| Governor Limits | Consumed as normal |
ApiCall__c logs | Rolled 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
apiTestHarnessFormLWC 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__mdtfiltered 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:warningicon 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__crecord 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 formApiTestHarness.tab-meta.xml: the Custom Tab referencing the FlexiPageAdministrator.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 quicklyIdempotencyKeyBodyHash__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:trueif the answer was returned from an earlier call's record without re-running the handler;falsefor 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 scenario | Stored hash on existing record | New request body hash | Framework response |
|---|---|---|---|
| Same key, same body (network retry, idempotent replay) | matches | matches | HTTP 200 + cached response from Response__c |
| Same key, different body (caller bug or stale-edit retry) | populated | differs | HTTP 409 with JSON body referencing the original ApiCall.Id |
| Same key, legacy record (created before body-hash detection shipped) | null | any | HTTP 200 + cached response (legacy passthrough, see "Backward compatibility") |
The HTTP 409 response body shape:
{
"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:
- Investigate the divergence: fetch the original record via
kern__ApiCall__c.Id = :originalApiCallIdand compare the storedRequest__cagainst the new payload. Most often the caller has buggy retry logic that mutates the request body before retrying. - 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. - 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:
// 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:
Create Screen - Collect user input
Screen: Enter Details - Text Input: Title → {!title}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}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 screenScreen: Success - Display result
Display Text: "API Response: {!apiResponse}"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:
| Parameter | Type | Required | Description | Example |
|---|---|---|---|---|
webServiceClassName | String | ✅ | API class name | API_PostExample |
recordId | String | ❌ | Record ID (if API needs record context) | {!recordId} or blank |
parameters | String | ❌ | Comma-separated name=value pairs | foo=Test Title |
inputDelimiter | String | ❌ | Delimiter for parameters (default: ,) | , or ; |
extractPath | String | ❌ | JSON path to extract specific element | title |
Output Variables:
| Output | Type | Description | Example Value |
|---|---|---|---|
success | Boolean | Whether API call succeeded | true or false |
responseBody | String | Full API response or error message | {"body":"Some Random Body","id":101,"title":"Test Title","userId":9} |
queueItemId | String | ID of logged ApiCall__c record | a0X5g000000AbCD |
extractedValue | String | Extracted 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:
| Flow | Trigger | Purpose |
|---|---|---|
ResetOutboundApiCall | Before Save | Clears execution output fields (zero DML) when an ApiCall__c is set to Queued |
InvokeOutboundApiCall | After Save | Invokes 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
InvokeOutboundApiCallflow callsFLOW_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:
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 RecordsCreate 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__cto monitor progress - Framework automatically retries failed calls based on
ApiSetting__mdtconfiguration - 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:
| Aspect | Synchronous | Asynchronous |
|---|---|---|
| Execution | Blocks until complete | Returns immediately |
| Use From | Screen Flows, Manual Invocation | Record-Triggered Flows (after-save), Scheduled Flows |
| Response Available | Yes, immediately | No (logged to ApiCall__c) |
| Max Duration | 120 seconds | No practical limit (runs in Queueable/Batch) |
| Error Handling | Must handle in flow | Automatic retry via framework |
| User Experience | User waits for response | User not blocked |
| Governor Limits | Counts against transaction limits | Separate async limits |
| Best For | Real-time validation, Interactive forms | Background sync, Notifications, Data enrichment |
| Retry Support | Manual only | Automatic 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.
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.
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.
✅ 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:
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
@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
@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
| Component | Location | Details |
|---|---|---|
| All API Calls | ApiCall__c | Request, response, status, timing |
| Large Payloads | ContentVersion | Files linked to queue item |
| Errors | ApiCall__c.ErrorMessages__c | Error messages and stack traces |
| Failures | ApiIssue__c | Persistent failure records (if enabled) |
| Application Logs | LogEntryEvent__e Platform Event | Via 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:
| Field | When Set | Description |
|---|---|---|
serviceName | configure() | The API handler class name |
endpoint | configure() | The endpoint URL being called |
httpMethod | configure() | The HTTP method (GET, POST, etc.) |
statusCode | After callout | HTTP response status code (outbound only) |
calloutDurationMs | After callout | Callout 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:
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:
// 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 debuggingContext 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
SELECT Name, ServiceName__c, Status__c, StatusCode__c, CreatedDate, ErrorMessages__c
FROM ApiCall__c
WHERE CreatedDate = LAST_N_DAYS:7
ORDER BY CreatedDate DESCDashboard 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:
// 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 Class | Purpose | Used For |
|---|---|---|
API_OutboundTestHelper | Test outbound REST APIs | Successful calls, failed calls, aborted calls, parse failures |
API_InboundTestHelper | Test inbound REST APIs | Setting up REST context, validating aborted calls |
SEL_ApiCall | Assert queue item status | Verifying service completion, abortion, or failure |
TST_Factory | Create test data | Generating ApiCall__c records for testing |
TST_Builder | Build test records | Creating 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:
| Method | Purpose | Returns |
|---|---|---|
assertCallSuccessful(serviceName, recordId) | Test successful API execution | API handler instance |
assertCallSuccessful(serviceName, recordId, parameters) | Test with custom parameters | API handler instance |
assertCallSuccessfulWithTransformation(serviceName, recordId, transformerClassName) | Test with response transformer | API handler instance |
assertCallFailed(queueItems) | Test failed API calls | List of API handlers |
assertCallAborted(serviceName, recordId) | Test aborted calls | API handler instance |
What These Methods Do Automatically:
- Create
ApiCall__cqueue item usingTST_Factory - Execute the API via
API_Dispatcher - Assert the expected status (completed, failed, aborted)
- Return the handler for additional assertions
Complete Outbound Test Example
/**
* @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:
/**
* @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:
| Method | Purpose |
|---|---|
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:
- Creates new
RestRequestandRestResponseobjects - Sets
Content-Typeheader toapplication/json - Sets HTTP method to
POST - Sets default request URI to
https://example.com/test - Assigns to
RestContext.requestandRestContext.response
Complete Inbound Test Example
/**
* @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:
/**
* @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:
- Query
ApiCall__cfor the specified service name - Assert that at least one queue item exists
- Assert that all queue items have the expected status
- Optionally assert error message contains the specified phrase
- Return the queue items for additional assertions
Query Methods:
/**
* @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
- Always use test helper classes -
API_OutboundTestHelperandAPI_InboundTestHelpereliminate boilerplate - Use
TST_Builderfor test data - Cleaner than manual record creation - Test bulk scenarios - Create and process multiple queue items
- Use assertion methods -
assertServiceCompleted(),assertServiceAborted(),assertServiceFailed() - Verify queue items - Always check that
ApiCall__crecords were created correctly - Test error scenarios - Missing parameters, invalid data, disabled APIs
- Use @TestSetup - Create common test data once for multiple test methods
- Test with different users - Use
System.runAs()to test with API users - Enable logging when needed - Set
LOG_Builder.ignoreTestMode = truefor log testing - Test mock responses - Verify that mock classes return expected data
Common Test Patterns
Pattern 1: Simple Successful Outbound Call
@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
@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
@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
@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
@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.
| Capability | Custom Metadata | Field/Class | Notes |
|---|---|---|---|
| API endpoint configuration (inbound) | ApiSetting__mdt | EndpointPath__c | Declarative endpoint configuration for inbound REST handlers |
| API endpoint configuration (outbound) | ApiSetting__mdt | EndpointPath__c (HTTP method via handler's getHttpMethod() override) | Declarative endpoint; HTTP verb is set in the handler class, not the metadata record |
| Credential management | ApiCredential__mdt | Named Credential reference | Secure credential storage and rotation |
| Data masking | MaskingRule__mdt + MaskingTarget__mdt | Mode__c (Regex / JsonKey / ExactMatch / CreditCard), Pattern__c, Replacement__c, MinInputLength__c, ApplicableFieldTypes__c | Shared redaction framework; ships with secrets + credit-card rules active; you opt additional rules in per field |
| Mock mode | ApiSetting__mdt | MockingEnabled__c | Enable mock responses without callouts |
| API disable switch | ApiRuntimeSwitch__c | DisableAllApis__c | Emergency kill switch for all API calls (hierarchy) |
| Retry strategy | ApiSetting__mdt | MaxRetryCount__c, RetryBackoffSeconds__c | Configurable retry with linear or exponential backoff |
| Circuit breaker | ApiSetting__mdt | CircuitBreakerEnabled__c | Automatic circuit breaker protection |
| Performance logging | Automatic | ApiCall__c.TotalDurationMs__c | All API calls are automatically timed and logged |
| Batched callouts | ScheduledJob__c | SCHED_PerformBatchedCallouts | Process 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-Pattern | Why It's Wrong | Instead |
|---|---|---|
Raw HttpRequest/HttpResponse without the framework | No logging, no retry, no data masking, no mock support | Extend API_Outbound or API_Inbound and let the orchestrator handle the lifecycle |
| DML before callout in the same transaction | Salesforce throws CalloutException; you cannot make callouts after DML | Use the framework's orchestration pattern: callout first, then onSuccess() for DML registration |
| Hardcoding endpoints or credentials in Apex | Cannot change per environment, fails security review, blocks testing | Use ApiSetting__mdt + Named Credentials; override getWebServiceEndPoint() only when dynamic |
| Overriding every virtual method | Makes the class brittle and harder to maintain when the framework evolves | Override only what differs from the defaults (see Override Only What You Need) |
Skipping @JsonAccess on DTOs in subscriber orgs | Serialization fails at runtime with a security error in managed package context | Always 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_*(extendsAPI_CallCurrentOrg) - Mocks:
API_*Mock
Inbound APIs (Two-Class Pattern):
- REST Routing Classes:
REST_*- Examples:
REST_Echo,REST_Person,REST_Invoices
- Examples:
- API Implementation Classes:
API_*- Examples:
API_Echo,API_PersonRetrieve,API_PersonUpdate
- Examples:
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:
// ❌ 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:
/**
* @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:
apexLOG_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 attentionLOG_Builder.build().warn().emitAt()- Potential issuesLOG_Builder.build().info().emitAt()- Informational messagesLOG_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
@RestResourceonly 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
@RestResourcedirectly 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 sharingvswithout 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:
- Callouts happen first (no uncommitted work)
- DML happens after all callouts complete
- Errors are handled at each stage
Because of that order, you should:
- Never call DML directly in
process()method - Always use
doInsert(),doUpdate(),doDelete()withinonSuccess() - 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__cfield for specific error - Verify
ApiRuntimeSwitch__cand 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:
// ❌ 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__cfield - Verify
ApiSetting__mdt.MockingEnabled__candMockAllInboundAPIsfeature 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()withinonSuccess() - Calling DML directly instead of using framework
- Exception during commit
Solutions:
- Use
doInsert(),doUpdate(),doDelete()withinonSuccess()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 exampleAPI_PostExample- Outbound POST with DTOsAPI_GetPwndPasswords- Outbound GET with parametersAPI_CallCurrentOrg- Intra-org API base classAPI_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__crecords 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
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | November 2024 | Framework Team | Initial guide |
| 2.0 | January 2025 | Framework Team | Added managed package namespace, orchestration pattern, real-world examples |
| 2.1 | January 2025 | Framework Team | Applied code standards with ApexDoc, detailed method explanations, intra-org API documentation, factual retry mechanism, removed fictional references |
| 2.2 | January 2025 | Framework Team | Added custom retry strategy documentation, clarified LINEAR_BACKOFF default behavior, documented createRetryStrategy() extensibility pattern |
| 3.0 | February 2026 | Framework Team | Updated 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.1 | March 2026 | Framework Team | Pre-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.2 | March 2026 | Framework Team | Third-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.3 | March 2026 | Framework Team | Fourth-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.4 | March 2026 | Framework Team | Documented 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. |
Related Documentation
- 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