Native Shell
Native Shell은 모든 플랫폼의 앱 안에서 만들어지는 AI UI입니다
The .NET SDK for the Anis Partner API — signed requests, verified responses, typed operations. NuGet: Anis.Partners
저장소 README에서 가져옴
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Version 2.2.0. 1.x is deprecated — upgrade.
The .NET client for the Anis Partner API. Targets net8.0 and net10.0.
Every request is signed, every answer Anis signs is verified, and the operations are typed. The parts that are easy to get dangerously wrong — the idempotency key, the credential-once rule, recovery after a timeout, response verification — are shaped so you cannot get them wrong quietly.
dotnet add package Anis.Partners --version 2.2.0
The Anis Partner API does not use an API key. Every request is signed with RFC 9421 HTTP Message
Signatures over P-256/SHA-256, the body is bound in with an RFC 9530 Content-Digest, and the
signature must be IEEE P1363 — the DER encoding of the same signature is refused. Every answer that moves
money, delivers card codes or establishes or stops access is signed too — orders, order reads, both reveals,
access stops, enrolment and
the signature self-check, the success and every refusal — and the contract requires clients to discard any of
those they cannot verify. The information reads (profile, wallets, catalogue, owned cards) are answered unsigned.
None of that fits an "auth provider" hook, which sets one header and never sees the method, authority,
path, query or body bytes. All of those are signed. Get one wrong and you get a 401 with — correctly — no
hint as to which.
builder.Services
.AddAnisPartners(builder.Configuration.GetSection("AnisPartners")) // Authority, SignatureLifetime, …
.WithSigner(EcdsaP256Signer.FromPemFile("/secure/partner-key.pem", keyId));
Then take IAnisPartnersClient from the container. Settings can also be given in code — see
Getting started. They are validated when the host starts.
WithSigner is a separate step on purpose: there is no default, because there is no safe guess about where
your private key lives. It can be any custody — IRequestSigner takes bytes and returns 64 bytes.
var profile = await anis.Profile.GetAsync(ct);
await foreach (var wallet in anis.Wallets.ListAsync(ct)) // cursor paging, handled
Console.WriteLine($"{wallet.Name} {wallet.Balance}");
await foreach (var card in anis.Catalogue.ListCardsAsync(walletId, subcategoryId, ct)) // every page, followed for you
Console.WriteLine($"{card.Name?.En} {card.UnitPrice}");
var catalogueCard = await anis.Catalogue.GetCardAsync(walletId, cardId, ct);
var ownedCard = await anis.OwnedCards.GetAsync(walletId, soldCardId, ct);
Console.WriteLine($"{ownedCard.SoldCardId} {ownedCard.Card?.FaceValue} {ownedCard.Card?.Category?.Name?.En}");
var credential = await anis.OwnedCards.RevealAsync(walletId, soldCardId, ct);
var credentials = await anis.OwnedCards.RevealInvoiceAsync(walletId, invoiceId, ct); // all or none, max 100
To stop access, supply a required reason (1–500 characters after trimming). These calls need no scope and verify their signed answers. Resumption is staff-only; revocation is terminal.
var suspended = await anis.Access.SuspendApplicationAsync("Pause while investigating an incident", ct);
To revoke one key while the application is admitted:
var revoked = await anis.Access.RevokeKeyAsync(keyId, "Key exposed", ct);
MaskedCard uses SoldCardId and OperationId; product details are in CardSummary (Card). The same summary
appears in reveals and order SoldCards. Catalogue cards include Logo and Category (Category.Type is local
or international); subcategories include CategoryType and Description. Wallet currency is Balance.Currency.
All amounts use Money, a decimal string at scale three
with an uppercase three-letter currency. A wallet balance is negative (for example -150.000) while the wallet uses
allowed debt; prices and totals are never negative. Owned-card routes use /owned-cards.
Signed answers require tag="anis-response-v2" and bind both request signature headers. The verifier refuses
compromised keys, signatures after a key's retirement and signatures before any published key's activation.
The key document is cached for five minutes by default and at most; an unknown key triggers a refresh at most
once every 30 seconds. Retired keys remain published for 90 days after retirement. See
Security for the closed verification failure vocabulary.
Buying is deliberately less convenient — see Orders and recovery:
var operationId = Guid.NewGuid(); // yours; persist it, with the request, BEFORE sending
var request = new CreateOrderRequest
{
CardId = card.Id,
Quantity = 2,
ExpectedUnitPrice = card.UnitPrice!.Value, // the price THIS wallet pays
ExpectedTotal = card.UnitPrice!.Value.Multiply(2), // exact decimal, never a double
};
await db.RecordOrderIntentAsync(operationId, walletId, request, ct);
var outcome = await anis.Orders.CreateAsync(walletId, operationId, request, ct);
switch (outcome)
{
case OrderCompleted { CodesWithheld: true } w:
await support.ReportWithheldAsync(operationId, ct); // paid, but no codes released: never buy it again
break;
case OrderCompleted c:
await vault.StoreAsync(c.Credentials, ct); // paid; codes here, ONCE — store them first
break;
case OrderReplayed r:
await vault.FetchByInvoiceAsync(r.Order.InvoiceId!.Value, ct); // paid earlier; revealing again needs cards:reveal
break;
case OrderProcessing p:
await scheduler.ResumeAfterAsync(operationId, p.RetryAfter, ct);
break;
case OrderOutcomeUnknown u:
await scheduler.ResumeAfterAsync(operationId, u.SuggestedDelay, ct);
break;
case OrderNotPlaced n:
await CloseAsNotPlacedAsync(operationId, n.Refusal, ct); // NOT charged: fix cause, then use a new id
break;
default:
throw new InvalidOperationException($"Unhandled order outcome {outcome}");
}
Every outcome is a value — a refusal, a timeout and an answer that could not be verified included — so one
switch handles them all and ResumeAsync returns the same five cases. The call throws only for a mistake in the
call itself (an invalid request, a signer that failed before anything was sent) or when your own token cancels it.
(C# does not check a switch over a class hierarchy for completeness, hence the default case.)
Three more operation groups sell and follow what the wallet pays for, and two manage notifications. Every purchase keeps the rule that makes cards safe: the operation id is yours, persisted with the request before anything is sent, and an interrupted call is resumed with the same id.
// Meter charges: scope meter_charges:create (read and packages are implied) or meter_charges:read
var packages = await anis.MeterCharges.GetMeterPackagesPageAsync(walletId, cancellationToken: ct);
foreach (var listed in packages.Items)
Console.WriteLine($"{listed.Name} {listed.UnitPrice}");
var package = packages.Items.First(); // or let the buyer choose
var chargeId = Guid.NewGuid(); // persist it, with the request, BEFORE sending
var charge = new CreateMeterChargeRequest
{
PackageId = package.Id,
MeterNumber = "0268000710262", // leading zeros are part of the number
ExpectedTotal = package.UnitPrice,
};
var chargeOutcome = await anis.MeterCharges.CreateAsync(walletId, chargeId, charge, ct);
switch (chargeOutcome)
{
case MeterChargeFinal { Charge.Status: MeterChargeStatus.Succeeded } done:
await vault.StoreAsync(done.Charge.Receipt!, ct); // receipt and every token, in order
break;
case MeterChargeFinal failed: // failed, refund settled; failed.Charge.Refund says where the money is
await support.ReportAsync(failed.Charge, ct);
break;
case MeterChargeProcessing p:
await scheduler.ResumeAfterAsync(chargeId, p.RetryAfter, ct);
break;
case MeterChargeOutcomeUnknown u:
await scheduler.ResumeAfterAsync(chargeId, u.SuggestedDelay, ct);
break;
case MeterChargeNotPlaced n: // nothing was charged; a new attempt takes a new id
await CloseAsNotPlacedAsync(chargeId, n.Refusal, ct);
break;
}
// Direct recharge: scope direct_recharges:create (reads implied) or direct_recharges:read
var quote = await anis.DirectRechargeProducts.QuoteAsync(walletId, productId, "1500.000", DirectRechargeAmountIn.Fx, ct);
var rechargeId = Guid.NewGuid(); // persist it, with the request, BEFORE sending
var rechargeOutcome = await anis.DirectRecharges.CreateAsync(walletId, rechargeId, new CreateDirectRechargeRequest
{
ProductId = productId,
PayoutAmount = quote.Payout.Amount,
ExpectedTotal = quote.Total,
Recipient = [new() { Key = "recipient_number", Value = "01012345678" }],
}, ct);
// A paused direct recharge takes a recipient correction; 202 confirms acceptance, not the updated read:
var correction = await anis.DirectRecharges.UpdateRecipientAsync(rechargeId, new UpdateDirectRechargeRecipientRequest
{
Recipient = [new() { Key = "recipient_number", Value = "01098765432" }],
}, ct);
Console.WriteLine($"Correction accepted for {correction.OperationId}; read later at {correction.Location}");
// Or wait for a directRecharge.* webhook. The SDK does not follow Location or retry the PATCH.
// Notifications: no scope; every application manages its own endpoint
var endpoint = await anis.WebhookEndpoint.SetAsync(new SetWebhookEndpointRequest { Url = "https://pos.example.ly/hooks/anis" }, ct);
await anis.WebhookEndpoint.TestAsync(ct);
await foreach (var delivery in anis.WebhookEvents.GetWebhookEventsAsync(new WebhookEventsQuery { Status = WebhookDeliveryStatus.Failed }, ct))
await anis.WebhookEvents.RedeliverAsync(delivery.Id, ct);
A meter charge or Direct recharge that is accepted but not finished arrives as …Processing (the exact three-member
acknowledgment, or the full owner state while it is still pending). A finished one arrives as …Final, including a
failed meter charge whose refund is settled: that is a result, not an exception. A failed one with its refund still
pending stays …Processing: money was taken, read again. A refusal that proves nothing was
taken arrives as …NotPlaced; anything that proves nothing about the earlier attempt arrives as …OutcomeUnknown.
See Orders and recovery.
Notifications are verified before their contents are read. Capture the raw request bytes, then call
PartnerWebhookVerifier.VerifyAndParseAsync; see Webhooks. The same page explains the
endpoint challenge and why the event id must be recorded durably before you act on it.
The SDK will not generate your idempotency key. operationId is a required parameter. If the SDK made
one up, a dropped response plus an ordinary retry would mint a second key and buy the cards twice.
OrderResult is five records, not one object with a nullable credential list. Only the response that
first reports completion carries the credentials — and the SDK returns any response that carries them as
OrderCompleted, so they can never be hidden behind the "already delivered" case.
Every order outcome tells you the next safe action. OrderNotPlaced means nothing was bought (a new attempt takes a new id); OrderOutcomeUnknown means resume the same id; OrderProcessing also needs a resume. On a resume, a refusal that is not the order's recorded answer comes back as OrderOutcomeUnknown, because it says nothing about the earlier attempt. So do invalid_credentials, insufficient_scope and wallet_not_granted on a create: restore access, then resume the same id.
Observability is on by default and leaks nothing. One ActivitySource and one Meter, both named
Anis.Partners.Sdk, plus structured logging through your own ILoggerFactory. Tests capture every log
line, span tag and metric tag during a reveal, an order and an enrollment and assert that no voucher,
serial, signature, signature base, nonce or enrollment token appears in any of them.
Response verification has no off switch. The contract says clients discard what they cannot verify, so
there is no option to disable it and no way to ship with it accidentally off. Which answers are verified is fixed
per route, in the SDK's route table, never guessed from the answer: a signed route's answer that arrives with no
signature is discarded (SignatureMissing), and an information read's answer is returned as it arrives. On a signed
read, an unverifiable response raises UnverifiableResponseException; order calls return OrderOutcomeUnknown. Both
discard the unverified content.
All 39 routes this version speaks, in twelve operation groups — Access, Profile, Wallets, Catalogue, Orders,
OwnedCards, Diagnostics, MeterCharges, DirectRechargeProducts, DirectRecharges, WebhookEndpoint,
WebhookEvents — plus AnisEnrollmentClient for bootstrap. The wallet statement and wallet-to-wallet transfer are
not part of this surface. 48 public error codes, generated
from the error catalogue, each pinned by a test to its exception type and its order outcome. The signature
self-check (Diagnostics) needs diagnostics:use and is the right first call when a signature will not
verify, because it reports the exact facts the gateway built its base from.
Not "reviewed and looks right":
| Evidence | What it holds |
|---|---|
| Request vectors | The SDK signs, then Anis's own parser, base builder and verifier run over the result. A vector exists only if Anis rebuilt a byte-identical base and accepted the signature. They also record the body each nonce mutation sends — zero bytes on a reveal, {} on the self-check — a rule the gateway enforces, so the live run is its proof. |
| Response vectors | Signed by the gateway's production signer. Accept and reject cases — each rejection a single-change derivation, so a failure names the rule. They include the real repeat of a completed order (201 + Idempotency-Replayed), the recovered completion, and a recorded refusal. |
| Enrollment proof vectors | The SDK builds the possession proof, and Anis's own proof check rebuilds the message byte for byte and accepts it; the DER form of the same signature is refused. |
| Safety-code vectors | A real key, its thumbprint, the 16-character code Anis staff ask you to read, and every way an entry may be typed (case, dashes, spaces, look-alike letters, the full thumbprint, wrong and short input), derived by an independent Python implementation. |
| Contract drift tests | The route table is diffed against contracts/partner-public-v1.json in both directions, request profiles and signed answers included; the error codes against contracts/error-catalogue.json. New model members and requiredness are checked against the complete OpenAPI schemas; every owner file is pinned by SHA-256 and source revision. |
| Pipeline tests | Every order outcome as the live gateway sends it, every refusal code, cursor paging, the body of each nonce mutation, the idempotency header, money at scale three, settings binding, several applications in one host, a host that retries every call, an order that times out, enrollment end to end, and a tampered response being discarded. |
| Telemetry redaction | Every log line, span tag and metric tag captured, asserted to contain no secret — and asserted non-empty, so it cannot pass vacuously. |
| Python cross-checks | Independent implementations reproduce every request base, every response verdict, every enrollment proof and every safety code — proof the contract is expressible outside .NET. |
dotnet test Anis.Partners.Sdk.slnx # on net8.0 AND net10.0
The suites read the vectors from tests/Anis.Partners.Sdk.Tests/vectors — the same published set as
https://developers.anis.ly/vectors/index.json (override with -p:ConformanceVectors=<path>) — and fail loudly
rather than run zero vectors. Group manifests, when present, determine the expected inventory; no vector count is
hard-coded. The response corpus requires regeneration for the v2 profile before its conformance gate can pass.
On 2026-09-23 this SDK and its sample ran against a complete Anis stack: enrollment with a real key through staff confirmation, all 19 routes (every answer verified — before 1.4.0, when Anis still signed the information reads), twelve kinds of refusal, a staff-set orders limit, a key revoked in the middle of a run, the overlap while a key is replaced, a caller outside the allowed networks, and the same run on .NET 8. Four defects that no vector could see were found there and fixed before this version.
On 2026-10-07 1.4.0 ran against a complete stack with the gateway that signs only money, card and key answers: enrollment through staff confirmation, the eight information reads answered unsigned and accepted, an order and its replay, both reveals, a signed refusal (a changed price) and an unsigned one (a wallet not granted).
Version 2.x has no live certification from this lane yet; the dated runs above cover 1.x.
202 still processing, a timeout, an owner-side outage, the
recovered completion. They cannot be provoked by hand on a healthy stack; they are covered by Anis's
composed end-to-end suite and by this SDK's own tests.src/.../Errors/PartnerErrors.g.cs is generated. The catalogue names sdk-constants as one of its own
generation targets, so the direction is fixed: the catalogue is the source.
python3 tools/generate-errors.py
Never hand-edit the generated file — the drift test will fail on it, which is the point.
MIT © 2026 Aniscom for Technical Services (Anis).