TrailForceTips

REST API in Apex Salesforce: A Complete How-To

REST API in Apex Salesforce illustration
Visual overview of a REST API in Apex Salesforce — endpoints, JSON, and authentication.

REST APIs are the backbone of modern integrations. In Salesforce, you can build custom endpoints with Apex to expose clean, secure, and business-ready services. This guide explains core concepts and walks you through a production-grade REST API in Apex Salesforce — including JSON I/O, testing, OAuth, and best practices.

1) What is a REST API?

REST (Representational State Transfer) uses HTTP verbs — GET, POST, PUT, DELETE — to perform CRUD operations. A REST API in Apex Salesforce can read and mutate CRM data, orchestrate processes, and integrate external platforms with precise business rules.

  • Retrieve Salesforce records with query-safe filters;
  • Create, update, and delete data under strict validation;
  • Bridge Salesforce with external apps using JSON contracts.

2) Why build a REST API in Apex Salesforce?

  • Flexibility: full control over payloads, status codes, and rules.
  • Security: leverage OAuth scopes, sharing, and field-level checks.
  • Customization: craft domain-specific endpoints that mirror your processes.

3) Step-by-Step: Build a REST API in Apex

3.1 Define the resource and JSON helpers

Annotate your class with @RestResource and your methods with HTTP annotations. Use DTOs (wrapper classes) to parse/return JSON predictably and a small helper to send responses consistently.

@RestResource(urlMapping='/v1/accounts/*')
global with sharing class AccountApiV1 {

    // ---------- DTOs ----------
    global class AccountRequest {
        public String name;
        public String phone;
    }
    global class AccountResponse {
        public Id id;
        public String name;
        public String phone;
        public String message;
    }
    global class ErrorResponse {
        public String error;
        public String details;
        public Integer status;
    }

    // ---------- Utility: JSON sender ----------
    private static void sendJson(Integer status, Object body) {
        RestResponse res = RestContext.response;
        res.statusCode = status;
        res.addHeader('Content-Type', 'application/json');
        res.responseBody = Blob.valueOf(JSON.serialize(body));
    }

    // ---------- GET /v1/accounts/{id} ----------
    @HttpGet
    global static void getAccount() {
        String id = RestContext.request.requestURI.substringAfter('/v1/accounts/');
        if (String.isBlank(id)) {
            sendJson(400, new ErrorResponse('Bad Request','Account Id is required',400));
            return;
        }
        try {
            Account a = [SELECT Id, Name, Phone FROM Account WHERE Id = :id LIMIT 1];
            AccountResponse out = new AccountResponse();
            out.id = a.Id; out.name = a.Name; out.phone = a.Phone;
            sendJson(200, out);
        } catch (QueryException qe) {
            sendJson(404, new ErrorResponse('Not Found','No Account for Id '+id,404));
        } catch (Exception e) {
            sendJson(500, new ErrorResponse('Server Error', e.getMessage(), 500));
        }
    }

    // ---------- POST /v1/accounts ----------
    @HttpPost
    global static void createAccount() {
        try {
            AccountRequest in = (AccountRequest) JSON.deserialize(RestContext.request.requestBody.toString(), AccountRequest.class);
            if (in == null || String.isBlank(in.name)) {
                sendJson(400, new ErrorResponse('Bad Request','Name is required',400)); return;
            }
            Account a = new Account(Name=in.name, Phone=in.phone);
            insert a;
            AccountResponse out = new AccountResponse();
            out.id=a.Id; out.name=a.Name; out.phone=a.Phone; out.message='Account created';
            sendJson(201, out);
        } catch (DmlException dml) {
            sendJson(422, new ErrorResponse('Unprocessable Entity', dml.getDmlMessage(0), 422));
        } catch (Exception e) {
            sendJson(500, new ErrorResponse('Server Error', e.getMessage(), 500));
        }
    }

    // ---------- PUT /v1/accounts/{id} ----------
    @HttpPut
    global static void updateAccount() {
        String id = RestContext.request.requestURI.substringAfter('/v1/accounts/');
        try {
            AccountRequest in = (AccountRequest) JSON.deserialize(RestContext.request.requestBody.toString(), AccountRequest.class);
            Account a = [SELECT Id, Name, Phone FROM Account WHERE Id = :id LIMIT 1];
            if (in != null) { a.Name = (in.name!=null ? in.name : a.Name); a.Phone=in.phone; }
            update a;
            AccountResponse out = new AccountResponse(); out.id=a.Id; out.name=a.Name; out.phone=a.Phone; out.message='Account updated';
            sendJson(200, out);
        } catch (QueryException qe) {
            sendJson(404, new ErrorResponse('Not Found','No Account for Id '+id,404));
        } catch (DmlException dml) {
            sendJson(422, new ErrorResponse('Unprocessable Entity', dml.getDmlMessage(0), 422));
        } catch (Exception e) {
            sendJson(500, new ErrorResponse('Server Error', e.getMessage(), 500));
        }
    }

    // ---------- DELETE /v1/accounts/{id} ----------
    @HttpDelete
    global static void deleteAccount() {
        String id = RestContext.request.requestURI.substringAfter('/v1/accounts/');
        try {
            delete [SELECT Id FROM Account WHERE Id = :id LIMIT 1];
            sendJson(204, null); // no content
        } catch (QueryException qe) {
            sendJson(404, new ErrorResponse('Not Found','No Account for Id '+id,404));
        } catch (DmlException dml) {
            sendJson(422, new ErrorResponse('Unprocessable Entity', dml.getDmlMessage(0), 422));
        } catch (Exception e) {
            sendJson(500, new ErrorResponse('Server Error', e.getMessage(), 500));
        }
    }

    // ErrorResponse constructor sugar
    private static ErrorResponse new ErrorResponse(String e, String d, Integer s) {
        ErrorResponse r = new ErrorResponse(); r.error=e; r.details=d; r.status=s; return r;
    }
}

3.2 Test the REST API in Apex Salesforce

Salesforce requires tests for deployment. Simulate HTTP traffic with RestRequest/RestResponse. Keep tests bulk-safe and assert status codes plus JSON content.

@IsTest
private class AccountApiV1_Test {
    @TestSetup
    static void data() { insert new Account(Name='Seed Co'); }

    @IsTest
    static void getAccount_ok() {
        Account a = [SELECT Id FROM Account LIMIT 1];
        RestRequest req = new RestRequest();
        req.requestURI = '/services/apexrest/v1/accounts/' + a.Id;
        req.httpMethod = 'GET';
        RestContext.request = req; RestContext.response = new RestResponse();
        AccountApiV1.getAccount();
        System.assertEquals(200, RestContext.response.statusCode);
    }

    @IsTest
    static void post_then_put_then_delete() {
        // POST
        RestRequest post = new RestRequest();
        post.requestURI = '/services/apexrest/v1/accounts';
        post.httpMethod = 'POST';
        post.addHeader('Content-Type','application/json');
        post.requestBody = Blob.valueOf('{"name":"NewCo","phone":"555-111"}');
        RestContext.request=post; RestContext.response=new RestResponse();
        AccountApiV1.createAccount();
        System.assertEquals(201, RestContext.response.statusCode);

        // Extract Id
        Map<String,Object> body = (Map<String,Object>) JSON.deserializeUntyped(RestContext.response.responseBody.toString());
        Id newId = (Id) body.get('id');

        // PUT
        RestRequest put = new RestRequest();
        put.requestURI = '/services/apexrest/v1/accounts/' + newId;
        put.httpMethod = 'PUT';
        put.requestBody = Blob.valueOf('{"phone":"555-999"}');
        RestContext.request=put; RestContext.response=new RestResponse();
        AccountApiV1.updateAccount();
        System.assertEquals(200, RestContext.response.statusCode);

        // DELETE
        RestRequest del = new RestRequest();
        del.requestURI = '/services/apexrest/v1/accounts/' + newId;
        del.httpMethod = 'DELETE';
        RestContext.request=del; RestContext.response=new RestResponse();
        AccountApiV1.deleteAccount();
        System.assertEquals(204, RestContext.response.statusCode);
    }
}

4) Authentication with OAuth 2.0

Create a Connected App in Setup → App Manager, enable OAuth, add scopes like api and refresh_token, then obtain a token via the token endpoint. Use the token in the Authorization: Bearer <ACCESS_TOKEN> header for every call to your REST API in Apex Salesforce.

POST https://login.salesforce.com/services/oauth2/token
grant_type=password&client_id=CLIENT_ID&client_secret=CLIENT_SECRET&username=USER&password=PASS+TOKEN

Docs: Salesforce REST API Authentication.

5) Call the API (Postman or cURL)

Example request to create an account:

POST https://YOUR_INSTANCE.my.salesforce.com/services/apexrest/v1/accounts
Headers:
  Authorization: Bearer <ACCESS_TOKEN>
  Content-Type: application/json
Body:
  {"name":"Test Company","phone":"123-456-7890"}

6) Best Practices for a REST API in Apex Salesforce

  1. Consistent contracts: always return JSON; include status codes and clear error objects.
  2. Security first: prefer with sharing/inherited sharing; validate input; use Security.stripInaccessible for FLS.
  3. Version your endpoints: e.g., /v1/, /v2/ to evolve safely.
  4. Bulk operations: support arrays for batch create/update when possible.
  5. Limits & performance: avoid SOQL/DML in loops; cache read-only data when appropriate.
  6. Observability: add structured logs; consider Platform Events for async auditing.
  7. Documentation: publish examples and error codes for consumers.

7) Related Reading

Pair this guide with our architecture posts to standardize your org:

Conclusion

Building a REST API in Apex Salesforce gives you exact control over payloads, rules, and security. With DTOs, consistent JSON responses, robust tests, and OAuth in place, your endpoints become reliable building blocks for integrations at scale.