Why 301 Redirects Break Salesforce Callouts

Introduction

HTTP 301 redirects are a standard mechanism for permanently moving resources to new URLs. Browsers handle them seamlessly, automatically following the redirect chain until reaching the final destination. However, Salesforce’s HTTP callout framework does not follow redirects by default, causing integrations to fail silently or return unexpected responses. This behaviour catches many developers off guard, particularly when external APIs change their URL structures or migrate to new domains.

Understanding why this happens and how to handle it properly is essential for building robust Salesforce integrations.

How HTTP Redirects Work

When a server returns a 301 (Moved Permanently) or 302 (Found/Temporary Redirect) status code, it includes a Location header pointing to the new URL. The expected behaviour is:

  1. Client sends request to original URL
  2. Server responds with 301/302 and Location header
  3. Client automatically sends new request to the Location URL
  4. Process repeats until a non-redirect response is received

Browsers and many HTTP libraries handle this automatically. Salesforce does not.

Why Salesforce Doesn’t Follow Redirects

Salesforce’s HttpRequest class is designed with security and predictability in mind. When you make a callout, Salesforce returns exactly what the server sends back—including redirect responses. This means:

Http http = new Http();
HttpRequest request = new HttpRequest();
request.setEndpoint('https://api.example.com/v1/data');
request.setMethod('GET');

HttpResponse response = http.send(request);
System.debug('Status: ' + response.getStatusCode()); // Returns 301, not 200
System.debug('Body: ' + response.getBody()); // Empty or redirect HTML

If api.example.com/v1/data redirects to api.example.com/v2/data, the above code receives the 301 response rather than following it to get the actual data.

The Security Rationale

This behaviour exists for valid security reasons:

  • Prevent open redirects: Automatic redirect following could expose your org to malicious redirect chains
  • Remote Site Settings enforcement: Each URL must be explicitly authorised; automatic redirects could bypass this
  • Credential leakage prevention: Following redirects could send authentication headers to unintended domains
  • Predictable behaviour: Developers maintain explicit control over which endpoints receive requests

Common Scenarios That Trigger Redirect Issues

Domain Migrations

External services frequently migrate domains:

  • api.oldcompany.comapi.newcompany.com
  • http://api.example.comhttps://api.example.com

API Version Changes

APIs often redirect old versions to new ones:

  • /api/v1/resource/api/v2/resource

URL Normalisation

Servers may enforce trailing slashes or specific URL formats:

  • /api/users/api/users/
  • /API/Users/api/users

Load Balancer and CDN Behaviour

Infrastructure changes can introduce redirects:

  • Regional routing redirects
  • Authentication gateway redirects
  • CDN edge node redirects

Detecting Redirect Issues

Before implementing solutions, confirm that redirects are the problem:

public static void debugCallout(String endpoint) {
    Http http = new Http();
    HttpRequest request = new HttpRequest();
    request.setEndpoint(endpoint);
    request.setMethod('GET');
    request.setTimeout(30000);

    HttpResponse response = http.send(request);

    System.debug('Status Code: ' + response.getStatusCode());
    System.debug('Status: ' + response.getStatus());

    // Check for redirect status codes
    Integer statusCode = response.getStatusCode();
    if (statusCode == 301 || statusCode == 302 || statusCode == 307 || statusCode == 308) {
        System.debug('REDIRECT DETECTED');
        System.debug('Location Header: ' + response.getHeader('Location'));
    }

    // Log all headers for debugging
    for (String header : response.getHeaderKeys()) {
        System.debug('Header - ' + header + ': ' + response.getHeader(header));
    }
}

Top Tip: Always log the Location header when troubleshooting integration issues. A missing or unexpected redirect is one of the most common causes of “working locally but failing in Salesforce” scenarios.

Solution 1: Update the Endpoint URL

The simplest solution is to use the final destination URL directly. If you discover that your endpoint redirects, update your configuration to point to the final URL.

// Instead of the redirecting URL
// request.setEndpoint('http://api.example.com/data');

// Use the final destination
request.setEndpoint('https://api.example.com/v2/data');

Remember to update your Remote Site Settings or Named Credentials accordingly.

Best Practice: When integrating with external APIs, test the endpoint URL using a tool like Postman or cURL with redirect following disabled (curl -L follows redirects, omit -L to see the raw response). This reveals the actual behaviour Salesforce will encounter.

Solution 2: Implement Manual Redirect Following

When you cannot control the endpoint URL or need to handle dynamic redirects, implement redirect following in your Apex code:

public class HttpCalloutWithRedirect {

    private static final Integer MAX_REDIRECTS = 5;
    private static final Set<Integer> REDIRECT_CODES = new Set<Integer>{301, 302, 307, 308};

    public static HttpResponse sendWithRedirect(HttpRequest originalRequest) {
        Http http = new Http();
        HttpRequest request = originalRequest;
        HttpResponse response;
        Integer redirectCount = 0;

        while (redirectCount < MAX_REDIRECTS) {
            response = http.send(request);

            if (!REDIRECT_CODES.contains(response.getStatusCode())) {
                return response;
            }

            String newLocation = response.getHeader('Location');
            if (String.isBlank(newLocation)) {
                throw new CalloutException('Redirect response missing Location header');
            }

            // Handle relative URLs
            newLocation = resolveRedirectUrl(request.getEndpoint(), newLocation);

            // Create new request for redirect destination
            request = new HttpRequest();
            request.setEndpoint(newLocation);
            request.setMethod(getRedirectMethod(originalRequest.getMethod(), response.getStatusCode()));
            request.setTimeout(originalRequest.getTimeout());

            // Copy headers (excluding Host which should be recalculated)
            copyHeaders(originalRequest, request);

            redirectCount++;
        }

        throw new CalloutException('Maximum redirect limit (' + MAX_REDIRECTS + ') exceeded');
    }

    private static String resolveRedirectUrl(String originalUrl, String location) {
        // Handle absolute URLs
        if (location.startsWith('http://') || location.startsWith('https://')) {
            return location;
        }

        // Handle protocol-relative URLs
        if (location.startsWith('//')) {
            return 'https:' + location;
        }

        // Handle relative URLs
        Url original = new Url(originalUrl);
        if (location.startsWith('/')) {
            return original.getProtocol() + '://' + original.getHost() + location;
        }

        // Handle relative path (same directory)
        String path = original.getPath();
        Integer lastSlash = path.lastIndexOf('/');
        String basePath = lastSlash > 0 ? path.substring(0, lastSlash + 1) : '/';
        return original.getProtocol() + '://' + original.getHost() + basePath + location;
    }

    private static String getRedirectMethod(String originalMethod, Integer statusCode) {
        // 307 and 308 preserve the original method
        if (statusCode == 307 || statusCode == 308) {
            return originalMethod;
        }
        // 301 and 302 traditionally convert to GET (though this is debated)
        return 'GET';
    }

    private static void copyHeaders(HttpRequest source, HttpRequest target) {
        // Note: HttpRequest doesn't expose getHeaderKeys(), so you must track headers separately
        // or use a wrapper class. This is a simplified example.
    }
}

Usage Example

HttpRequest request = new HttpRequest();
request.setEndpoint('https://api.example.com/v1/data');
request.setMethod('GET');
request.setTimeout(30000);
request.setHeader('Authorization', 'Bearer ' + accessToken);

HttpResponse response = HttpCalloutWithRedirect.sendWithRedirect(request);

if (response.getStatusCode() == 200) {
    // Process successful response
    Map<String, Object> data = (Map<String, Object>) JSON.deserializeUntyped(response.getBody());
}

Performance Tip: Each redirect adds latency and consumes a callout from your governor limits. Salesforce allows 100 callouts per transaction—a 5-redirect chain uses 5 of those. Where possible, cache the final URL after discovering it.

Solution 3: Use Named Credentials with External Credentials

Named Credentials provide a more robust solution for managing endpoints and authentication. While they don’t automatically follow redirects, they centralise endpoint management:

HttpRequest request = new HttpRequest();
request.setEndpoint('callout:My_Named_Credential/api/data');
request.setMethod('GET');

Http http = new Http();
HttpResponse response = http.send(request);

When the external service URL changes, update the Named Credential configuration rather than modifying code.

Setting Up Named Credentials

  1. Navigate to Setup → Named Credentials
  2. Create a new Named Credential with the correct (non-redirecting) endpoint
  3. Configure authentication as required
  4. Reference it in code using the callout: prefix

Best Practice: Combine Named Credentials with the redirect-following code above. Use Named Credentials for the base URL and authentication, then handle any unexpected redirects programmatically.

Solution 4: External Services and API Specifications

For REST APIs with OpenAPI/Swagger specifications, External Services can simplify integration. However, the same redirect limitations apply—ensure the specification points to the final endpoint URL.

When registering an External Service:

  1. Verify the API specification uses the correct base URL
  2. Test endpoints individually for redirect behaviour
  3. Update the specification if the provider changes URLs

Handling Cross-Domain Redirects Securely

Cross-domain redirects require additional Remote Site Settings. If your integration might redirect to a different domain, you must authorise both:

Original: https://api.example.com → Remote Site Setting required
Redirect: https://cdn.example.com → Additional Remote Site Setting required

Without the second Remote Site Setting, the redirect following code throws a CalloutException.

Security Considerations

When implementing redirect following for cross-domain scenarios:

private static final Set<String> ALLOWED_REDIRECT_DOMAINS = new Set<String>{
    'api.example.com',
    'cdn.example.com',
    'auth.example.com'
};

private static void validateRedirectDomain(String redirectUrl) {
    Url parsed = new Url(redirectUrl);
    String host = parsed.getHost().toLowerCase();

    if (!ALLOWED_REDIRECT_DOMAINS.contains(host)) {
        throw new CalloutException('Redirect to unauthorised domain: ' + host);
    }
}

Best Practice: Maintain an explicit allowlist of permitted redirect domains. Never blindly follow redirects to arbitrary domains, as this creates security vulnerabilities.

Monitoring and Alerting

Implement monitoring to detect when redirects start occurring unexpectedly:

public class IntegrationMonitor {

    public static void logCalloutResult(String integrationName, HttpResponse response) {
        Integer statusCode = response.getStatusCode();

        // Log redirects as warnings for investigation
        if (statusCode >= 300 && statusCode < 400) {
            String location = response.getHeader('Location');

            // Create a custom object record or platform event for monitoring
            Integration_Log__c log = new Integration_Log__c(
                Integration_Name__c = integrationName,
                Status_Code__c = statusCode,
                Redirect_Location__c = location,
                Timestamp__c = DateTime.now(),
                Severity__c = 'Warning'
            );
            insert log;

            // Optionally publish a platform event for real-time alerting
            Integration_Alert__e alert = new Integration_Alert__e(
                Integration_Name__c = integrationName,
                Message__c = 'Unexpected redirect detected to: ' + location
            );
            EventBus.publish(alert);
        }
    }
}

Testing Redirect Handling

Write unit tests that verify redirect behaviour:

@isTest
public class HttpCalloutWithRedirectTest {

    @isTest
    static void testRedirectFollowing() {
        // Set up mock that returns a redirect
        Test.setMock(HttpCalloutMock.class, new RedirectMock());

        HttpRequest request = new HttpRequest();
        request.setEndpoint('https://api.example.com/old-endpoint');
        request.setMethod('GET');
        request.setTimeout(30000);

        Test.startTest();
        HttpResponse response = HttpCalloutWithRedirect.sendWithRedirect(request);
        Test.stopTest();

        System.assertEquals(200, response.getStatusCode(), 'Should follow redirect to successful response');
        System.assertEquals('{"data":"success"}', response.getBody());
    }

    @isTest
    static void testMaxRedirectLimit() {
        Test.setMock(HttpCalloutMock.class, new InfiniteRedirectMock());

        HttpRequest request = new HttpRequest();
        request.setEndpoint('https://api.example.com/infinite-loop');
        request.setMethod('GET');

        Test.startTest();
        try {
            HttpCalloutWithRedirect.sendWithRedirect(request);
            System.assert(false, 'Should have thrown exception for max redirects');
        } catch (CalloutException e) {
            System.assert(e.getMessage().contains('Maximum redirect limit'), 'Should indicate max redirects exceeded');
        }
        Test.stopTest();
    }

    private class RedirectMock implements HttpCalloutMock {
        private Integer callCount = 0;

        public HttpResponse respond(HttpRequest request) {
            HttpResponse response = new HttpResponse();

            if (callCount == 0) {
                response.setStatusCode(301);
                response.setHeader('Location', 'https://api.example.com/new-endpoint');
                callCount++;
            } else {
                response.setStatusCode(200);
                response.setBody('{"data":"success"}');
            }

            return response;
        }
    }

    private class InfiniteRedirectMock implements HttpCalloutMock {
        public HttpResponse respond(HttpRequest request) {
            HttpResponse response = new HttpResponse();
            response.setStatusCode(301);
            response.setHeader('Location', 'https://api.example.com/another-redirect');
            return response;
        }
    }
}

Conclusion

Salesforce’s decision not to automatically follow HTTP redirects is a deliberate security choice, but it creates challenges for integrations. The key points to remember:

  • Salesforce returns redirect responses directly rather than following them automatically
  • Update endpoint URLs to point to final destinations when possible
  • Implement redirect following in Apex when dynamic handling is required
  • Validate redirect domains to maintain security
  • Monitor for unexpected redirects to catch integration issues early
  • Configure Remote Site Settings for all domains in the redirect chain

By understanding this behaviour and implementing appropriate solutions, you build integrations that remain stable even as external services evolve their URL structures.