SuccessMortgagePartners.Encompass.Client
0.7.0
dotnet add package SuccessMortgagePartners.Encompass.Client --version 0.7.0
NuGet\Install-Package SuccessMortgagePartners.Encompass.Client -Version 0.7.0
<PackageReference Include="SuccessMortgagePartners.Encompass.Client" Version="0.7.0" />
<PackageVersion Include="SuccessMortgagePartners.Encompass.Client" Version="0.7.0" />
<PackageReference Include="SuccessMortgagePartners.Encompass.Client" />
paket add SuccessMortgagePartners.Encompass.Client --version 0.7.0
#r "nuget: SuccessMortgagePartners.Encompass.Client, 0.7.0"
#:package SuccessMortgagePartners.Encompass.Client@0.7.0
#addin nuget:?package=SuccessMortgagePartners.Encompass.Client&version=0.7.0
#tool nuget:?package=SuccessMortgagePartners.Encompass.Client&version=0.7.0
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)
IHttpClientFactoryintegration 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.0andnetstandard2.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) wrapsPOST /encompass/v3/loanPipeline. Encompass requires a filter, loan ids or loan folders; the total count comes from theX-Total-Countheader (CountAsyncasks for the count only withcalculateTotalCount=CountOnly; under the defaultNoWait, 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: setCalculateTotalCount = LoanPipelineTotalCount.WaitForCountso each page carriesTotalCount, or callCountAsynconce and page against that number (TotalCountisnullwhenever Encompass sends no usable count). Field values come back as text in Encompass's formats (dates like2/14/2019 12:00:00 AM), unknown canonical names inFieldsare silently dropped unlessIgnoreInvalidFields = false(then409), and a filter on an unknown field is a409. Archived loans are left out unlessIncludeArchivedLoans = true.Loans.GetAsyncwrapsGET /encompass/v3/loans/{id}. A whole loan can be several hundred kilobytes, so passentities(for exampleFees,Milestones,ClosingCost).ReadFieldsAsyncwrapsPOST /encompass/v3/loans/{id}/fieldReaderand returns values as text keyed by field id.Compliance.GetLoanReportAsyncwrapsGET /ecs/v1/complianceReportsfor a loan and returnsnullfor the404Encompass sends when a loan has no report (a204too; an empty200is an error). The report file's download details (reportFileInfo, which include a credential) are removed before the report is returned.Conditions.ListAsyncwrapsGET /encompass/v1/loans/{id}/conditions/underwriting,/preliminaryand/postclosing(the standard conditions) and reads every page (100 at a time, until an empty page) and returns the whole JSON array (includeRemoved: trueadds removed conditions;[]for a204; an empty200or a body that isn't an array is an error). Enhanced conditions (/conditionswithout a type) aren't wrapped.- Loan ids, including
LoanPipelineQuery.LoanIds, must be GUIDs (anything else is anArgumentException), 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,GetDocumentAsyncandListAttachmentsAsyncwrapGET /encompass/v3/loans/{id}/documents[/{documentId}]and/attachments. Lists always sendstartandlimit(the attachments list only reports a total when paged) and leave out documents marked removed unless you passincludeRemoved: true. Attachment metadata has no file type:typeisImageorNative.- Attachment metadata has no file type, and
assignedTois only there for attachments in cloud storage: an attachment is unassigned when no document's activeattachmentsreference it, not merely whenassignedTois missing. For attachments in older storage,fileSizeis the size of the page images and overstates the file. DownloadAttachmentAsyncasksPOST /encompass/v3/loans/{id}/attachmentDownloadUrlfor a download URL and fetches the file at once, from cloud or older storage; it returnsnullwhen Encompass has only page images for the attachment and no original file. The URL and its credential (anelli-signaturevalue, 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 ownHttpClient(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.DownloadAttachmentPagesAsyncmakes the same URL request and then fetches each of the attachment'spages(the page images Encompass keeps for older storage, usuallyimage/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 with401/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 attachmentsDownloadAttachmentAsyncreturnsnullfor, or to keep the page images as well.GetAttachmentLinksAsync(loanId, attachmentIds)asks for the links of 1 to 100 attachments in oneattachmentDownloadUrlcall ({"attachments":[...]}), instead of one gated call per attachment. Pass the result to theDownloadAttachmentAsync(links, attachmentId)orDownloadAttachmentPagesAsync(links, attachmentId, page)overloads, which then make no call to Encompass. The links stay inside the result and expire about 15 minutes after they're issued, so ask only for what you will download soon. An attachment the links or their answer don't cover, or whose link the file host refuses with401/403, is asked for alone (once, or once per refused page). Encompass refuses the whole batch with404when any one id is unknown, so fall back to one attachment at a time whenGetAttachmentLinksAsyncthrows.IssuedAttells you when to ask for fresh links, andAttachmentLinks.MaxAttachmentsis the 100 limit. When mocking, set upGetAttachmentLinksAsync(for examplenew AttachmentLinks(loanId)) and thelinksoverloads: an overload that isn't set up returnsnull("only page images") or 0 pages.- 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 aCancellationTokenwith 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).Storagesays which storage the file came from, andContentTypeis 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%PDFmarker 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
MaxConcurrentRequestsare 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
429after 1 s, 2 s, 4 s (orRetry-After), up toConcurrencyLimitRetriestimes, then returns it to the caller; - retries once with a fresh token after a
401(every token that gets a401is dropped from the cache, so a second401is 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:
- Interface in
src/.../<Area>/I<Area>Resource.cs, implementation beside it (internal sealed). - Expose it on
IEncompassClientand construct it inEncompassClient. - Send requests through
EncompassClient.SendAsync(internal) andEnsureEncompassSuccessAsync, so the bearer token and error handling stay in one place. - Unit tests in
tests/…Client.TestsusingFakeHttpMessageHandler(assert the exact URL, method, and body), and real calls intests/…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.
developis the default branch. Pull requests targetdevelop.- Every push and pull request to
developormainbuilds, tests and packs. - Every merge (push) to
mainalso pushes the package to NuGet.org and creates thevX.Y.Ztag and GitHub release. The version is<Version>insrc/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>ondevelop, then mergedevelopintomain. If that version is already on NuGet.org from another commit, the run warns and publishes nothing (the pull request intomainshows 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 whilemainstill points at that commit; otherwise create the GitHub release by hand.) Don't push release tags by hand.
One-time setup in Azure DevOps:
- In the Development project, Pipelines → New pipeline → GitHub (YAML), pick this repo and the existing
azure-pipelines.yml. Use the existinggithub.com_SuccessMortgagePartnersconnection. - Library → Variable group
encompass-client-releasewith secret variableNuGetApiKey(a NuGet.org key with Push new packages and package versions for the globSuccessMortgagePartners.*; 0.1.0 creates the package). Allow the pipeline to use it, and add a Branch control check that allows onlyrefs/heads/main, so no other branch can read the key. - 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 connectiongithub.com_SuccessMortgagePartnerscan'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 onlyrefs/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. - Environment
encompass-client-nuget, with a Branch control check forrefs/heads/main(the Release stage'slockBehavior: sequentialalready makes two quick merges publish one at a time). Add an approval check too if you want a person to confirm each publish. - Set
releaseEnabled: 'true'inazure-pipelines.yml. Until then, runs onmainbuild and pack but don't publish.
License
MIT — see LICENSE.
| Product | Versions 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. |
-
.NETStandard 2.0
- Microsoft.Extensions.Http (>= 10.0.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.0)
- System.Text.Json (>= 10.0.0)
-
net10.0
- Microsoft.Extensions.Http (>= 10.0.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.