Rethinking the Setup: SignalR as an HTTP API Behind API Management in Azure
In a recent project, I needed to expose a SignalR hub hosted in an Azure Container App through Azure API Management (APIM). This turned out to be less straightforward than I initially expected. In this post, I’ll walk through the setup, the common pitfalls I encountered, and how I verified the integration using a basic connectivity test.
Background
Previously, I set up a WebSocket API in Azure API Management using the WebSocket configuration interface. The intent was to expose a SignalR hub running in an Azure Container App at the following endpoint:
https://ca-o8l7l7dkd6.graysea-006ff647.westus3.azurecontainerapps.io/hub1
On the APIM side, the exposed WebSocket endpoint was configured as:
wss://apim-ro-contract-engine-backend-dev.azure-api.net/realtime
However, when I tested the endpoint, I received a 404 error:
'Response status code does not indicate success: 404 (Resource Not Found).'
At first, I assumed that the issue was related to the hub path being missing. But even after ensuring that /hub1 was part of the request, the same error persisted.
Rethinking the Setup: SignalR as an HTTP API
After further investigation, I realized that I didn’t need to treat SignalR as a WebSocket connection at the APIM layer. Instead, I could treat it as a regular HTTP API and just proxy the negotiate endpoint, which is the first step in the SignalR connection flow.
Here’s the Terraform configuration I used to expose the SignalR negotiate endpoint through APIM:
resource "azurerm_api_management_api" "main" {
name = "realtime"
resource_group_name = var.api_management.resource_group
api_management_name = var.api_management.name
display_name = "realtime"
protocols = ["https"]
subscription_required = false
revision = "1"
path = "realtime"
import {
content_format = "openapi+json"
content_value = data.http.openapi.response_body
}
}
This setup successfully routed requests from the APIM endpoint to the negotiate endpoint hosted in the container app.
Verifying the Integration with an Integration Test
To validate that the APIM routing works as expected, I wrote a simple integration test that sends a POST request to the /negotiate endpoint of the SignalR hub. Here’s the test:
[Fact]
public async Task Negotiate()
{
// Arrange
var baseUrl = _endpointFixture.Endpoints[EndpointType.RegionalStamp];
using var handler = new HttpClientHandler
{
// Keep defaults; change only if you are testing against self-signed certs (not your case).
AutomaticDecompression = DecompressionMethods.All
};
using var http = new HttpClient(handler)
{
BaseAddress = new Uri(baseUrl),
Timeout = TimeSpan.FromSeconds(20)
};
// SignalR negotiate endpoint path:
// POST {hubPath}/negotiate?negotiateVersion=1
var negotiateUri = $"{HubPath}/negotiate?negotiateVersion=1";
// Act
using var resp = await http.PostAsync(negotiateUri, content: null);
// Assert: "exists" means: not 404.
if (resp.StatusCode == HttpStatusCode.NotFound)
{
var body = await resp.Content.ReadAsStringAsync();
Assert.Fail(
$"Negotiate endpoint not found at '{negotiateUri}'. " +
$"Check MapHub route. Response body: {Truncate(body, 500)}");
}
// If your hub is protected with auth, negotiate might return 401/403.
// In that case, the endpoint is still "in the right place".
if (resp.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
Assert.True(true);
return;
}
resp.EnsureSuccessStatusCode();
// Try to parse the negotiate payload. Azure SignalR typically returns JSON with url/accessToken.
// This schema is intentionally loose to avoid breaking on small changes.
var json = await resp.Content.ReadFromJsonAsync<NegotiateResponseLoose>();
Assert.NotNull(json);
// Depending on hosting/mode, one of these patterns is usually present:
// - url + accessToken (common with Azure SignalR)
// - connectionId (classic in-proc negotiate)
Assert.True(
!string.IsNullOrWhiteSpace(json!.Url) ||
!string.IsNullOrWhiteSpace(json.AccessToken) ||
!string.IsNullOrWhiteSpace(json.ConnectionId),
$"Negotiate response didn't contain expected fields. Raw: {await resp.Content.ReadAsStringAsync()}");
// If url is present, it should generally be https and often points to *.service.signalr.net
if (!string.IsNullOrWhiteSpace(json.Url))
{
Assert.StartsWith("https://", json.Url, StringComparison.OrdinalIgnoreCase);
}
}
This test verifies:
That the negotiate endpoint is reachable (i.e., not returning 404).
That if the hub is protected, the expected 401 or 403 is handled gracefully.
That the response contains at least one of the expected fields (url, accessToken, or connectionId), confirming a valid negotiate response.
Next Steps: Investigating Client Behavior Post-Negotiation
Now that the APIM correctly routes the initial negotiate request from the frontend (in this case, a React app), the next step is to ensure that subsequent WebSocket connections from the client are not bypassing APIM.
This is a common pattern with SignalR: after negotiation, clients often connect directly to the URL returned in the negotiate response, which may point to an Azure SignalR service or directly to the container app depending on how the hub is hosted. If you want all traffic — including WebSocket connections — to go through APIM, additional work may be needed, such as rewriting the returned URLs or using a reverse proxy setup.
Conclusion
Exposing SignalR through Azure API Management doesn’t require configuring it as a WebSocket API. Instead, you can treat it as a standard HTTP API and route the negotiate endpoint via APIM. This approach simplifies integration while still preserving control over the initial handshake.
The key takeaway is to treat SignalR’s negotiate endpoint like any other HTTP route and verify it independently. Once that works, further tests are needed to ensure post-negotiate traffic aligns with your architecture — especially if you’re trying to keep all traffic behind APIM.
If you’re using a hosted SignalR hub like in Azure Container Apps, this pattern can help simplify your setup while still maintaining gateway control through APIM.