Salesforce Development
Hands-onIntermediate 45 min· Developer · 13 of 17

Integration: REST APIs and platform events

Call the Salesforce REST API, publish your own Apex REST endpoint, make outbound callouts with a Named Credential and test them with mocks, then publish and subscribe to platform events. Learn which integration tool fits which job.

What you will build: you will call the Salesforce REST API from the command line, write your own REST endpoint in Apex, call an outside API from Apex with a Named Credential, and publish and receive a platform event. By the end you will know the main ways data moves in and out of Salesforce.

Prerequisites: a Developer Edition org (see *Get a free Developer Edition org and start Trailhead*), the Salesforce CLI installed, and the Apex and testing basics from *Apex basics* and *Testing Apex*. For the curl commands use Git Bash, macOS or a Linux terminal. In Windows PowerShell, curl is a different command, so use Git Bash.

Why integrate at all

A company rarely uses Salesforce alone. Orders come from a website, invoices live in an ERP, and a mobile app needs customer data. Integration means making these systems exchange data automatically instead of people copying it by hand.

There are two directions. Inbound means another system calls Salesforce (to read or write records). Outbound means Salesforce calls another system. Salesforce has a tool for each case.

Choosing an integration tool

NeedToolDirection
Read or write standard and custom records from outsideREST API (also SOAP API, and Bulk API for large loads)Inbound
Expose your own custom endpoint with your own logicApex REST (@RestResource)Inbound
Call an outside web service from ApexHTTP callout with a Named CredentialOutbound
Tell other systems that something happened, without waiting for a replyPlatform eventsEither (publish and subscribe)
Connect many systems with prebuilt connectors and API managementMuleSoft (a separate Salesforce product)Both

Part 1: call the Salesforce REST API

Every org has a REST API. Records are addressed by URL and the HTTP method says what to do: GET reads, POST creates, PATCH updates, DELETE deletes. The data is JSON. Every call must carry an access token that proves who you are. Real systems get tokens through OAuth (explained below). To learn, we will borrow the token the CLI already has.

Get a token for practice

  1. 1Log in from your terminal: run sf org login web --alias dev. A browser opens. Log in to your Developer Edition org and click Allow.
  2. 2Show the details of the org: run sf org display --target-org dev --verbose. Find the Instance Url and Access Token in the output.
  3. 3Save them in shell variables (next code block). The token works for a short time. When calls start returning 401, run the display command again.
Terminal
export INSTANCE_URL='https://yourdomain.my.salesforce.com'
export TOKEN='paste-the-access-token-here'

# Which API versions does this org support? A quick test that the token works.
curl "$INSTANCE_URL/services/data/" -H "Authorization: Bearer $TOKEN"

The last call lists API versions. Pick the newest version number it shows and use it in the URLs below. This lesson writes v62.0, so replace it if yours is different.

Treat tokens like passwords

The access token is a password for your org. Never paste it into a public repository, a screenshot or a chat. Never hard-code tokens in programs. Use environment variables or a secrets store.

Now query records. The query endpoint takes a SOQL string. The -G and --data-urlencode options let curl handle spaces and special characters for you.

Read records with SOQL
curl -G "$INSTANCE_URL/services/data/v62.0/query" \
  --data-urlencode "q=SELECT Id, Name, Industry FROM Account LIMIT 3" \
  -H "Authorization: Bearer $TOKEN"
Roughly what you see (your records will differ)
{
  "totalSize": 3,
  "done": true,
  "records": [
    {
      "attributes": { "type": "Account", "url": "/services/data/v62.0/sobjects/Account/001..." },
      "Id": "001...",
      "Name": "Burlington Textiles Corp of America",
      "Industry": "Apparel"
    }
  ]
}

Create a record with POST to /sobjects/Account, sending the field values as JSON. Salesforce replies with the new Id. To update, send PATCH to /sobjects/Account/<Id>. A successful PATCH returns status 204 with an empty body.

Create an Account
curl -X POST "$INSTANCE_URL/services/data/v62.0/sobjects/Account" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"Name": "Lakeview Foods", "Industry": "Retail"}'
Reply
{"id":"001XXXXXXXXXXXXXXX","success":true,"errors":[]}
Update it (use the id you got)
curl -i -X PATCH "$INSTANCE_URL/services/data/v62.0/sobjects/Account/001XXXXXXXXXXXXXXX" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"Phone": "080-5550100"}'

The body is empty. The -i option shows the status line, which should say 204.

HTTP status codes you will meet

StatusMeaningWhat to do
200 / 201OK / createdRead the JSON body
204Success, no body (typical for PATCH and DELETE)Nothing to read
400Bad request, such as a wrong field name or valueRead the JSON error message, it names the problem
401Token missing, expired or invalidGet a new token
403Logged in but not allowed (permissions, or API access is off)Check the profile and permission sets
404Wrong URL, or the record does not existCheck the version, object name and Id

How real systems log in

The CLI token is only for practice. A real integration, such as a website server calling Salesforce, uses OAuth 2.0. You create an app definition in the org that identifies the outside system, and Salesforce issues tokens to it. In many orgs that definition is a connected app; newer orgs steer you towards an external client app. Which one you can create depends on your org settings and release, so check the current Salesforce Help page for your org.

For server-to-server calls with nobody at a browser, the usual choices are the client credentials flow or the JWT bearer flow. Both let the server get a token using a secret or certificate, and the integration runs as one dedicated integration user with only the permissions it needs (see *Security model: profiles, permission sets and sharing*). Never use a real person's account.

The REST API also counts against API request limits for your org, so integrations should fetch only what they need and use the Bulk API for large loads.

Part 2: your own endpoint with Apex REST

The standard API gives access to records. Sometimes you want one call that runs business logic: check a rule, create several records and return a clean answer. Apex REST lets you publish your own endpoint. The class needs @RestResource with a URL mapping, and each method gets @HttpGet, @HttpPost and so on. Your endpoint lives under /services/apexrest/.

AccountApi.cls
@RestResource(urlMapping='/accounts/*')
global with sharing class AccountApi {

    // GET /services/apexrest/accounts/?industry=Retail
    @HttpGet
    global static List<Account> getByIndustry() {
        String industry = RestContext.request.params.get('industry');
        if (String.isBlank(industry)) {
            RestContext.response.statusCode = 400;
            return new List<Account>();
        }
        return [
            SELECT Id, Name, Industry
            FROM Account
            WHERE Industry = :industry
            LIMIT 50
        ];
    }

    // POST /services/apexrest/accounts/ with a JSON body {"name": "...", "industry": "..."}
    @HttpPost
    global static Id createAccount(String name, String industry) {
        Account acc = new Account(Name = name, Industry = industry);
        insert acc;
        RestContext.response.statusCode = 201;
        return acc.Id;
    }
}

The parameters of a POST method are filled from the JSON keys with the same names. The class must be global.

Call your endpoint
curl -G "$INSTANCE_URL/services/apexrest/accounts/" \
  --data-urlencode "industry=Retail" \
  -H "Authorization: Bearer $TOKEN"

curl -X POST "$INSTANCE_URL/services/apexrest/accounts/" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Green Leaf Traders", "industry": "Retail"}'

The GET returns a JSON list of accounts. The POST returns the new Id as a JSON string such as "001...".

Apex REST classes need tests like any Apex. You build a fake request and response, then call the method directly.

AccountApiTest.cls
@IsTest
private class AccountApiTest {

    @IsTest
    static void getByIndustryReturnsMatches() {
        insert new Account(Name = 'Test Retail', Industry = 'Retail');

        RestRequest req = new RestRequest();
        req.requestURI = '/services/apexrest/accounts/';
        req.addParameter('industry', 'Retail');
        RestContext.request = req;
        RestContext.response = new RestResponse();

        List<Account> result = AccountApi.getByIndustry();

        System.assertEquals(1, result.size());
        System.assertEquals('Test Retail', result[0].Name);
    }

    @IsTest
    static void missingIndustryGives400() {
        RestContext.request = new RestRequest();
        RestContext.response = new RestResponse();

        List<Account> result = AccountApi.getByIndustry();

        System.assertEquals(400, RestContext.response.statusCode);
        System.assertEquals(0, result.size());
    }
}

Part 3: calling an outside API from Apex

For outbound calls Apex uses the HttpRequest, Http and HttpResponse classes. Salesforce blocks callouts to unknown addresses, so each endpoint must be registered. The modern way is a Named Credential: a saved setting that holds the base URL and how to log in. Your code only mentions the name, so no URL, password or API key sits in the code, and an admin can change the target (for example from a test server to the live server) without touching Apex.

The example below is hypothetical. It assumes a Named Credential called Weather_API whose URL is the base address of a weather service, and that GET /v1/current?city=Bengaluru returns JSON such as {"city":"Bengaluru","tempC":27.5}. Use any JSON API you have access to and change the path and field names to match.

Set up the Named Credential

  1. 1In Setup, search Quick Find for Named Credentials.
  2. 2Create the credential. The page may ask you to create an External Credential first, which stores the authentication method. Choose the one that matches your API, or no authentication for a public test API. Name the Named Credential Weather_API and enter the base URL.
  3. 3If the setup asks for a permission set mapping on the External Credential, grant it to the users who will run the code (including you). Otherwise the callout is refused.
  4. 4Run the code below from Execute Anonymous (Developer Console, Debug menu): System.debug(WeatherService.getTempC('Bengaluru'));
WeatherService.cls
public with sharing class WeatherService {

    public class WeatherException extends Exception {}

    public static Decimal getTempC(String city) {
        HttpRequest req = new HttpRequest();
        req.setEndpoint('callout:Weather_API/v1/current?city=' + EncodingUtil.urlEncode(city, 'UTF-8'));
        req.setMethod('GET');
        req.setTimeout(10000); // milliseconds

        HttpResponse res = new Http().send(req);
        if (res.getStatusCode() != 200) {
            throw new WeatherException('Weather API returned ' + res.getStatusCode());
        }
        Map<String, Object> data = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
        return (Decimal) data.get('tempC');
    }
}

callout:Weather_API is replaced by the credential's URL at run time.

Tests are not allowed to make real callouts. Instead you supply a mock: a class that returns a fake response. The test then checks your parsing and error handling with no network involved.

WeatherServiceTest.cls
@IsTest
private class WeatherServiceTest {

    private class MockOk implements HttpCalloutMock {
        public HttpResponse respond(HttpRequest req) {
            HttpResponse res = new HttpResponse();
            res.setStatusCode(200);
            res.setBody('{"city":"Bengaluru","tempC":27.5}');
            return res;
        }
    }

    private class MockFail implements HttpCalloutMock {
        public HttpResponse respond(HttpRequest req) {
            HttpResponse res = new HttpResponse();
            res.setStatusCode(500);
            res.setBody('error');
            return res;
        }
    }

    @IsTest
    static void parsesTemperature() {
        Test.setMock(HttpCalloutMock.class, new MockOk());
        Test.startTest();
        Decimal t = WeatherService.getTempC('Bengaluru');
        Test.stopTest();
        System.assertEquals(27.5, t);
    }

    @IsTest
    static void throwsOnServerError() {
        Test.setMock(HttpCalloutMock.class, new MockFail());
        try {
            WeatherService.getTempC('Bengaluru');
            System.assert(false, 'Expected an exception');
        } catch (WeatherService.WeatherException e) {
            System.assert(e.getMessage().contains('500'));
        }
    }
}

Callouts and triggers

A trigger runs inside a database transaction and should not make a callout directly. Callouts are also not allowed after DML that has not been committed in the same transaction. The usual fix is to start the callout asynchronously, for example from a Queueable class that also implements Database.AllowsCallouts. Other limits to remember: a transaction may make at most 100 callouts, and each callout has a timeout (10 seconds by default in code that does not set one).

Part 4: platform events

A REST call is a conversation: ask, wait, get an answer. A platform event is an announcement. Imagine the bell at the Brew & Bean counter. When an order is ready, staff ring it once. They do not know who is listening and they do not wait. A system that cares (a display screen, an SMS service) reacts.

Technically, a platform event is a special kind of object whose API name ends in __e. One side publishes events and any number of subscribers receive them. Subscribers can be Apex triggers, Flows, Lightning components or external systems (outside systems use the Pub/Sub API). Salesforce keeps events for a limited time, so a subscriber that was offline can catch up using a replay id. Check the current Platform Events Developer Guide for the exact retention period.

Create the event

  1. 1Open Setup and search Quick Find for Platform Events. Click New Platform Event.
  2. 2Label: Order Ready. Plural label: Order Ready Events. Leave the publish behaviour as Publish After Commit, so an event is sent only if the transaction succeeds. Save.
  3. 3Under Custom Fields, add a Text field Order Number (length 20, API name Order_Number__c) and a Text field Customer Name (length 80, API name Customer_Name__c).
  4. 4The event object is now Order_Ready__e.
Publish (Execute Anonymous)
List<Order_Ready__e> events = new List<Order_Ready__e>{
    new Order_Ready__e(Order_Number__c = 'BB-1042', Customer_Name__c = 'Asha'),
    new Order_Ready__e(Order_Number__c = 'BB-1043', Customer_Name__c = 'Ravi')
};

List<Database.SaveResult> results = EventBus.publish(events);
for (Database.SaveResult sr : results) {
    if (sr.isSuccess()) {
        System.debug('Published');
    } else {
        System.debug('Failed: ' + sr.getErrors()[0].getMessage());
    }
}

EventBus.publish takes a list, so publishing many events is one call.

Now the subscriber. A platform event trigger looks like an ordinary trigger but supports only after insert, because an event arrives as a newly created message. For practice, we create a Task for each event so you can see that the trigger ran.

OrderReadyTrigger.trigger
trigger OrderReadyTrigger on Order_Ready__e (after insert) {
    List<Task> tasks = new List<Task>();
    for (Order_Ready__e evt : Trigger.new) {
        tasks.add(new Task(
            Subject = 'Order ' + evt.Order_Number__c + ' is ready for ' + evt.Customer_Name__c,
            Priority = 'Normal',
            Status = 'Not Started'
        ));
    }
    insert tasks;
}

Event triggers run as the Automated Process user, in their own transaction, separate from the publisher.

What you should see
Run the publish code, then run this in the Developer Console Query Editor: SELECT Subject FROM Task ORDER BY CreatedDate DESC LIMIT 5

You should see two tasks: "Order BB-1042 is ready for Asha" and "Order BB-1043 is ready for Ravi".

To test a subscriber, publish inside Test.startTest() and Test.stopTest(). When stopTest runs, the platform delivers the events, so your trigger has finished before your assertions.

OrderReadyTriggerTest.cls
@IsTest
private class OrderReadyTriggerTest {
    @IsTest
    static void createsTaskForEachEvent() {
        Test.startTest();
        EventBus.publish(new Order_Ready__e(Order_Number__c = 'BB-1', Customer_Name__c = 'Test'));
        Test.stopTest();

        List<Task> tasks = [SELECT Subject FROM Task WHERE Subject LIKE 'Order BB-1%'];
        System.assertEquals(1, tasks.size());
    }
}

REST or platform event?

QuestionREST or Apex RESTPlatform event
Does the caller need an answer now?YesNo
How many receivers?OneZero, one or many
Should the sender fail if the receiver is down?Yes, it needs the answerNo, the receiver can catch up later
ExampleWebsite asks "is the item in stock?"Warehouse, billing and SMS all react to "order shipped"
Prompt · Explain an integration error
I call the Salesforce REST API with curl and get this response:

[paste the status code and body]

My request was:

[paste the curl command, with the token replaced by XXXX]

Explain in plain English what the error means, list the 3 most likely causes in order, and tell me how to check each one in Setup.

What to expect: Check the answer against the status codes table. Never paste a real token into an AI tool.

Try it: Add a stock check endpoint

Use the standard Product2 object. Add an @HttpGet method in a new Apex REST class that returns a product by its ProductCode query parameter. Return status 404 when nothing matches. Write a test for both cases, then call the endpoint with curl.

Try it: Add a second subscriber

Create a Record-Triggered Flow or a second trigger that also reacts to Order_Ready__e, for example by writing a custom log record. Publish one event and confirm both subscribers ran. Notice that the publishing code did not change.

Key takeaways

  1. Integration moves data between Salesforce and other systems. Inbound means others call Salesforce; outbound means Salesforce calls others.
  2. The REST API uses HTTP verbs and JSON. Always send an access token; 401 means a bad or expired token.
  3. Production integrations use OAuth and a dedicated integration user with minimal permissions, never a person's login or a pasted token.
  4. Apex REST (@RestResource, @HttpGet, @HttpPost) publishes your own endpoints when you need custom logic.
  5. Use Named Credentials for outbound callouts so URLs and secrets stay out of code. Test callouts with HttpCalloutMock.
  6. Triggers should not call out directly; start asynchronous work such as a Queueable with Database.AllowsCallouts.
  7. Platform events (__e objects) publish announcements to any number of subscribers. Use them when the sender should not wait for an answer.

Check yourself

Finishing the quiz marks the lesson as done.

1. A curl call to the Salesforce REST API returns status 401. What is the most likely cause?

2. Why use a Named Credential instead of putting the URL and API key in Apex?

3. How do you test Apex code that makes an HTTP callout?

4. Which situation fits a platform event best?

5. Why does a trigger on a platform event support only after insert?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free