Straight answer: @RestResource gives you a URL under /services/apexrest/ and six annotations for the HTTP verbs. The class must be global, the methods static, and the URL mapping unique. Everything after that is design, and the design decisions that matter are authentication, bulk, and what you do when half the payload is bad.
The minimum
@RestResource(urlMapping='/orders/*')
global with sharing class OrderApi {
@HttpGet
global static Order__c getOne() {
RestRequest req = RestContext.request;
String ref = req.requestURI.substring(req.requestURI.lastIndexOf('/') + 1);
List<Order__c> found = [
SELECT Id, Reference__c, Status__c FROM Order__c
WHERE Reference__c = :ref WITH USER_MODE LIMIT 1
];
if (found.isEmpty()) {
RestContext.response.statusCode = 404;
return null;
}
return found[0];
}
}
Two things worth noticing. The URL wildcard means you parse the path yourself. There is no routing layer. And returning null with a status code is how you produce a 404, because the annotation has no other mechanism.
Accept a list, always
The single most common design mistake is an endpoint that creates one record. The caller then loops, and every call is a separate transaction, a separate API call against your daily limit, and a separate round trip. An endpoint that accepts a list of up to a few hundred is barely more code and an order of magnitude better to consume.
@HttpPost
global static List<ItemResult> create(List<OrderInput> orders) {
...
}
Apex deserialises the request body into your parameters by name, so a JSON body of {"orders": [ ... ]} maps to a parameter called orders. This is convenient and it is also why a rename breaks every caller silently.
Partial success, and saying which half failed
A list endpoint that throws on the first bad row wastes the good ones. Use partial-success DML and return a result per input:
Database.SaveResult[] saves = Database.insert(records, false);
List<ItemResult> out = new List<ItemResult>();
for (Integer i = 0; i < saves.size(); i++) {
ItemResult r = new ItemResult();
r.reference = inputs[i].reference;
r.success = saves[i].isSuccess();
r.errors = saves[i].isSuccess() ? null
: String.join(errorStrings(saves[i].getErrors()), '; ');
out.add(r);
}
return out;
Then return 200 with a body describing what happened, or 207 if you want to be strict about it. Returning 500 because one row had a bad picklist value is technically defensible and practically unhelpful.
Security is not automatic
The class being global does not make it public — the caller still needs a session and the right permissions. What it does not give you for free:
- Object and field permissions. Add
WITH USER_MODEon queries andAccessLevel.USER_MODEon DML, as in enforcing CRUD and FLS. - Sharing. Declare
with sharingon the class. A REST class with no keyword runs without sharing, which is how an endpoint quietly becomes an access bypass. - Input validation. Anything from the body is caller-controlled. A reference used in dynamic SOQL is an injection risk exactly as described in preventing SOQL injection.
Idempotency for anything that creates
Callers retry. Networks time out after the work succeeded. If your POST creates a record every time it is called, a retry creates a duplicate. Accept a caller-supplied key, store it on an external ID field, and upsert. Then a retry is harmless and you have removed the hardest class of integration bug.
Versioning
Put the version in the URL mapping, /v1/orders/*, from the first release, even when there is only one version. Retrofitting it later means either breaking callers or maintaining an unversioned path forever.
Frequently asked
Can a method be public? No, the class and the annotated methods must be global.
How do I return a custom status code? Set RestContext.response.statusCode. An uncaught exception returns 500 with a body you did not write, so catch and translate.
What is the payload size limit? 6 MB for the request in a synchronous context, and the response counts against heap. A large batch is a heap problem before it is a design problem.
