Skip to content

Downstream API Calls

Configure your Forge application to call other APIs with proper authentication.

Property Value
Goal Call a downstream API from your application
Prerequisites Downstream API deployed, your app authorized in its config
Time Estimate 15-30 minutes
Difficulty Intermediate

📋 Overview

To call another API you need to:

  1. Infrastructure: be listed as an authorized app in the downstream API's configuration (see App Permissions)
  2. Code — configure a Kiota client with the downstream API's project ID and permission names

This guide covers the code side.

The Forge templates include SAIF.Platform.Kiota.HttpClientLibrary. A client requires an OpenAPI document and the .NET SDK version in Version Compatibility. The platform library wires authentication into Kiota; Kiota alone does not choose a Forge token flow. For a simple one-off HTTP call, or an endpoint without an OpenAPI document, use HttpClient directly instead of generating a Kiota client.


🚀 Quick Start

Step 1: Add a KiotaReference

Add a <KiotaReference> to your .csproj pointing to the downstream API's OpenAPI spec:

<ItemGroup>
  <KiotaReference Include="DownstreamClient" OpenApi="https://openapi.saif.com/it-api-sys-downstream/test/openapi.v1.yaml">
    <NamespaceName>YourApp.Clients.Downstream</NamespaceName>
  </KiotaReference>
</ItemGroup>

OpenAPI URL pattern: https://openapi.saif.com/{project-id}/{environment}/openapi.v1.yaml

Environment Path
Test /test/
Production /prod/

Step 2: Configure the HTTP Client

In Program.cs, register the client with the downstream API's project ID and the permission names you need:

builder.ConfigureHttpClient<DownstreamApiClient>(
    "it-api-sys-downstream",
    options => options.Scopes = ["Client.Read"]);

Use the Client.* scope names that the downstream API has defined — the same names for user-delegated and service-to-service calls. See Permission Names below.

Step 3: Use the Client

Inject the client into your endpoint or service:

app.MapGet("/data", async (DownstreamApiClient client) =>
{
    var result = await client.Resources.GetAsync();
    return Results.Ok(result);
});

The platform handles token acquisition, scope formatting, and provider selection automatically.


🏷️ Permission Names

options.Scopes always contains the downstream API's Client.* scope names — whether you call on behalf of a user or as your service. The token provider determines the access pattern, not the scope names.

What you need options.Scopes Token provider
Call on behalf of a user ["Client.Read"] DefaultAccessTokenProvider (default)
Call as your service (no user) ["Client.Read"] ClientCredentialsTokenProvider

App.* is granted, never requested

App.* roles are assigned to users or applications — they are never put in options.Scopes. Always use the downstream API's Client.* scope names.

Entra service-to-service always uses .default

When calling with ClientCredentialsTokenProvider via the Corp (Entra) path, the platform always sends api://{projectId}-{env}/.default — your access comes from the App.* roles the downstream API granted your app. The Client.* values in options.Scopes are used for the Okta path. Always configure them — Entra works without them, but Okta will fail without the explicit scope names.


⚙️ Configuration Options

ConfigureHttpClient

builder.ConfigureHttpClient<TClient>(
    projectId: "it-api-sys-target",
    configure: options =>
    {
        options.Scopes = ["Client.Read", "Client.Write"];
    });

HttpClientConfigurationOptions

Property Type Default Description
Scopes string[] [] Permission names to request
PrefixScopesWithProjectId bool true Prefixes scopes with project ID for the token request
AllowedSchemes string[] ["https", "http"] Allowed URL schemes for service discovery
DisableDefaultScopes bool false When true, skips auto-appending user-groups (Okta) and user_impersonation (Entra) for delegated flows

KiotaReference Options

<KiotaReference Include="ClientName" OpenApi="https://openapi.saif.com/project-id/test/openapi.v1.yaml">
  <NamespaceName>YourApp.Clients.ClientName</NamespaceName>
  <IncludePath>/pets;/pets/{id}#GET</IncludePath>
  <ExcludePath>/admin/**</ExcludePath>
</KiotaReference>
Property Description
Include Client class name (required)
OpenApi URL or path to OpenAPI specification (required)
NamespaceName Namespace for generated code
IncludePath Limit generation to specific paths (semicolon-separated)
ExcludePath Exclude specific paths from generation

🔑 Token Providers

Automatically selects the correct flow based on the incoming token. Use this for all new projects.

builder.ConfigureHttpClient<ApiClient>(
    "it-api-sys-downstream",
    options => options.Scopes = ["Client.Read"]);

TokenExchangeAccessTokenProvider

Use when you always want to call on behalf of the current user. OBO failures for app tokens surface as errors (no fallback).

builder.ConfigureHttpClient<ApiClient, TokenExchangeAccessTokenProvider>(
    "it-api-sys-downstream",
    options => options.Scopes = ["Client.Read"]);

ClientCredentialsTokenProvider

Use for service-to-service calls with no user context.

builder.ConfigureHttpClient<ApiClient, ClientCredentialsTokenProvider>(
    "it-api-sys-datasync",
    options => options.Scopes = ["Client.Read"]);

💡 Examples

Example 1: Calling a System API for User Data

// Program.cs
builder.ConfigureHttpClient<UserProfileClient>(
    "it-api-sys-userprofile",
    options => options.Scopes = ["Client.Read"]);

// Endpoint
app.MapGet("/my-profile", async (UserProfileClient client, ClaimsPrincipal user) =>
{
    var userId = user.FindFirstValue("sub");
    var profile = await client.Users[userId].GetAsync();
    return Results.Ok(profile);
});

Example 2: Background Job Calling an API

// Program.cs
builder.ConfigureHttpClient<DataSyncClient, ClientCredentialsTokenProvider>(
    "it-api-sys-datasync",
    options => options.Scopes = ["Client.Read", "Client.Write"]);

// Background service
public class SyncService(DataSyncClient client) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var data = await client.Sync.GetAsync();
            await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
        }
    }
}

Example 3: Aggregating Multiple Downstream APIs

// Program.cs
builder
    .ConfigureHttpClient<ClaimsClient>(
        "it-api-sys-claims",
        options => options.Scopes = ["Client.Read"])
    .ConfigureHttpClient<PolicyClient>(
        "it-api-sys-policy",
        options => options.Scopes = ["Client.Read"])
    .ConfigureHttpClient<CustomerClient>(
        "it-api-sys-customer",
        options => options.Scopes = ["Client.Read"]);

// Endpoint
app.MapGet("/dashboard/{customerId}", async (
    string customerId,
    ClaimsClient claimsClient,
    PolicyClient policyClient,
    CustomerClient customerClient) =>
{
    var customerTask = customerClient.Customers[customerId].GetAsync();
    var claimsTask = claimsClient.Claims.GetAsync(q => q.QueryParameters.CustomerId = customerId);
    var policiesTask = policyClient.Policies.GetAsync(q => q.QueryParameters.CustomerId = customerId);

    await Task.WhenAll(customerTask, claimsTask, policiesTask);

    return Results.Ok(new
    {
        Customer = customerTask.Result,
        Claims = claimsTask.Result,
        Policies = policiesTask.Result
    });
});

Version notes

Kiota 1.32.0+ and numeric default initialization

Kiota 1.32.0 and later initialize numeric and Boolean properties from their OpenAPI default values. APIs whose published OpenAPI document was generated with @saif/platform-typespec earlier than 3.8.0 can produce clients that fail with CS0103 for a missing Status property.

For the full symptom, cause, and fix, see Kiota-generated client fails with CS0103 for Status.

Duplicate properties are dropped from generated clients

With Kiota 1.35.0, declaring type on both ProblemDetails and a concrete error model causes the generated C# error to lose its Type property when Kiota replaces the base with ApiException. Unlike the status defect above, this missing property does not itself cause a build error. Documents generated with @saif/platform-typespec 3.8.0 through 3.9.2 contain this duplication in the platform error models.

The fix keeps type on ProblemDetails and omits it from the concrete models' property spreads. Release 3.9.3 contains this correction; releases through 3.9.2 do not. Update the API provider to a release containing the fix, republish its OpenAPI document, and regenerate the consuming client. Verify that type appears only on the base schema, that concrete error schemas still reference it through allOf, and that the generated errors expose both Type and Status.

Custom error models must therefore omit type from the ProblemDetailsProperties spread. See Building custom status models, including the C# server-emitter compatibility warning: preserving the hierarchy changes generated server constructors and their response bodies.

Service discovery: local to deployed

Forge's AddServiceDefaults() registers service discovery for HTTP clients. Locally, logical names come from your AppHost resources and WithReference() connections; see Golden Path: Service Discovery.

Name mapping when deployed

Deployed resolution reads Azure App Configuration, not an APIM backend lookup. When service discovery is enabled, saif-resources/modules/api/service_discovery.tf writes environment-labeled services:{projectId}:https and services:{projectId}:path keys for the Front Door hostname and APIM path. AddAzureDefaults() loads services:* into application configuration. The client keeps the same logical project ID, but resolves the deployed endpoint rather than a local AppHost port.

The APIM backend identifier remains a separate routing configuration. Finding a service-discovery entry does not prove APIM has a working backend or that the network and identity configuration permit the request.

Per-topology behavior

  • Private network access: Forge's private endpoint decision distinguishes APIM's ingress from private application resources. A connection failure can indicate network reachability rather than a bad logical name.
  • API versus frontend routing: API calls go through APIM; frontend traffic goes from Front Door to App Service without APIM. The PR slot activation guide explains the different cookies and headers.
  • PR slots: a slot does not require a different logical service name. The x-saif-slot-routing map and SlotRoutingDelegatingHandler select and propagate slot routing. AddJwtBearerServices(), AddOpenIdConnectServices(), or AddSlotRouting() register that propagation; see PR Slot Deployments for usage and the routing design for backend mechanics.
  • Identity boundaries: a token-exchange failure is not a service-discovery failure. See How Authentication Works and ADR 0006 for provider selection and tenant boundaries.

Troubleshooting deployed failures

When a call works locally but fails after deployment, check in order: the services:{projectId}:* keys exist with the target environment label, the APIM backend exists for the same service and environment, the caller can reach any private endpoints, the x-saif-slot-routing map includes the target project ID, and the caller and API use the intended Okta organization. Separate a connection error from a 401 or 403 before changing authentication.

🔍 Troubleshooting

"401 Unauthorized"

  1. Your app is not listed in the downstream API's authorized_apps; see App Permissions.
  2. Scopes in code don't match those granted in infrastructure
  3. Missing delegation scope — verify user_impersonation (corp) or user-groups (ext) is in the downstream's authorized_apps entry for your app

"403 Forbidden" After Successful Authentication

  1. Insufficient permissions — review the downstream API's @useAuth in TypeSpec
  2. Verify your app has the correct permission names in authorized_apps

Token Not Being Acquired

  1. The downstream API must be deployed before your app can discover it
  2. Both apps must be in the same environment (test, qa, uat, production)
  3. Check the downstream API's deployment logs for Terraform errors