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:
- Client sends request to original URL
- Server responds with 301/302 and
Locationheader - Client automatically sends new request to the
LocationURL - 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.com→api.newcompany.comhttp://api.example.com→https://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
Locationheader 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 -Lfollows redirects, omit-Lto 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
- Navigate to Setup → Named Credentials
- Create a new Named Credential with the correct (non-redirecting) endpoint
- Configure authentication as required
- 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:
- Verify the API specification uses the correct base URL
- Test endpoints individually for redirect behaviour
- 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.