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:
- Infrastructure: be listed as an authorized app in the downstream API's configuration (see App Permissions)
- 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¶
DefaultAccessTokenProvider (Recommended)¶
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-routingmap andSlotRoutingDelegatingHandlerselect and propagate slot routing.AddJwtBearerServices(),AddOpenIdConnectServices(), orAddSlotRouting()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"¶
- Your app is not listed in the downstream API's
authorized_apps; see App Permissions. - Scopes in code don't match those granted in infrastructure
- Missing delegation scope — verify
user_impersonation(corp) oruser-groups(ext) is in the downstream'sauthorized_appsentry for your app
"403 Forbidden" After Successful Authentication¶
- Insufficient permissions — review the downstream API's
@useAuthin TypeSpec - Verify your app has the correct permission names in
authorized_apps
Token Not Being Acquired¶
- The downstream API must be deployed before your app can discover it
- Both apps must be in the same environment (test, qa, uat, production)
- Check the downstream API's deployment logs for Terraform errors
📚 Related Documentation¶
- How Authentication Works: flow selection,
ScopeBuilder, and claim mechanics behind these options - App Permissions: configure the downstream API to accept your calls
- Kiota documentation — upstream client generation guidance
- TypeSpec — API contract definition
- Settings and Secrets: configuration management