TimDinh.IgMarkets.Client
1.0.0
dotnet add package TimDinh.IgMarkets.Client --version 1.0.0
NuGet\Install-Package TimDinh.IgMarkets.Client -Version 1.0.0
<PackageReference Include="TimDinh.IgMarkets.Client" Version="1.0.0" />
<PackageVersion Include="TimDinh.IgMarkets.Client" Version="1.0.0" />
<PackageReference Include="TimDinh.IgMarkets.Client" />
paket add TimDinh.IgMarkets.Client --version 1.0.0
#r "nuget: TimDinh.IgMarkets.Client, 1.0.0"
#:package TimDinh.IgMarkets.Client@1.0.0
#addin nuget:?package=TimDinh.IgMarkets.Client&version=1.0.0
#tool nuget:?package=TimDinh.IgMarkets.Client&version=1.0.0
TimDinh.IgMarkets.Client
A reusable, Native AOT compatible .NET client for the IG Markets REST Trading API.
Targets net8.0 and net10.0. No third-party dependencies.
Why this exists
The IG REST API is versioned per endpoint, tunnels its deletes through a header, and returns a deal reference rather than a deal outcome. This library hides those quirks behind a typed surface while staying trim-safe and reflection-free, so it can be linked into a Native AOT binary.
Every payload contract is generated by the System.Text.Json source generator. Nothing is built
by reflection at run time, so nothing the trimmer removes can be needed later.
Quick start
using TimDinh.IgMarkets;
using TimDinh.IgMarkets.Models.Common;
using TimDinh.IgMarkets.Models.Dealing;
using var client = new IgClient(new IgClientOptions
{
ApiKey = "your-api-key",
Environment = IgEnvironment.Demo, // Demo is the default, so a misconfigured client cannot deal for real
});
await client.Session.LoginAsync("your-username", "your-password");
// Find something to deal on.
var search = await client.Markets.SearchAsync("EUR/USD");
var epic = search.Markets[0].Epic;
// Check what the market allows before sending a deal.
var market = await client.Markets.GetMarketAsync(epic);
Console.WriteLine($"{market.Instrument!.Name}: {market.Snapshot!.Bid} / {market.Snapshot.Offer}");
// Place a deal, then confirm what actually happened to it.
var deal = await client.Dealing.CreatePositionAsync(new CreatePositionRequest
{
Epic = epic,
Expiry = market.Instrument.Expiry!,
Direction = Direction.Buy,
CurrencyCode = "GBP",
Size = 1,
ForceOpen = true,
GuaranteedStop = false,
OrderType = OrderType.Market,
TimeInForce = PositionTimeInForce.FillOrKill,
});
var confirmation = await client.Dealing.GetConfirmationAsync(deal.DealReference);
if (confirmation.DealStatus is DealStatus.Rejected)
{
Console.WriteLine($"Rejected: {confirmation.Reason}");
}
await client.Session.LogoutAsync();
A deal reference is not a filled deal. Every dealing call returns only a
dealReference, which means IG accepted the instruction. You must callGetConfirmationAsyncto learn whether the deal was filled or rejected, and why.
Authentication
IG offers three login schemes. The client stores whichever credentials it receives and attaches them to every later request automatically.
Version 2 — session token headers (most common)
await client.Session.LoginAsync("username", "password");
Returns the CST and X-SECURITY-TOKEN header pair. These do not expire on a fixed timer, but
IG invalidates them after a period of inactivity.
Version 2 with an encrypted password
await client.Session.LoginEncryptedAsync("username", "password");
Fetches the RSA public key from GET /session/encryptionKey and encrypts the password with it, so
the plain text password never leaves the process. The scheme is
base64(RSA-PKCS1(base64("password|timestamp"))), implemented in IgPasswordEncryptor.
Version 3 — OAuth
await client.Session.LoginOAuthAsync("username", "password");
Returns a short-lived bearer token plus a refresh token, sent as Authorization: Bearer and
IG-ACCOUNT-ID. The access token expires after about 60 seconds. By default the client
detects a 401, refreshes the token, and retries the request once. Turn that off with
AutoRefreshOAuthToken = false if you would rather handle it yourself.
Inspect the current state at any time:
if (client.Authentication.IsAuthenticated) { /* ... */ }
client.Authentication.AccessTokenExpiresAtUtc; // null unless a v3 session is active
Endpoint coverage
The client is organised into groups on IgClient. Each call targets the latest documented
version of its endpoint; the Version header is set for you.
client.Session
| Method | Endpoint | Ver |
|---|---|---|
LoginAsync |
POST /session |
2 |
LoginEncryptedAsync |
GET /session/encryptionKey then POST /session |
1, 2 |
LoginOAuthAsync |
POST /session |
3 |
GetEncryptionKeyAsync |
GET /session/encryptionKey |
1 |
GetAsync |
GET /session |
1 |
SwitchAccountAsync |
PUT /session |
1 |
RefreshTokenAsync |
POST /session/refresh-token |
1 |
LogoutAsync |
DELETE /session |
1 |
client.Dealing
| Method | Endpoint | Ver |
|---|---|---|
GetPositionsAsync |
GET /positions |
2 |
GetPositionAsync |
GET /positions/{dealId} |
2 |
CreatePositionAsync |
POST /positions/otc |
2 |
ClosePositionAsync |
DELETE /positions/otc |
1 |
UpdatePositionAsync |
PUT /positions/otc/{dealId} |
2 |
GetWorkingOrdersAsync |
GET /workingorders |
2 |
CreateWorkingOrderAsync |
POST /workingorders/otc |
2 |
UpdateWorkingOrderAsync |
PUT /workingorders/otc/{dealId} |
2 |
DeleteWorkingOrderAsync |
DELETE /workingorders/otc/{dealId} |
2 |
GetConfirmationAsync |
GET /confirms/{dealReference} |
1 |
client.Markets
| Method | Endpoint | Ver |
|---|---|---|
GetMarketAsync |
GET /markets/{epic} |
3 |
GetMarketsAsync |
GET /markets?epics= |
2 |
SearchAsync |
GET /markets?searchTerm= |
1 |
GetPricesAsync |
GET /prices/{epic} |
3 |
GetCategoriesAsync |
GET /categories |
1 |
GetCategoryInstrumentsAsync |
GET /categories/{categoryId}/instruments |
1 |
client.Accounts
| Method | Endpoint | Ver |
|---|---|---|
GetAccountsAsync |
GET /accounts |
1 |
GetPreferencesAsync |
GET /accounts/preferences |
1 |
UpdatePreferencesAsync |
PUT /accounts/preferences |
1 |
GetActivityAsync |
GET /history/activity |
3 |
GetTransactionsAsync |
GET /history/transactions |
2 |
client.Watchlists
| Method | Endpoint | Ver |
|---|---|---|
GetWatchlistsAsync |
GET /watchlists |
1 |
GetWatchlistAsync |
GET /watchlists/{id} |
1 |
CreateWatchlistAsync |
POST /watchlists |
1 |
DeleteWatchlistAsync |
DELETE /watchlists/{id} |
1 |
AddMarketAsync |
PUT /watchlists/{id} |
1 |
RemoveMarketAsync |
DELETE /watchlists/{id}/{epic} |
1 |
client.ClientSentiment
| Method | Endpoint | Ver |
|---|---|---|
GetAsync(marketIds) |
GET /clientsentiment?marketIds= |
1 |
GetAsync(marketId) |
GET /clientsentiment/{marketId} |
1 |
GetRelatedAsync |
GET /clientsentiment/related/{marketId} |
1 |
client.Operations
| Method | Endpoint | Ver |
|---|---|---|
GetApplicationsAsync |
GET /operations/application |
1 |
UpdateApplicationAsync |
PUT /operations/application |
1 |
DisableCurrentApplicationAsync |
PUT /operations/application/disable |
1 |
GetRepeatDealingWindowAsync |
GET /repeat-dealing-window |
1 |
client.IndicativeCosts
| Method | Endpoint | Ver |
|---|---|---|
GetOpeningCostsAsync |
POST /indicativecostsandcharges/open |
1 |
GetClosingCostsAsync |
POST /indicativecostsandcharges/close |
1 |
GetEditCostsAsync |
POST /indicativecostsandcharges/edit |
1 |
GetHistoryAsync |
GET /indicativecostsandcharges/history/from/{from}/to/{to} |
1 |
DownloadDurableMediumAsync |
GET /indicativecostsandcharges/durablemedium/{ref} |
1 |
Error handling
Any non-success status raises IgApiException:
try
{
await client.Session.LoginAsync("username", "wrong-password");
}
catch (IgApiException ex)
{
ex.StatusCode; // HttpStatusCode.BadRequest
ex.ErrorCode; // "error.security.invalid-details"
ex.ResponseBody; // the raw body, kept for diagnostics
}
ErrorCode is null when IG returns something that is not a JSON error payload — an HTML gateway
page, for example. The raw body is always preserved.
Dependency injection
Pass your own HttpClient to participate in an IHttpClientFactory pipeline:
services.AddHttpClient<IgClient>()
.AddStandardResilienceHandler();
services.AddSingleton(sp => new IgClient(
igOptions,
sp.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(IgClient))));
When you supply the HttpClient, you keep ownership of it; IgClient.Dispose only clears the
stored credentials. When the client creates its own, disposing it disposes both.
Threading: an IgClient instance is one IG session. Concurrent reads and deals on an
established session are fine. Do not log in concurrently on one instance — a login replaces the
stored credentials.
Native AOT
The library sets IsAotCompatible and IsTrimmable, and publishes with zero trim or AOT
analyzer warnings.
samples/IgMarkets.AotCheck exists to prove it. It runs the client against canned responses and
asserts on the results, so a contract that fell back to reflection would fail there even though it
passes on CoreCLR:
dotnet publish samples/IgMarkets.AotCheck -c Release -r osx-arm64
./samples/IgMarkets.AotCheck/bin/Release/net10.0/osx-arm64/publish/IgMarkets.AotCheck
Produces a ~5 MB self-contained native binary and exits non-zero if any check fails.
Implementation notes
Things that are easy to get wrong against this API, and how they are handled here.
The documentation's URL slugs are not the wire paths. The reference pages are served at
working-orders.html and client-sentiment.html, but the actual endpoints are /workingorders
and /clientsentiment, unhyphenated. This was verified against two independent client libraries
before it was written down.
Every delete is tunnelled. IG does not accept a real HTTP DELETE on these endpoints, so all
deletes are sent as POST carrying the _method: DELETE header, with {} as the body when there
is nothing else to send. This matches what the reference Python and Node clients do.
Request bodies are flat. The reference pages show wrapper names such as
authenticationRequest and createWatchlistRequest. These are schema model names, not JSON
envelopes; the real bodies have no wrapper.
Enums are strict. Enum members are declared in PascalCase and converted to IG's
UPPER_SNAKE_CASE on the wire by a single generic converter. Every documented constant is
present, including IG's own misspelling of PARTIALY_CLOSED_POSITION_NOT_DELETED. If IG adds a
constant this library does not know, deserialization throws a JsonException naming the value,
which is preferable to silently reporting a deal in the wrong direction. Open an issue and the
value gets added.
Dates stay strings. IG returns at least four different date formats across endpoints, some
without a timezone, and the format varies by endpoint and version. Rather than guess and risk a
silent off-by-one-hour bug, date fields are surfaced exactly as IG sends them. Fields IG documents
as snapshotTimeUTC or createdDateUTC are mapped explicitly, since the casing does not follow
the camelCase convention.
Some money fields are strings. GET /history/transactions returns profitAndLoss,
openLevel, closeLevel and size as preformatted strings, sometimes with a currency symbol.
They are surfaced unchanged rather than being coerced into decimal.
Deliberately not implemented
- Older versions of covered endpoints. IG keeps v1 of
/positions,/workingorders,/history/activityand others alongside newer versions, along with dated-path variants such asGET /prices/{epic}/{resolution}/{numPoints}andGET /history/activity/{fromDate}/{toDate}. Each endpoint is implemented once, at the newest documented version, whose query-parameter form supersedes the dated paths. GET /markets/{epic}version 4. The reference lists it but documents the version 3 schema, so version 3 is what is implemented rather than a guess at version 4./marketnavigation. A real endpoint for browsing IG's market hierarchy, but absent from the API reference this library was built against./categoriescovers the documented equivalent.- The streaming API. IG's live prices run over Lightstreamer, which is a separate protocol and
a separate library.
SessionDetails.LightstreamerEndpointgives you the endpoint if you want to wire one up.
Building and testing
dotnet build # both target frameworks, warnings as errors
dotnet test # 101 tests
dotnet publish samples/IgMarkets.AotCheck -c Release -r osx-arm64
The test suite substitutes the HTTP layer with NSubstitute and asserts on the exact bytes sent —
paths, Version headers, the _method override, enum wire names and omitted optional fields — as
well as on how responses are mapped back.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. 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 is compatible. 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. |
-
net10.0
- No dependencies.
-
net8.0
- No dependencies.
-
net9.0
- No dependencies.
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 |
|---|---|---|
| 1.0.0 | 69 | 9/6/2026 |