SuccessMortgagePartners.Encompass.Client 0.5.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package SuccessMortgagePartners.Encompass.Client --version 0.5.0
                    
NuGet\Install-Package SuccessMortgagePartners.Encompass.Client -Version 0.5.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SuccessMortgagePartners.Encompass.Client" Version="0.5.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SuccessMortgagePartners.Encompass.Client" Version="0.5.0" />
                    
Directory.Packages.props
<PackageReference Include="SuccessMortgagePartners.Encompass.Client" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add SuccessMortgagePartners.Encompass.Client --version 0.5.0
                    
#r "nuget: SuccessMortgagePartners.Encompass.Client, 0.5.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package SuccessMortgagePartners.Encompass.Client@0.5.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=SuccessMortgagePartners.Encompass.Client&version=0.5.0
                    
Install as a Cake Addin
#tool nuget:?package=SuccessMortgagePartners.Encompass.Client&version=0.5.0
                    
Install as a Cake Tool

SuccessMortgagePartners.Encompass.Client

A .NET client for the ICE Mortgage Technology Encompass Developer Connect API.

  • OAuth endpoints: password grant, token exchange (act as another user), introspection, revocation
  • Cached API token that follows Encompass's lifetime rules (kept alive by use at least every 15 min, 24 h max)
  • Per-user calls: ForUser(encompassUserId) acts as another Encompass user through token exchange, with one cached token per user
  • Users: list internal users and look them up by login id, email or employee id
  • Loans: read a loan or only the entities you need, and read field values by id (field reader)
  • Loan pipeline: find loans with filters (field terms and and/or groups), page through them, and count matches
  • Compliance: read the Mavent compliance report already on a loan (never orders one)
  • Conditions: list a loan's standard underwriting, preliminary and post-closing conditions (read only)
  • Documents: list a loan's eFolder documents and attachments and download an attachment's file (read only)
  • IHttpClientFactory integration and options binding (AddEncompassClient)
  • API areas are added one at a time as each is verified against the live API (auth, users, loans, pipeline, compliance, conditions and documents today)
  • Targets net10.0 and netstandard2.0

Status: early. The surface area grows as we wrap more endpoints; see Extending.

Install

dotnet add package SuccessMortgagePartners.Encompass.Client

Configure

appsettings.json (or environment variables Encompass__ClientId, etc.):

{
  "Encompass": {
    "ClientId": "...",
    "ClientSecret": "...",
    "InstanceId": "BE11111111",
    "Username": "apiuser",
    "Password": "..."
  }
}

BaseAddress defaults to https://api.elliemae.com/; if you point it at a path (for example a proxy), end it with / (and note that a request path starting with / resolves to the host root, bypassing the prefix). Never commit real credentials; use user-secrets, Key Vault, or app settings.

Optional throttling settings: MaxConcurrentRequests (default: follow 80% of the limit Encompass reports, starting at 24) and ConcurrencyLimitRetries (default 3) control how the client stays under Encompass's concurrency limit; see "Concurrency" below.

Use

using SuccessMortgagePartners.Encompass.Client;
using SuccessMortgagePartners.Encompass.Client.DependencyInjection;

builder.Services.AddEncompassClient(builder.Configuration); // binds the "Encompass" section

// later, via DI
public sealed class WhoAmI(IEncompassClient encompass)
{
    public async Task<string?> CurrentUserAsync(CancellationToken ct)
    {
        var token = await encompass.Auth.RequestPasswordTokenAsync(ct);
        try
        {
            var info = await encompass.Auth.IntrospectAsync(token.AccessToken, ct);
            return info.UserName;
        }
        finally
        {
            // Not `ct`: a cancelled caller must still revoke the token it created.
            await encompass.Auth.RevokeAsync(token.AccessToken, CancellationToken.None);
        }
    }
}

This example requests a token explicitly because it exercises the OAuth endpoints themselves. API resource groups (added one area at a time) don't do this: they send requests through the registered IAccessTokenProvider, which caches one password-grant token and keeps it alive while it is in use.

encompass.Auth.ExchangeTokenAsync(actorToken, subjectUserId) returns a token that acts as another Encompass user, with that user's permissions.

Acting as a user

A service that serves several people (for example behind Entra sign-in) can make each call as the person it is for, so Encompass applies that user's persona and loan access and its audit trail shows them:

var matches = await encompass.Users.FindByEmailAsync("jane.doe@example.com", ct);
if (matches.Count != 1 || matches[0].AccountEnabled != true)
{
    throw new UnauthorizedAccessException("No single enabled Encompass user for this person.");
}

var user = matches[0];

IEncompassClient asUser = encompass.ForUser(user.Id); // cheap; create one per request
// asUser.<Area>.… calls now run as user.Id. asUser.ActingUserId == user.Id.

The id passed to ForUser decides whose access every call gets, so take it only from a mapping you trust (for example a verified employee id, then email, then a reviewed override list), never from request or model input. Run lookups on the API-user client: most users can't list users.

ForUser exchanges the configured API user's token for the subject (IUserTokenProvider, registered as a singleton) and caches one token per user with the same sliding expiry as the API token. A 401 on an API call drops that user's token and retries once with a new exchange; a 401 (or 400 invalid_grant/invalid_token) on the exchange itself retries with the API user's current token if another call already renewed it; otherwise, only if introspection shows the API user's token is no longer active, it drops that token (the next call gets a new one) and, at most once a minute, renews it at once and tries again. If no token can be had, the call throws and nothing is sent: a ForUser client never falls back to the API user. Concurrent calls for one user share one exchange. Encompass only allows the exchange when the API user is at or above the subject in the organization hierarchy (Super Administrators can act as anyone); other refusals surface as EncompassApiException.

Users

encompass.Users wraps GET /encompass/v3/users. ListAsync returns one page (UserListRequest maps to orgId, isRecursive, entities, filter, start, limit; at most 100 per page) and ListAllAsync reads pages until an empty one. FindByIdAsync, FindByEmailAsync and FindByEmployeeIdAsync search the root organization and all children and request the Summary,AccountInformation entities. Encompass can't filter on employee id, so FindByEmployeeIdAsync reads every user: cache its result. Encompass only returns users to administrators or personas with the Settings "Organizations/User" right.

Loans, pipeline, compliance and conditions

var count = await encompass.Pipeline.CountAsync(new LoanPipelineQuery
{
    Filter = LoanPipelineFilter.And(
        LoanPipelineFilter.Field("Fields.Log.MS.Date.<milestone name>", "01/01/2000", LoanPipelineMatchType.GreaterThanOrEquals, LoanPipelinePrecision.Day),
        LoanPipelineFilter.Field("Loan.CurrentMilestoneName", "<milestone name>", LoanPipelineMatchType.Exact)),
}, ct);

var query = new LoanPipelineQuery { Filter = LoanPipelineFilter.Field("Loan.LoanNumber", "123456", LoanPipelineMatchType.Exact) };
query.Fields.Add("Loan.LoanAmount");
var page = await encompass.Pipeline.QueryAsync(query, start: 0, limit: 25, ct);   // page.Loans[i].LoanId, .Fields["Loan.LoanAmount"]

var values = await encompass.Loans.ReadFieldsAsync(loanId, ["364", "1109"], ct);   // { "364": "...", "1109": "..." }
var fees = await encompass.Loans.GetAsync(loanId, ["Fees"], ct);                    // JsonElement with only the fees
var report = await encompass.Compliance.GetLoanReportAsync(loanId, ct);             // JsonElement?, null when the loan has none (404)
var conditions = await encompass.Conditions.ListAsync(loanId, ConditionType.Underwriting, cancellationToken: ct); // JSON array, [] when none
  • Pipeline (IPipelineResource) wraps POST /encompass/v3/loanPipeline. Encompass requires a filter, loan ids or loan folders; the total count comes from the X-Total-Count header (CountAsync asks for the count only with calculateTotalCount=CountOnly; under the default NoWait, a page reports no count when it isn't ready in time); the client rejects pages above 1000 (Encompass would silently cap them), and since offset paging over live data can shift, page on a total: set CalculateTotalCount = LoanPipelineTotalCount.WaitForCount so each page carries TotalCount, or call CountAsync once and page against that number (TotalCount is null whenever Encompass sends no usable count). Field values come back as text in Encompass's formats (dates like 2/14/2019 12:00:00 AM), unknown canonical names in Fields are silently dropped unless IgnoreInvalidFields = false (then 409), and a filter on an unknown field is a 409.
  • Loans.GetAsync wraps GET /encompass/v3/loans/{id}. A whole loan can be several hundred kilobytes, so pass entities (for example Fees, Milestones, ClosingCost). ReadFieldsAsync wraps POST /encompass/v3/loans/{id}/fieldReader and returns values as text keyed by field id.
  • Compliance.GetLoanReportAsync wraps GET /ecs/v1/complianceReports for a loan and returns null for the 404 Encompass sends when a loan has no report (a 204 too; an empty 200 is an error). The report file's download details (reportFileInfo, which include a credential) are removed before the report is returned.
  • Conditions.ListAsync wraps GET /encompass/v1/loans/{id}/conditions/underwriting, /preliminary and /postclosing (the standard conditions) and reads every page (100 at a time, until an empty page) and returns the whole JSON array (includeRemoved: true adds removed conditions; [] for a 204; an empty 200 or a body that isn't an array is an error). Enhanced conditions (/conditions without a type) aren't wrapped.
  • Loan ids, including LoanPipelineQuery.LoanIds, must be GUIDs (anything else is an ArgumentException), so an id can never change the request path. Exception messages include Encompass's error text, which can quote values you sent.

All of these run as the client's user. Encompass applies that user's loan access to Loans and Pipeline (and presumably Conditions, which is a loan endpoint; not verified); whether the separate compliance service (ECS) also enforces per-loan access is not verified, so check access first (for example ReadFieldsAsync(loanId, ["364"]) on the same ForUser client) before reading a report for a user.

Documents (eFolder)

var documents = await encompass.Documents.ListDocumentsAsync(loanId, start: 0, limit: 25, ct);    // documents.Items (JsonElement), documents.TotalCount
var attachments = await encompass.Documents.ListAttachmentsAsync(loanId, start: 0, limit: 25, ct);
using var file = await encompass.Documents.DownloadAttachmentAsync(loanId, attachmentId, ct);    // null when there are only page images
if (file is not null) { /* file.Content (Stream), ContentType, ContentLength, Storage */ }
var removed = await encompass.Documents.ListDocumentsAsync(loanId, 0, 25, includeRemoved: true, ct);    // also documents marked removed
var pages = await encompass.Documents.DownloadAttachmentPagesAsync(loanId, attachmentId,
    async (index, page, token) => await page.Content.CopyToAsync(target, token), ct);    // older storage: each page image (image/jpeg); 0 for cloud
  • ListDocumentsAsync, GetDocumentAsync and ListAttachmentsAsync wrap GET /encompass/v3/loans/{id}/documents[/{documentId}] and /attachments. Lists always send start and limit (the attachments list only reports a total when paged) and leave out documents marked removed unless you pass includeRemoved: true. Attachment metadata has no file type: type is Image or Native.
  • Attachment metadata has no file type, and assignedTo is only there for attachments in cloud storage: an attachment is unassigned when no document's active attachments reference it, not merely when assignedTo is missing. For attachments in older storage, fileSize is the size of the page images and overstates the file.
  • DownloadAttachmentAsync asks POST /encompass/v3/loans/{id}/attachmentDownloadUrl for a download URL and fetches the file at once, from cloud or older storage; it returns null when Encompass has only page images for the attachment and no original file. The URL and its credential (an elli-signature value, or a signature in the URL) work without sign-in for about 15 minutes, so they stay inside the client: they are never returned or put in an error (a network failure can still name the file host), and a failed file request throws with the status, error code and correlation id but no response body, because a storage host's error can echo the signed request. The file request goes through its own HttpClient (ServiceCollectionExtensions.DownloadHttpClientName, so handlers you add to the API client never see those URLs) with only that credential and no bearer token, skips the concurrency gate (Encompass doesn't count downloads) and isn't retried.
  • DownloadAttachmentPagesAsync makes the same URL request and then fetches each of the attachment's pages (the page images Encompass keeps for older storage, usually image/jpeg; thumbnails are skipped) in order, handing each to your callback and disposing it when the callback returns. The same rules apply to every page request, all page URLs are checked before the first is fetched, a page refused with 401/403 (the URLs expire after about 15 minutes) gets fresh URLs and one more try, and it returns the number of pages (0 when Encompass lists none, as for cloud storage). Use it for attachments DownloadAttachmentAsync returns null for, or to keep the page images as well.
  • The result's stream reads from the network, so dispose it; enforce any size limit by stopping reading, not only by ContentLength (which is the compressed size if you turn on automatic decompression); and pass a CancellationToken with a deadline, since the client's timeout only covers the wait for the response headers (cancelling it later ends the download, and reads then fail). Storage says which storage the file came from, and ContentType is the type Encompass declared with the URL when it gave one, else the file host's header; attachment metadata has no file type, so check for the %PDF marker or similar yourself.

IEncompassClient and the resource interfaces are meant to be consumed, not implemented, by applications: a new API area adds a member, which is a minor version bump. Mock the resource interfaces your code uses; a hand-written fake that implements IEncompassClient must expect new members on each update.

Failures throw EncompassApiException with StatusCode, ErrorCode (Encompass errorCode or OAuth error), CorrelationId (the X-Correlation-ID header, which ICE support asks for), and the raw ResponseBody.

Concurrency

Encompass allows a fixed number of concurrent API requests per environment (30 by default) and answers 429 Too Many Requests beyond it; the counter resets every second. Every API response reports the allowance in X-Concurrency-Limit-Limit and X-Concurrency-Limit-Remaining (OAuth responses don't). The client:

  • holds API requests in a gate so no more than MaxConcurrentRequests are in flight. By default the cap follows 80% of the reported limit, the share ICE recommends lenders stay within, starting at 24 until the first response;
  • retries a 429 after 1 s, 2 s, 4 s (or Retry-After), up to ConcurrencyLimitRetries times, then returns it to the caller;
  • retries once with a fresh token after a 401 (every token that gets a 401 is dropped from the cache, so a second 401 is returned to you without leaving a known-bad token behind);
  • exposes the last reported allowance as IEncompassClient.LastConcurrencyLimit.

Each 429 back-off is capped at 30 s (also when Retry-After asks for more) and adds up to 500 ms of random jitter. Redirects are never followed, so the bearer and Basic headers only ever go to BaseAddress. The cap is per process: if you run several instances against one Encompass environment (for example a scaled-out Azure Functions app), set MaxConcurrentRequests to roughly 80% of the limit divided by the instance count.

Extending

Add a resource group per API area (Loans, Documents, Pipeline, …), verified against the API reference and the integration tests:

  1. Interface in src/.../<Area>/I<Area>Resource.cs, implementation beside it (internal sealed).
  2. Expose it on IEncompassClient and construct it in EncompassClient.
  3. Send requests through EncompassClient.SendAsync (internal) and EnsureEncompassSuccessAsync, so the bearer token and error handling stay in one place.
  4. Unit tests in tests/…Client.Tests using FakeHttpMessageHandler (assert the exact URL, method, and body), and real calls in tests/…Client.IntegrationTests (see its README).

Return JsonElement until a payload is stable enough to deserve a model; typed models go in <Area>/Models/.

Build and test

dotnet build
dotnet test

CI and release

CI runs on Azure Pipelines from azure-pipelines.yml; the code stays on GitHub.

  • develop is the default branch. Pull requests target develop.
  • Every push and pull request to develop or main builds, tests and packs.
  • Every merge (push) to main also pushes the package to NuGet.org and creates the vX.Y.Z tag and GitHub release. The version is <Version> in src/SuccessMortgagePartners.Encompass.Client/SuccessMortgagePartners.Encompass.Client.csproj; a version with a suffix (1.2.0-preview.1) is published as a prerelease.
  • To release: bump <Version> on develop, then merge develop into main. If that version is already on NuGet.org from another commit, the run warns and publishes nothing (the pull request into main shows the same warning); if NuGet.org is still indexing it, the push fails instead, which is also safe. If a release run fails after the NuGet push, wait until the version shows on nuget.org, then use Rerun failed jobs on that run: it sees the package came from that commit and creates or updates the GitHub release. (A new run only does this while main still points at that commit; otherwise create the GitHub release by hand.) Don't push release tags by hand.

One-time setup in Azure DevOps:

  1. In the Development project, Pipelines → New pipeline → GitHub (YAML), pick this repo and the existing azure-pipelines.yml. Use the existing github.com_SuccessMortgagePartners connection.
  2. Library → Variable group encompass-client-release with secret variable NuGetApiKey (a NuGet.org key with Push new packages and package versions for the glob SuccessMortgagePartners.*; 0.1.0 creates the package). Allow the pipeline to use it, and add a Branch control check that allows only refs/heads/main, so no other branch can read the key.
  3. Service connection (GitHub) named github-releases-encompass-client, for creating the tag and GitHub release. GitHubRelease@1 only accepts OAuth ("Grant authorization") or personal access token connections, so the existing GitHub App connection github.com_SuccessMortgagePartners can't be used. With a token, use a fine-grained one limited to this repo with Contents: read and write. Either way, add a Branch control check allowing only refs/heads/main. Tokens expire and OAuth grants follow their user's access: when it stops working, the release step fails after the NuGet push; renew the token, update the connection, then use Rerun failed jobs.
  4. Environment encompass-client-nuget, with a Branch control check for refs/heads/main (the Release stage's lockBehavior: sequential already makes two quick merges publish one at a time). Add an approval check too if you want a person to confirm each publish.
  5. Set releaseEnabled: 'true' in azure-pipelines.yml. Until then, runs on main build and pack but don't publish.

License

MIT — see LICENSE.

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.7.0 53 11/9/2026
0.6.0 134 11/4/2026
0.5.0 94 11/4/2026
0.4.0 136 11/3/2026
0.3.0 113 11/2/2026
0.2.0 87 11/2/2026
0.1.0 105 11/1/2026