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.