@RestResource annotation turns a regular Apex class into your own custom REST API endpoint inside Salesforce. You get full control over the URL path, the HTTP methods (GET, POST, PATCH), and the exact JSON structure. You should build a custom @RestResource API when standard Salesforce REST APIs require too many separate network calls or when you need to run custom business logic before saving data. Always remember: your code runs using the permissions of the external user calling the API, so their sharing rules and field security apply!
A company's ERP billing system was set up to send new customer orders to a custom Salesforce
@RestResource endpoint. Whenever the internet connection hiccuped, the ERP didn't receive a success message in time, so it automatically sent the exact same order a second time. Because the Salesforce developer used a basic insert statement in Apex, Salesforce created two identical orders for the same customer purchase!❌ The Old / Bad Fix: The team built a nightly Batch Apex job to hunt down and merge duplicate orders. It was slow, fragile, and occasionally merged two real orders from customers who legitimately bought the same item twice in one day.
✅ The Modern / Good Fix: The team updated the Apex endpoint to require a unique Order Number from the ERP and switched the database command from
insert to upsert matched against an External_Id__c field. Now, whenever the ERP retries a timed-out request, Salesforce spots the matching External ID and cleanly updates the existing order instead of creating a duplicate. The nightly cleanup batch was deleted forever, and the ERP team didn't have to change a single line of their retry logic.
🔑 Key Points: Salesforce as the API Provider
When you write a @RestResource class, you are configuring Salesforce as the API Provider (known in integration architecture as the Remote Call-In pattern). Here are the core building blocks you need to know:
- 🌐 The Endpoint URL: Add
@RestResource(urlMapping='/v1/orders/*')to the top of aglobalApex class. Salesforce immediately exposes it athttps://yourInstance.my.salesforce.com/services/apexrest/v1/orders/. - 🛠️ The HTTP Verbs: Use method annotations—
@HttpGet,@HttpPost,@HttpPut,@HttpPatch, and@HttpDelete—onglobal staticmethods to route incoming traffic. You can only use each annotation once per class. - 📦 Raw Request & Response Control: Use
RestContext.requestto read URL query parameters, custom HTTP headers, or the raw JSONrequestBody. UseRestContext.responseto set custom HTTP status codes (like201 Createdor400 Bad Request). - 👤 Integration User Context: Your endpoint executes under the identity of the authenticated calling user (ideally assigned the Salesforce Integration user license and a dedicated Permission Set). If your class uses
with sharingandAccessLevel.USER_MODE, Salesforce automatically enforces their exact Object, Field-Level Security (FLS), and Sharing permissions. - 🔒 Connected Apps vs. External Client Apps: External callers must authenticate via OAuth 2.0 (such as the OAuth 2.0 Client Credentials Flow for server-to-server integrations) using a modern External Client App or Connected App before hitting your endpoint.
- 📏 Request & Response Size Limits: Keep payloads lean! Synchronous Apex REST requests and responses are strictly capped by the 6 MB heap size limit, and each transaction times out after 10 seconds of CPU time.
🧭 The 360 Card: Inbound Apex REST Architecture
📏 The Golden Rule: Design every @RestResource endpoint to be idempotent (safe to retry without creating duplicate data) and bulkified (accepting a JSON list rather than a single record).
🎁 The Gain: You define the exact API contract. You can validate complex business rules, write to multiple unrelated objects in one transaction, and return a custom response payload that standard Salesforce APIs can't match.
💸 The Price: You own the maintenance. Unlike standard Salesforce APIs that upgrade automatically, you must write unit tests, manage URL versioning (/v1/, /v2/), and handle error logging yourself.
🛑 The Limits: Governed by the 6 MB synchronous heap limit, 10,000 DML rows per transaction, 100 SOQL queries, and the org's 24-hour total API request limit. Furthermore, no more than 25 long-running synchronous requests (over 5 seconds) can run at the same time across your entire org.
🚀 At High Volume: Never build an endpoint that processes just one record per HTTP call. Always accept a JSON array (for example, 200 orders per call) so external systems don't burn through your daily API limits or trigger concurrent request errors.
💬 Core Q&A & Interview Prep
Q1: Design a custom inbound REST endpoint that lets an external ERP system create Orders in Salesforce. What are the absolute must-haves?
global with sharing class annotated with @RestResource using a versioned URL like /v1/orders/*. Inside the @HttpPost method, I would deserialize the payload into a list of Data Transfer Objects (DTOs), perform an upsert using an External ID field for idempotency, enforce AccessLevel.USER_MODE for security, and support partial success so one bad record doesn't fail the entire batch."
When an architect or interviewer asks you to design an inbound REST API, they are testing whether you know how to protect Salesforce from bad external data and network glitches. Here are the four pillars your code must show:
- 1. Strongly-Typed DTOs (Wrapper Classes): Instead of parsing raw JSON with messy
Map<String, Object>casts, deserialize the request body directly into a clean list of custom Apex wrapper classes (OrderDto) usingJSON.deserialize(). - 2. Bulk-Safe by Default: Accept a
List<OrderDto>so the caller can send 1 or 200 orders in a single HTTP call. - 3. Safe to Retry (Idempotent): Use
Database.upsert()matched against anExternal_Id__cfield rather thaninsert. - 4. Modern User-Mode Security & Partial Success: Pass
false(allOrNone = false) andAccessLevel.USER_MODEinto yourDatabase.upsertcall. This guarantees that the integration user's FLS and CRUD permissions are respected, and allows valid orders to save even if one order in the batch has missing data.
// 1. Expose the class as a versioned REST endpoint enforcing sharing rules
@RestResource(urlMapping='/v1/orders/*')
global with sharing class OrderApi {
// Wrapper classes (DTOs) define a clean API contract
global class OrderDto {
public String externalOrderId;
public Decimal amount;
public String status;
}
global class OrderResponseDto {
public String externalOrderId;
public Id salesforceId;
public Boolean isSuccess;
public String errorMessage;
}
// 2. Map this method to HTTP POST requests
@HttpPost
global static List<OrderResponseDto> upsertOrders() {
RestRequest req = RestContext.request;
RestResponse res = RestContext.response;
List<OrderResponseDto> responseList = new List<OrderResponseDto>();
try {
// 3. Deserialize JSON array into a strongly-typed Apex list
String jsonBody = req.requestBody != null ? req.requestBody.toString() : '[]';
List<OrderDto> incomingOrders = (List<OrderDto>) JSON.deserialize(
jsonBody,
List<OrderDto>.class
);
List<Order__c> ordersToUpsert = new List<Order__c>();
for (OrderDto dto : incomingOrders) {
ordersToUpsert.add(new Order__c(
External_Id__c = dto.externalOrderId,
Amount__c = dto.amount,
Status__c = dto.status
));
}
// 4. Upsert using External ID, Partial Success (false), and USER_MODE security
Database.UpsertResult[] results = Database.upsert(
ordersToUpsert,
Order__c.External_Id__c,
false,
AccessLevel.USER_MODE
);
// 5. Build a clear per-record response for the calling system
for (Integer i = 0; i < results.size(); i++) {
OrderResponseDto itemRes = new OrderResponseDto();
itemRes.externalOrderId = ordersToUpsert[i].External_Id__c;
itemRes.isSuccess = results[i].isSuccess();
itemRes.salesforceId = results[i].getId();
if (!results[i].isSuccess()) {
itemRes.errorMessage = results[i].getErrors()[0].getMessage();
}
responseList.add(itemRes);
}
res.statusCode = 200;
return responseList;
} catch (Exception ex) {
// 6. Catch malformed JSON or security exceptions cleanly
res.statusCode = 400;
OrderResponseDto err = new OrderResponseDto();
err.isSuccess = false;
err.errorMessage = 'Request failed: ' + ex.getMessage();
responseList.add(err);
return responseList;
}
}
}
Q2: When should you build a custom @RestResource class instead of using Salesforce's standard REST APIs (like SObject Collections or the Composite API)?
@RestResource class when the external system cannot adopt Salesforce's payload format, or when we need complex transactional orchestration and custom response shaping that standard APIs can't handle in a single call."
Salesforce gives you powerful out-of-the-box REST endpoints like SObject Collections (which can insert/upsert up to 200 records in one call), the Composite API, and the Composite Graph API. You don't have to write or test a single line of Apex to use them.
However, you should build a custom @RestResource endpoint in three specific scenarios:
- 🔒 Webhook / Third-Party Payload Lock-in: You are receiving a webhook from a system like Stripe, Shopify, or a legacy ERP where you cannot change their outbound JSON structure to match Salesforce's standard
sObjectschema. - ⚙️ Complex Custom Business Logic: You need to receive one payload, query several lookup tables, calculate custom pricing tiers in Apex, create records across 6 objects, and roll back the entire transaction via
Database.setSavepoint()if a downstream validation fails. - 🛡️ Schema Abstraction: You want to hide your internal Salesforce field and object names (
Cust_Ord_Header__c) behind a clean, versioned public contract so you can refactor your Salesforce database later without breaking external integrations.
@HttpPost method throw an unhandled Apex exception! When an unhandled exception occurs, Salesforce returns a raw 500 Internal Server Error with an internal stack trace. Even worse, many external middleware tools (like MuleSoft or Boomi) are configured to automatically retry 5xx server errors—meaning a bad data payload will get retried endlessly, burning your API limits! Always wrap your logic in a try-catch block and return a clean 400 Bad Request for data errors or 200 OK with per-record error messages.
🔗 Connecting the Dots: Follow-Up Questions
Q3: The external caller sometimes retries on timeout, resulting in duplicate Orders in Salesforce. How do you fix this without asking them to change their client code?
You make your endpoint idempotent—which is a fancy way of saying "calling it once or calling it five times produces the exact same result." Grab a stable, unique identifier that the external system is already sending in the payload (such as their internal orderNumber or transactionId) and map it to a custom field in Salesforce marked as an External ID and Unique.
Then, replace your Apex insert statement with Database.upsert(records, Order__c.External_Id__c, AccessLevel.USER_MODE). When the first call succeeds but the response gets lost over a slow network, the caller's automatic retry will simply match the External_Id__c and update the existing record in place. Zero duplicates created, and zero changes needed from the external team!
Q4: During a security review, an auditor asks: "Which user does this endpoint run as, and can it accidentally expose sensitive data?" How do you answer?
Tell the auditor: "This endpoint executes strictly under the context of the authenticated Integration User that obtained the OAuth token." To lock down security according to modern Salesforce best practices, follow three rules:
- 🪪 Dedicated Integration License: Create a dedicated user with the Salesforce Integration user license and
Api Onlyenabled so no human can log into the UI with those credentials. - 🔑 Least-Privilege Permission Sets: Never give the integration user "Modify All Data" or "View All Data." Grant access only to the exact Objects and Fields required for the API via a dedicated Permission Set.
- 🛡️ Enforce Sharing & User Mode in Code: Declare your class as
global with sharing(to enforce record-level sharing rules) and run all SOQL queries withWITH USER_MODEand all DML operations withAccessLevel.USER_MODE. If anyone tries to query or update a restricted field through the API, Salesforce blocks it immediately.
Q5: Why is bulkifying the JSON payload so critical for Apex REST endpoints?
Imagine an external warehouse system needs to sync 10,000 updated order statuses to Salesforce every morning. If you built your @RestResource endpoint to accept only a single JSON object ({"orderId": "ORD-101"}), that warehouse system has to make 10,000 separate HTTP calls. That flood of traffic will quickly drain your org's 24-hour API limit and risk hitting Salesforce's concurrent Apex limit.
By designing your endpoint from day one to accept a JSON Array ([{"orderId": "ORD-101"}, {"orderId": "ORD-102"}]), the warehouse can group 200 orders into each request and sync all 10,000 records in just 50 API calls—cutting network overhead by 99.5%!
Q6: How does automatic JSON serialization work in @RestResource methods when you use method parameters versus RestContext.request.requestBody?
Salesforce gives you two ways to read incoming JSON in an @HttpPost, @HttpPut, or @HttpPatch method:
- Method 1 (Automatic Parameter Binding): If you define parameters directly in your method signature—like
global static void createOrder(String orderName, Decimal amount)—Salesforce automatically unpacks the top-level JSON keys that match those exact variable names. However, if the external system sends a JSON key that isn't in your parameter list, or sends a top-level JSON array, automatic binding can fail with a parsing error before your code even starts running! - Method 2 (Manual Deserialization via
RestContext): If you leave your method parameters empty—global static void createOrder()—and read the raw payload viaRestContext.request.requestBody.toString(), you gain total control. You can wrapJSON.deserialize()inside your owntry-catchblock, handle top-level arrays effortlessly, log malformed payloads for debugging, and return custom error messages. For enterprise production APIs, readingRestContext.request.requestBodyinto a DTO class is always the safer choice.
Q7: Now that you built the endpoint, how do you write an Apex Test Class for a @RestResource class?
Unlike outbound callouts (which require HttpCalloutMock), testing an inbound @RestResource class is super straightforward! Inside your @IsTest method, you instantiate a new RestRequest and RestResponse, set the req.requestURI, req.httpMethod = 'POST', and req.requestBody = Blob.valueOf(sampleJson), and assign them directly to RestContext.request and RestContext.response. Then, simply call your Apex method inside System.runAs(integrationUser) to verify both your business logic and your User-Mode security permissions!
See also: Apex Test Class Examples for @HttpPost Exposed WebService Class
Comments
Questions, corrections and your own experience are all welcome.
Post a Comment