REST and SOAP API Integration Patterns for Salesforce Marketing Cloud

Every Salesforce Marketing Cloud project eventually hits the same wall: how do you make the platform talk to the rest of the stack? A storefront needs to fire a transactional email in seconds. A warehouse needs to load a Data Extension every night. A back-office tool needs to flip a subscription status in real time. SFMC answers with two API families — REST and SOAP — that coexist, cover different objects, and behave very differently under load. This guide walks through the integration patterns we deploy on client projects, with the code that goes with them and the traps worth avoiding.

REST or SOAP? Let the object decide

The heuristic is short: REST for data and sends, SOAP for configuration and legacy objects. REST covers Data Extensions, Journeys, Content Builder assets, and transactional messaging. SOAP, inherited from ExactTarget, is still the only door to a handful of objects REST never picked up.

What you needAPIObject / endpoint
Upsert rows into a Data ExtensionREST/data/v1/async/dataextensions/key:KEY/rows
Transactional sendREST/messaging/v1/email/messages
Inject a contact into a JourneyREST/interaction/v1/events
All Subscribers statusSOAPSubscriber
Lists and list subscriptionsSOAPListSubscriber
Create a Data ExtensionSOAPDataExtension
Query Data ViewsSOAPAutomation + SQL query

In practice, almost every serious integration ends up hybrid. Design for that from day one instead of forcing a single protocol — the workarounds you avoid now are the ones that would have cost you a quarter six months down the line.

Foundation one: one installed package per consumer

The most common mistake is creating a single installed package and handing its credentials to every team. The day you need to revoke one integration's access, you cut off everyone. Create one package per consumer — website, ETL, back-office, support tooling — each with the narrowest scope that works.

Under Setup > Apps > Installed Packages, add a Server-to-Server API Integration component and tick only the scopes you actually need. An ETL job needs data_extensions_read and data_extensions_write. It has no business holding email_write or journeys_execute.

Getting a token

POST https://YOUR_SUBDOMAIN.auth.marketingcloudapis.com/v2/token
Content-Type: application/json

{
  "grant_type": "client_credentials",
  "client_id": "xxxxxxxx",
  "client_secret": "yyyyyyyy",
  "account_id": "7281234",
  "scope": "data_extensions_read data_extensions_write"
}

The response carries access_token, expires_in (1,080 seconds), and — crucially — rest_instance_url and soap_instance_url. Never hard-code those two URLs. Read them from the token response every time. Tenants get moved between stacks, and a pinned endpoint always breaks eventually, usually at the worst moment.

Foundation two: cache the token

Requesting a fresh token on every call is the fastest way to burn through your auth quota and bolt 200ms onto every request. Cache it, and refresh with a safety margin. Here's the pattern in SSJS, usable from a CloudPage or a Script Activity:

<script runat="server">
Platform.Load("Core", "1.1.1");

var de = DataExtension.Init("API_TOKEN_CACHE");
var rows = de.Rows.Lookup(["TokenKey"], ["ETL"]);
var now = new Date();
var token = null;

if (rows.length > 0 && new Date(rows[0].ExpiresAt) > now) {
    token = rows[0].AccessToken;
} else {
    var auth = HTTP.Post(
        "https://YOUR_SUBDOMAIN.auth.marketingcloudapis.com/v2/token",
        "application/json",
        Stringify({
            grant_type: "client_credentials",
            client_id: clientId,
            client_secret: clientSecret,
            account_id: mid
        })
    );
    var body = Platform.Function.ParseJSON(auth.Response[0]);
    token = body.access_token;

    // 120-second buffer before actual expiry
    var expires = new Date(now.getTime() + (body.expires_in - 120) * 1000);
    de.Rows.Update(
        { AccessToken: token, ExpiresAt: expires },
        ["TokenKey"], ["ETL"]
    );
}
</script>

That 120-second buffer covers the classic race: the token is valid when you look it up, and expired by the time the request lands server-side.

Moving volume: the async upsert

For bulk loads, the synchronous endpoint runs out of headroom fast. The async endpoint takes larger batches, returns immediately, and hands you a tracking handle:

POST /data/v1/async/dataextensions/key:CONTACT_MASTER/rows
Authorization: Bearer {token}
Content-Type: application/json

{
  "items": [
    { "SubscriberKey": "C-10482", "Email": "j.walker@example.com",
      "Segment": "VIP", "UpdatedAt": "2026-09-08T09:12:00Z" },
    { "SubscriberKey": "C-10483", "Email": "s.reid@example.com",
      "Segment": "Standard", "UpdatedAt": "2026-09-08T09:12:00Z" }
  ]
}

You get back a requestId. Poll /data/v1/async/{requestId}/status, then hit /results to see exactly which rows were rejected and why. Three rules that hold up in production:

  • Batches of 2,000 to 5,000 rows give the best throughput-to-recoverability ratio.
  • The Data Extension primary key must be indexed, or your upsert quietly degrades into a full scan.
  • Always log the requestId on the calling side. Without it, post-mortem debugging is guesswork.

What only SOAP can still do

Changing a subscriber's status at the All Subscribers level is out of REST's reach. Global unsubscribes, in particular, go through SOAP or they don't go at all:

<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
  <s:Header>
    <fueloauth>{token}</fueloauth>
  </s:Header>
  <s:Body>
    <UpdateRequest xmlns="http://exacttarget.com/wsdl/partnerAPI">
      <Options/>
      <Objects xsi:type="Subscriber"
               xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
        <SubscriberKey>C-10482</SubscriberKey>
        <EmailAddress>j.walker@example.com</EmailAddress>
        <Status>Unsubscribed</Status>
      </Objects>
    </UpdateRequest>
  </s:Body>
</s:Envelope>

Worth noting: the OAuth 2.0 token from earlier works for SOAP too — it just goes in the fueloauth header instead of an Authorization bearer. So even a hybrid integration has only one authentication mechanism to maintain.

The OAuth 2.0 token is shared across REST and SOAP. A single, properly shared token cache simplifies your architecture far more than any protocol choice will.

Quotas, errors, and idempotency

SFMC enforces limits per tenant and per endpoint, and it does not publish them exhaustively. That alone is reason enough to design defensively from the start.

CodeMeaningCorrect response
401Token expired or revokedFlush cache, re-auth, retry once
403Scope missing on the packageDo not retry — alert and fix the config
429Rate limit hitExponential backoff with jitter
500 / 502Platform-side incidentRetry, then queue for later

Exponential backoff with jitter keeps all your workers from stampeding back at the same instant:

function callWithRetry(fn, maxAttempts) {
  var attempt = 0;
  while (true) {
    try {
      return fn();
    } catch (e) {
      attempt++;
      if (attempt >= maxAttempts || !isRetryable(e)) throw e;
      var base = Math.pow(2, attempt) * 500;
      var jitter = Math.random() * 500;
      sleep(base + jitter);
    }
  }
}

Finally, make your calls idempotent. An upsert on a primary key already is. A transactional send is not — replay it and your customer gets the email twice. Use the messageKey field on the Transactional Messaging API, which SFMC deduplicates against for several days.

Secure it, then watch it

Keep secrets in a vault — AWS Secrets Manager, Azure Key Vault, HashiCorp Vault — never in a Script Activity and never in a plaintext Data Extension. Document a rotation schedule and actually rehearse it. On the observability side, three metrics catch roughly 90% of incidents: 429s per minute, p95 latency per endpoint, and rejected rows per async batch. Ship them to whatever dashboard your team already looks at. A monitoring page nobody opens is not monitoring.

Key takeaways

Hybrid by design. REST for data and sends, SOAP for configuration and legacy objects. Don't twist one protocol to cover both jobs.

One package per consumer. Minimal scopes and separate credentials let you revoke a single integration without taking the others down with it.

One shared token cache. The same OAuth 2.0 token serves REST and SOAP; refresh it 120 seconds early and always read instance URLs from the token response.

Async for volume. Batches of 2,000 to 5,000 rows, an indexed primary key, and a logged requestId turn bulk loads into something you can actually debug.

Idempotency and backoff. A retry must never create a duplicate — use messageKey for transactional sends and exponential backoff with jitter on 429s.

Planning an SFMC integration, or trying to stabilise one that already exists? Get in touch — our Salesforce Marketing Cloud consultants audit your API flows and hand back a concrete remediation plan.

A voir: