Custom Apex REST
Module 5 of 7 in the chapter Salesforce as an API Provider, chapter 8 of the Integration Architect course. Each module stands on its own. Previous: GraphQL and the UI API · Next: When Someone Calls You Back
The question this module answers: When should a caller use Salesforce’s standard API, and when should you build your own door with a custom Apex REST service?
Where this module sits on the map.
Every door so far has been Salesforce’s: REST, composite, GraphQL, the UI API. The caller learns your org and uses Salesforce’s standard calls against it. There’s another choice. You write the door yourself, in Apex, with a contract you design: send me this, and I’ll answer with that.
This is one of the first big decisions on an integration project. It decides how much the other team has to know about your org, and who fixes what when it breaks.
The Standard Door: They Need All the Details
Section titled “The Standard Door: They Need All the Details”Take the team building the integration layer for Redrock. If they call Salesforce’s standard API, they need to know all the details. What are your objects? What are your fields? In what order do they write them, a manifest before its Work Orders before their line items? They work almost as if they were Apex developers inside your org: they follow all your rules, and they handle the timeouts.
That has real advantages. Nobody on your side writes code. The standard connectors in every integration tool already speak Salesforce’s API. And the responsibility sits with them: they write the calls, so they own the errors.
But it ties them to your inner workings. Rename a field, split an object, change a picklist value, and their integration breaks. Change one field, and the integrations break. So they end up in your design meetings, because every change you make is a change they have to make.
Your Own Door: They Need the Contract
Section titled “Your Own Door: They Need the Contract”With a custom Apex REST service, they don’t need the details. You write a contract: call this address, pass in this JSON, get back this response. Something like “save manifest” and “get delivery”. You can even hide the __c from them. Behind the door, your Apex does the work in the right order, against your objects.
Then you can move your Salesforce org around as much as you like. As long as you keep the contract, they’re fine.
On one project, that’s exactly why we built our own doors. There was a lot of complexity in what the org did with each incoming call, and we wanted to hide it from the middleware team. And because every call came through our own code, we could record it. I could time-stamp every incoming call. I knew when it happened. I knew how long it took. I even recorded the payload. We wrote that log asynchronously, so it didn’t slow the call down. And because the callers passed a unique ID on every request, during load testing I could match our log line for line with the performance test results. Chapter 14 designs that log properly: what to keep, for how long, and what not to keep.
The price is that it’s now your code. You need good Apex developers, you test it, and you own its errors.
Who has to know your org: the caller, or only your code.
How to Choose
Section titled “How to Choose”There’s no single right answer. I’ve used both. The question is whether the other team should know your inner workings or not.
| Standard API | Your own door | |
|---|---|---|
| The caller needs to know | Every object, field and rule | The contract |
| A field changes in your org | The caller changes too | Only your Apex changes |
| Who writes the code | The caller | You |
| Who owns the errors | Mostly the caller | Mostly you |
| A log of every call | From Salesforce’s event logs | Your own, as detailed as you design it |
| Salesforce’s version retirements | Apply to the caller | Don’t apply: you version it |
If the integration is simple, just a read or just a write, I’d lean towards the standard API. If there’s a lot of processing behind each call, or several teams depend on the shape of your data staying put, I’d build the door. Steve Trucking chooses its own door for Redrock’s manifests, because what happens to a manifest inside the org (matching, routing, scheduling) will keep changing, and Redrock shouldn’t have to.
Building the Door, One Step at a Time
Section titled “Building the Door, One Step at a Time”In Apex, a class becomes a door with one annotation, @RestResource, which gives the door its address. Each method answers one verb, @HttpGet or @HttpPost, and the class and methods are global (Introduction to Apex REST (opens in a new tab), Apex Developer Guide, checked 2026-10-31). The caller reaches it at /services/apexrest/ plus your address, with the same token and integration user as any other door.
When I teach this, I build the door up in five steps, each one a little more than the last:
- A GET that returns a list of sObjects. Query the accounts and return them. It works, and the caller gets Salesforce’s standard JSON. But if all you need is a simple query, you don’t need a custom door at all; the standard API already does that.
- A GET that returns a response class. Instead of the sObjects, return a light Apex class: a customer with just the ID and the name. Now I control exactly what goes back to the client. That response could be a contract I build with the external team.
- A GET with a parameter. Read the parameters from
RestContext.request.params, a map, and use one in the query’s WHERE clause. Now the caller asks for one customer. - A POST with a request class and a response class. The caller sends a customer in; my code turns the JSON into a request class with one line, creates the account, and answers with a response class: a status, and a list of errors if there are any.
- A POST that returns an error. The same as step 4, with the failure caught and the HTTP status code set on the response, so the caller can tell from the status alone that it failed.
Steps 4 and 5 are the door you actually build. Here’s step 4, from my own demo code:
@RestResource(urlMapping='/CustomWS4/*')global with sharing class STA_CustomWS_4 { @HttpPost global static STA_CreateCustomerResponse createCustomer() { STA_CreateCustomerResponse resp = new STA_CreateCustomerResponse();
// Read the incoming request payload String payload = RestContext.request.requestBody != null ? RestContext.request.requestBody.toString() : '';
// Parse it into an Apex object STA_CreateCustomerRequest req = STA_CreateCustomerRequest.parse(payload);
Account newAcct = new Account(); newAcct.Name = req.Customer.Name; try { insert newAcct; resp.status = 'success'; } catch (Exception ex) { resp.status = 'error'; resp.errorList = new List<String>{ ex.getMessage() }; } return resp; }}Look at what goes in and what comes out. STA_CreateCustomerRequest in, STA_CreateCustomerResponse out. Neither one is an Account.
The Contract Is Its Own Classes
Section titled “The Contract Is Its Own Classes”Those two classes are the contract. They’re small classes defined in Apex that represent only the structures coming in and the structures going out, and nothing else.
Apex REST will let you put sObjects straight into the contract, an Account in and an Account out. Step 1 did exactly that. Don’t build on it. The moment an sObject is in the contract, your data model is in the contract. The caller sees your field names, __c and all. Every field you add or rename changes what they send and receive. And a caller can send fields you never meant to accept, like an owner or a status, and your code has to remember to ignore them. That’s the standard door’s problem, back again through your own door.
For Steve Trucking’s manifest door, the contract is four light classes:
// What comes inglobal class ManifestRequest { global String manifestKey; // REDROCK-20261031 global Date deliveryDate; global List<DeliveryIn> deliveries;
global static ManifestRequest parse(String json) { return (ManifestRequest) System.JSON.deserialize(json, ManifestRequest.class); }}global class DeliveryIn { global String deliveryKey; // REDROCK-8472914-1 global String distributionCenter; global String recipientName; global List<String> items;}
// What goes outglobal class ManifestResult { global String manifestKey; global Integer accepted; global List<DeliveryResult> deliveries;}global class DeliveryResult { global String deliveryKey; global String status; // ACCEPTED or REJECTED global String problemType; // set only when rejected}That parse method is one line, and it’s the approach from my blog post JSON Jamboree: Fixed vs. Dynamic Deserialization: when the JSON’s structure is straightforward and doesn’t change often, write an Apex class that matches it exactly and deserialize it in one line. A contract is exactly that kind of structure: one you’ve agreed not to change. (The post shows the other two approaches, a hand-written parser and mappings kept in custom metadata, for the times you’re reading someone else’s JSON that won’t hold still.)
The door itself, /services/apexrest/manifests/v1/, reads the body, calls ManifestRequest.parse, and maps the request onto your objects: deliveryKey becomes Partner_Delivery_Key__c, distributionCenter is looked up and becomes a reference, items become Work Order Line Items. Then it builds the result. The caller never sees those names. When you change your data model, you change the mapping, not the contract.
Salesforce’s guide gives you a second way to take the request: declare the class as the method’s parameter, and Apex REST “deserializes request data into” it for you (Apex REST Methods (opens in a new tab), checked 2026-10-31). It’s shorter, but the parameter’s name becomes part of the body: a parameter called manifest means the caller sends { "manifest": { ... } }. Reading the body yourself and parsing it, as in my demo, keeps the body’s shape entirely yours. Either way, two rules from the same page hold:
- Only the types Apex REST allows: primitives, lists, maps with String keys, and your own classes made of them.
- No circles. If two of your classes point at each other, Salesforce answers the call with a
400at run time. A contract is a tree: a manifest holds deliveries, a delivery holds items.
The classes also make the versions clean. v1’s classes are v1’s contract. When v2 needs a different shape, it gets its own classes, and v1’s stay exactly as they were until v1 retires.
What an Architect Checks on Every Door
Section titled “What an Architect Checks on Every Door”- The version is in your address. Salesforce doesn’t version this door; its version retirements “do not impact” custom Apex REST services (module 1). So you put
v1in the address yourself, as chapter 3 taught, and when you need a breaking change you publishv2beside it. - It still counts. “Calls to Apex REST classes count against the organization’s API governor limits. All standard Apex governor limits apply” (Introduction to Apex REST (opens in a new tab)). Your door is still one door into the same building.
- Whose access does it run with? Declare the class
with sharing, as my demo does, and query and save in user mode, as Salesforce’s own sample does (WITH USER_MODE,insert as user; Apex REST Basic Code Sample (opens in a new tab), checked 2026-10-31). Then the code sees and changes only what the integration user may. Apex can run with more access than the caller has, and your door shouldn’t hand that access out. - Who can reach the door at all. Salesforce has a separate permission for this, Apex REST Services, which “enables access to Apex REST services even if API Enabled permission is not granted” (Which API Do I Use? (opens in a new tab), Salesforce Help, checked 2026-10-31). So a user who can’t call Salesforce’s standard API can still be allowed through your door. Give it on purpose, to the users who need your door and no other.
- The contract is written down. The request and response classes are the contract in code; publish it the way chapter 3 showed, so the caller doesn’t learn it by trial and error.
Answer Errors the Web’s Way
Section titled “Answer Errors the Web’s Way”When the call fails, what do you send back? Salesforce’s own APIs answer errors in Salesforce’s own format. With your own door, you choose, and it’s worth choosing a standard instead of inventing one.
The web has one: Problem Details for HTTP APIs, RFC 9457, an IETF standard since July 2023. It defines “a ‘problem detail’ to carry machine-readable details of errors in HTTP response content to avoid the need to define new error response formats for HTTP APIs” (RFC 9457 (opens in a new tab), checked 2026-10-31). The answer has its own content type, application/problem+json, and a few standard fields:
{ "type": "https://steve-trucking.example/problems/unknown-distribution-center", "title": "Unknown distribution center", "status": 422, "detail": "Delivery REDROCK-8472914-1 names distribution center PHX-9, which doesn't exist.", "instance": "/services/apexrest/manifests/v1/REDROCK-20261031"}Step 5 of my demo set the status code on the response, and that’s the first rule: the HTTP status says it failed, not only a word in the body. Pick it by whose problem it is. If the caller sent something wrong, like a delivery with no distribution center, that’s a 4xx: the caller has to fix it, and sending it again won’t help. If your side failed, that’s a 5xx: the caller can try again later. A 500 for everything tells the caller to retry a request that will never work.
The second rule is the body. status is the HTTP status, the one from chapter 3. type says which kind of problem, so the caller’s code can act on it. detail says what happened, for the person reading the log. Most integration tools already understand the format, and your callers’ code doesn’t need a special case for your door.
Tell Callers When to Come Back
Section titled “Tell Callers When to Come Back”One kind of error deserves its own answer: too many calls. If your door has to slow a caller down, the standard answer is 429 Too Many Requests, with a Retry-After header that says how many seconds to wait. A well-built caller waits that long instead of trying again at once and making it worse.
There’s also a newer way to warn callers before they hit the limit: RateLimit headers that tell the caller how much it has left. It’s still an IETF draft, not a standard (version 11, last updated 2026-05-23, datatracker (opens in a new tab), checked 2026-10-31). Watch it, and don’t build a contract on it yet.
Watch it on video
Section titled “Watch it on video”In 2023 I made a video building five custom Apex REST services, from a simple GET to a POST that returns an error. The note under the video says what’s still right and what’s changed since.
What an Architect Decides Here
Section titled “What an Architect Decides Here”- Standard door or your own. Does the other team need to know your inner workings? Simple read or write: standard. Complex processing, or a data model that will keep changing: your own.
- Who owns errors. Standard: mostly the caller. Your own: mostly you, so plan the log (chapter 14).
- The version in your address, and what happens to
v1whenv2arrives. - Whose access the code runs with. With sharing and user mode, unless there’s a written reason not to.
- The contract’s own classes. Light Apex classes for what comes in and what goes out, never sObjects; the mapping onto your objects stays inside.
- Your error format. Problem Details, with a
typefor each kind of problem your callers need to handle;429withRetry-Afterwhen they must slow down.
Explore
Section titled “Explore”- Introduction to Apex REST (opens in a new tab) and the Apex REST Basic Code Sample (opens in a new tab): Apex Developer Guide (checked 2026-10-31)
- RFC 9457, Problem Details for HTTP APIs (opens in a new tab): IETF, July 2023
- RFC 6585 (opens in a new tab): IETF, where
429 Too Many Requestsis defined - RateLimit header fields for HTTP (opens in a new tab): IETF Internet-Draft, still a draft (checked 2026-10-31)
- The Contract: chapter 3, writing the contract down
- JSON Jamboree: Fixed vs. Dynamic Deserialization: SteveTechArc, my 2023 post on the three ways to turn JSON into Apex
- Salesforce Developers, Apex Developer Guide, Apex REST Methods (opens in a new tab), checked 2026-11-07.
- Salesforce Help, Which API Do I Use? (opens in a new tab), checked 2026-11-07.
