Esiipayment.Core
2.1.0-preview.1
dotnet add package Esiipayment.Core --version 2.1.0-preview.1
NuGet\Install-Package Esiipayment.Core -Version 2.1.0-preview.1
<PackageReference Include="Esiipayment.Core" Version="2.1.0-preview.1" />
<PackageVersion Include="Esiipayment.Core" Version="2.1.0-preview.1" />
<PackageReference Include="Esiipayment.Core" />
paket add Esiipayment.Core --version 2.1.0-preview.1
#r "nuget: Esiipayment.Core, 2.1.0-preview.1"
#:package Esiipayment.Core@2.1.0-preview.1
#addin nuget:?package=Esiipayment.Core&version=2.1.0-preview.1&prerelease
#tool nuget:?package=Esiipayment.Core&version=2.1.0-preview.1&prerelease
esiipayment-dotnet
A .NET runtime for ESIIPayment, a language-neutral contract for accepting payments from Ethiopian payment service providers.
You write checkout code once, against six closed statuses and nine possible
next-user-actions. Most providers are described by declarative manifest.yaml
files (Chapa, ArifPay, SantimPay, and the mock reference provider) that this
runtime interprets; a few whose APIs the DSL structurally cannot describe are
native providers, implemented as code in their own package (currently
Telebirr). Either way you reach them through the same IPaymentClient, and
adding a provider does not change your code — that is
Invariant I12,
and it is the whole point.
Install
dotnet add package Esiipayment.Core --version 2.1.0-preview.1
dotnet add package Esiipayment.Providers --version 2.1.0-preview.1
dotnet add package Esiipayment.AspNetCore --version 2.1.0-preview.1 # only if you host webhooks
dotnet add package Esiipayment.Providers.Telebirr --version 2.1.0-preview.1 # only if you accept Telebirr
Targets net8.0. --version is required for now because 2.1.0-preview.1 is
a prerelease; see Versioning.
| Package | What it gives you | Needed? |
|---|---|---|
Esiipayment.Core |
The interpreter: manifest loading and validation, expression language, flow executor, canonical JSON, idempotency and state persistence, credential/OAuth2 handling, webhook signature verification. Also NativePaymentClient, the base a native provider inherits its invariants from. No ASP.NET Core dependency — fine in a worker service, Azure Function, or console app. |
Always |
Esiipayment.Providers |
Every provider declaration as embedded resources — manifest.yaml for a manifest provider, capabilities.yaml for a native one — so you don't vendor the spec repo or wire up a git submodule. |
Unless you supply your own YAML |
Esiipayment.AspNetCore |
A minimal-API webhook endpoint that reads the raw request body before JSON parsing (so HMAC verification runs over the exact bytes received), plus DI wiring. | Only to receive provider callbacks |
Esiipayment.Providers.Telebirr |
The native Telebirr implementation: Fabric token exchange, per-request signing, response mapping. Separate so an app that never touches Telebirr doesn't carry it. See the warning in Native providers before relying on it. | Only to accept Telebirr |
Quick start
This runs a real charge against Chapa's sandbox. Swap "chapa" for "mock" and
the transport for your own stub to run with no credentials and no network.
using System.Text.Json.Nodes;
using Esiipayment.Core;
using Esiipayment.Core.Domain;
using Esiipayment.Core.Flows;
using Esiipayment.Core.Manifests;
using Esiipayment.Core.Persistence;
using Esiipayment.Providers;
// 1. Load the provider manifest. Nothing here is Chapa-specific except the slug.
var manifest = ManifestLoader.Load(BundledProviders.ManifestYaml("chapa"));
// 2. Configure one provider. `ctx` supplies the URL templates the manifest
// references; `credentials` supplies the fields its auth block declares.
var client = new PaymentClient(
manifest,
new HttpProviderTransport(new HttpClient(), manifest.Environments["sandbox"].BaseUrl),
new InMemoryPaymentStore(), // replace in production — see Persistence
ctx: new JsonObject
{
["webhook_url"] = "https://your.app/esiipayment/webhooks/chapa/order-1001",
["return_url"] = "https://your.app/thanks?orderId=order-1001",
},
credentials: new JsonObject
{
["secret_key"] = Environment.GetEnvironmentVariable("CHAPA_SECRET_KEY"),
});
// 3. Take a payment. The idempotency key is yours — an order id works well.
var result = await client.CollectAsync("order-1001", new JsonObject
{
["amount"] = 45_000, // MINOR units: 450.00 ETB
["currency"] = "ETB",
["email"] = "abebe.kebede@gmail.com",
["first_name"] = "Abebe",
["last_name"] = "Kebede",
});
// 4. Branch on the status and the next action — never on the provider.
Console.WriteLine(result.Status); // RequiresAction
amount is in minor units. Pass 45_000 for 450.00 ETB; the manifest
converts to whatever the provider's API expects via its amount_major
transform. Sending 450 here would charge 4.50 ETB.
Every provider reads only the intent fields it declares, so sending the union of
fields is safe — an unread field is simply never interpolated. Chapa needs
email/first_name/last_name; Telebirr needs only amount/currency.
The two things you branch on
PaymentStatus — exactly six, closed
| Status | Meaning |
|---|---|
RequiresAction |
The payer must do something. Read NextAction. |
Processing |
Outcome not yet known. Resolve via SyncAsync or a webhook. |
Succeeded |
Terminal. |
Failed |
Terminal. Read Failure. |
Canceled |
Terminal. |
Expired |
Terminal. |
The last four are terminal: once reached under an idempotency key, no further
transition is legal, including to a different terminal state. Use
result.Status.IsTerminal().
NextAction — exactly nine variants
switch (result.NextAction)
{
case RedirectToUrlAction r: return Redirect(r.Url);
case PollAction p: return ScheduleSync(p.IntervalMs);
case SubmitOtpAction otp: return PromptForOtp(otp.Length, otp.Hint);
case DisplayQrAction qr: return ShowQr(qr.Payload, qr.ImageUrl);
case DialUssdAction u: return ShowUssd(u.Code);
case AwaitDevicePushAction push: return ShowWaitingForApproval(push.DisplayRef);
case ShowTransferDetailsAction t: return ShowBankTransfer(t.AccountNumber, t.Institution, t.Reference, t.Amount);
case CaptureAction: return CaptureLater();
case NoneAction or null: return Done();
}
Handle all nine once and every current and future provider is covered. A
manifest may only emit the variants it declares in capabilities.next_actions,
and the runtime rejects one that doesn't.
Failures: a code and what to do about it
A Failed result always carries Failure, never null:
if (result.Failure is { } f)
{
Console.WriteLine($"{f.Code} / {f.RetryClass}"); // e.g. AuthFailed / DoNotRetry
}
RetryClass is the actionable half — SafeToRetry, DoNotRetry, or
ResolveFirst. Treat ResolveFirst as "you must ask the provider what really
happened before retrying, or you risk double-charging."
FailureCode has 14 members: InsufficientFunds, InvalidRecipient,
RecipientLimitExceeded, SenderLimitExceeded, DuplicateRequest,
AuthFailed, AuthorizationDeclined, ProviderUnavailable, ProviderTimeout,
InvalidRequest, UnsupportedOperation, Expired, CanceledByUser,
Unknown. RetryClassMap.Get(code) gives the mandated pairing.
The invariant that prevents double charges
If the runtime cannot tell whether the provider processed a request — connection
reset, timeout, an ambiguous 5xx — the result is Processing + Poll, never
Failed. Failed would signal "safe to retry" and a naive retry against a
request the provider did receive creates a second real payment. This is
Invariant I4,
and it means an unreachable provider looks like Processing, not an error.
Operations
await client.CollectAsync(key, intent); // take money
await client.PayoutAsync(key, intent); // send money
await client.RefundAsync(key, intent); // give it back
await client.SyncAsync(key); // ask the provider the current status
await client.CancelAsync(key); // abandon a pending payment
Collect/Payout/Refund originate a side effect, so they take an intent
and are guarded by the idempotency machinery: the record is persisted before
any network call, a replay of the same key and payload returns the recorded
result, and the same key with a different payload fails as
DuplicateRequest / DoNotRetry.
Sync/Cancel address a payment an earlier call already registered under that
key. They throw PaymentNotFoundException if there is no such record, and
short-circuit to the stored result if it is already terminal.
Calling an operation a provider doesn't declare throws InvalidOperationException.
Check first with manifest.Operations.ContainsKey(Operation.Refund).
Webhooks
The signature must be verified over the exact bytes received, so never let a
model binder touch the body first. With Esiipayment.AspNetCore:
app.MapEsiipaymentWebhooks((httpContext, provider) =>
{
if (!SupportedProviders.Contains(provider)) return null; // -> 404
var idempotencyKey = httpContext.Request.RouteValues["idempotencyKey"] as string ?? "";
// Resolve scoped services from THIS request, not an app-lifetime singleton.
var db = httpContext.RequestServices.GetRequiredService<AppDbContext>();
return BuildClient(provider, new EfCorePaymentStore(db), idempotencyKey);
});
The default route is /esiipayment/webhooks/{provider}/{idempotencyKey}; pass
pattern: to change it. Build each collect call's ctx.webhook_url to match,
because the DSL standardizes no body-field correlator — the per-payment callback
URL is how a callback names the payment it resolves.
Responses: 200 {"status":"..."} on success, 401 on signature failure, 404
for an unknown provider or idempotency key. Redelivery of an already-terminal
payment's webhook is a no-op returning the recorded outcome, never an error.
Without ASP.NET Core, call it yourself:
var result = await client.HandleWebhookAsync(idempotencyKey, rawBody, signatureHeaderValue);
It throws WebhookVerificationException (bad signature) or
WebhookUnknownPaymentException (no such payment). The header to read is
client.Manifest.Webhook?.SignatureHeader.
Persistence
IPaymentStore is where idempotency and flow state live. InMemoryPaymentStore
is for tests and demos only — it forgets everything on restart, which defeats
both idempotency and webhook correlation.
Implement six methods for production:
Task<PersistOutcome> TryPersistNewAsync(string key, Operation op, string payloadHash, CancellationToken ct);
Task<bool> ExistsAsync(string key, CancellationToken ct);
Task SaveResultAsync(string key, PaymentResult result, CancellationToken ct);
Task<PaymentResult?> GetResultAsync(string key, CancellationToken ct);
Task SaveFlowStateAsync(string key, JsonObject state, CancellationToken ct);
Task<JsonObject?> GetFlowStateAsync(string key, CancellationToken ct);
TryPersistNewAsync carries the whole idempotency contract and must be atomic —
a unique constraint on the key, not a read-then-write. Its PersistOutcomeKind
is New (proceed), ReplayExisting (return ExistingResult and make no
provider call), or DuplicateConflict (same key, different payload).
services.AddEsiipayment() registers the in-memory store so an app starts
immediately; override it with
services.AddScoped<IPaymentStore, YourStore>() afterward.
The store, not your own mirrored status column, is the authoritative status — a webhook updates it directly, which is what lets a page reflect a webhook-driven change with no extra wiring.
Provider manifests
BundledProviders.Names // ["arifpay","chapa","mock","santimpay","telebirr"]
BundledProviders.ManifestNames // ["arifpay","chapa","mock","santimpay"]
BundledProviders.NativeNames // ["telebirr"]
BundledProviders.ManifestYaml("chapa") // raw manifest.yaml
BundledProviders.CapabilitiesYaml("telebirr") // raw capabilities.yaml (native providers)
BundledProviders.MetadataYaml("telebirr") // tier, verification status, provenance
These are the declarations this SDK version was conformance-tested against,
pinned to the spec commit it was built from. Nothing requires them to come from
this package — read your own YAML from anywhere and pass it to
ManifestLoader.Load, which throws ManifestValidationException on a manifest
that doesn't satisfy the spec.
Check metadata.yaml before trusting a provider in production. Most are
community tier and provisional — reconstructed from public documentation, not
verified against a live account.
Native providers
Almost every provider is a manifest: data this runtime interprets, identical in
every language runtime. A few cannot be, because their APIs need something the
DSL deliberately cannot express — a signature over a canonicalized parameter
string, an opaque session, a non-HTTP channel. Those are
native providers:
a capabilities.yaml declaring what they can do, plus hand-written code per
runtime.
Native providers implemented by this runtime: telebirr. Supporting none is
still fully conformant, so check this list rather than assuming the provider
catalog reflects what .NET can reach.
They are reached through the same interface as everything else, which is the point:
using Esiipayment.Core.Native;
using Esiipayment.Providers.Telebirr;
IPaymentClient client = new TelebirrPaymentClient(
CapabilitiesLoader.Load(BundledProviders.CapabilitiesYaml("telebirr")),
new HttpProviderTransport(httpClient, baseUrl), // native providers carry no
store, // environments: base URL is yours
new TelebirrOptions
{
// Five values, and the two "app ids" are different: swapping them
// yields a valid signature over the wrong identity.
FabricAppId = config["Telebirr:FabricAppId"]!, // X-APP-Key header
AppSecret = config["Telebirr:AppSecret"]!, // buys the bearer token
MerchantAppId = config["Telebirr:MerchantAppId"]!, // biz_content.appid
MerchantCode = config["Telebirr:MerchantCode"]!, // merch_code / payee_identifier
MerchantPrivateKey = config["Telebirr:MerchantPrivateKey"]!, // PEM or base64 DER
NotifyUrl = $"{publicBaseUrl}/esiipayment/webhooks/telebirr/{orderId}",
});
var result = await client.CollectAsync(orderId, intent); // identical from here on
Verified against the Telebirr developer-portal sandbox on 2026-08-04: an order was created, its status queried, and the request signature accepted. What has not been exercised is a payer completing payment on the H5 page, so every settled-status path (
Succeeded,Canceled, a decline) is still untested. One thing bites before anything else:merch_order_idis rejected unless alphanumeric. See providers/telebirr/metadata.yaml for the exact line between what is verified and what is not.
Two details worth knowing if you ever debug this provider, both of which cost more time than they should:
sign_typesaysSHA256WithRSA, and the algorithm is RSASSA-PSS. The field is a literal the gateway expects, not a description of the padding. PKCS#1 v1.5 produces a perfectly well-formed signature that is rejected.- PSS salts randomly, so the same request signs to a different value every
time. That is why signing goes through
ITelebirrSignatureSource: cassette replay substitutes a fixed signature, in the same way it substitutes a fixed clock and UUID source, because a golden result embedding a real signature could never be byte-identical twice.
Writing one
Inherit NativePaymentClient. It carries the invariants that must not vary
between providers — the record is persisted before your first network call
(I8), a repeated key with a different payload is DuplicateRequest (I7), an
already-terminal payment short-circuits (I2), flow state is loaded and saved
around you (I6) — leaving you one method to implement, plus the one invariant no
base class can enforce for you:
protected override async Task<PaymentResult> ExecuteAsync(
NativeOperationRequest request, CancellationToken cancellationToken)
{
var outcome = await _transport.SendAsync(BuildRequest(request), cancellationToken);
if (outcome is not TransportOutcome.Success success)
{
// Invariant I4: an outcome you don't know is never Failed. The payer
// may have been charged. Indeterminate() builds byte-identically to
// what the manifest interpreter emits, which is what lets your
// cassettes assert the same golden files as any other provider's.
return Indeterminate(request);
}
...
}
Conformance is then yours to assert: esiipayment replay has no manifest to
interpret and exits 0 with a note, so your own test suite must replay that
provider's cassettes to its expected/*.json byte-for-byte. See
TelebirrConformanceTests in this repository for the shape, and
Step 12 of the provider tutorial
for the full obligation.
Custom transports
IProviderTransport is a single method, which makes stubbing trivial:
public sealed class MyTransport : IProviderTransport
{
public Task<TransportOutcome> SendAsync(ResolvedRequest request, CancellationToken ct = default) =>
Task.FromResult<TransportOutcome>(new TransportOutcome.Success(200, """{"status":"success"}"""));
}
Return TransportOutcome.Timeout.Instance for an ambiguous failure and the
runtime applies Invariant I4 for you. HttpProviderTransport(HttpClient, baseUrl)
is the production implementation; prefer an IHttpClientFactory client so
timeouts and handlers are configured centrally.
FlowExecutionException means the manifest and the provider's actual response
disagree — an unmapped status_map value, say. It is worth surfacing rather than
swallowing: it is precisely the signal a manifest author needs. Catch it on
both your checkout and webhook paths, since a provider can add a status
literal at any time.
Versioning
The package major tracks the manifest DSL generation this runtime implements, so
Esiipayment 2.x reads as "implements spec generation 2" (spec_version: "2.0").
2.0.0-preview.1 is the current published version — a prerelease, so NuGet
won't select it without an explicit version or --prerelease. Pin exact
versions rather than floating (2.0.*) for a payments dependency, and move all
three packages together: the bundled manifests are versioned alongside the
runtime that was tested against them.
Working on this repository
git clone --recurse-submodules https://github.com/miki-smart/esiipayment-dotnet.git
cd esiipayment-dotnet
dotnet test tests/Esiipayment.Core.Tests
Already cloned without submodules? git submodule update --init.
src/
Esiipayment.Core/ the runtime
Esiipayment.AspNetCore/ webhook endpoint + DI
Esiipayment.Providers/ manifests embedded from spec-repo at build time
samples/
Esiipayment.Samples.WebApi/ ASP.NET Core + EF Core (SQLite), showing exactly
what gets persisted across collect -> sync ->
webhook
tests/
Esiipayment.Core.Tests/ the conformance suite
spec-repo/ git submodule -> miki-smart/esiipayment
Run the sample
cd samples/Esiipayment.Samples.WebApi
dotnet run
It runs the mock provider, so no credentials are needed:
curl -X POST http://localhost:5010/payments/collect \
-H "Content-Type: application/json" \
-d '{"idempotencyKey":"DEMO-0001","method":"redirect","amount":15000,"currency":"ETB"}'
curl http://localhost:5010/payments/DEMO-0001 # what actually got persisted
A fuller worked example — an e-commerce API and a React storefront consuming these packages from nuget.org — lives in esiipayment-ecommerce-api.
Testing against an unreleased build
pack-local.ps1 packs all three projects into ../esiipayment-local-feed at
2.0.0-local. Consume it with a per-restore source override rather than editing
a committed nuget.config:
dotnet restore -s https://api.nuget.org/v3/index.json -s ../esiipayment-local-feed
NuGet caches by exact version and 2.0.0-local never changes, so clear the
cached copy after re-packing:
dotnet nuget locals http-cache --clear
rm -rf ~/.nuget/packages/esiipayment.*
Conformance
Every manifest-driven provider in the spec repo — mock, chapa, arifpay,
santimpay, telebirr — replays all of its cassettes byte-identical to their
golden expected/*.json output (47 cassettes), and every vector in
spec-repo/vectors/ passes. Discovery is automatic, so anything added upstream
is picked up with no test-code change.
Releasing
Publishing runs on a published GitHub Release via
.github/workflows/publish-nuget.yml, using NuGet Trusted Publishing (OIDC, no
stored API key). The package version is derived from the release tag, and the
workflow refuses to publish an empty or -local version. The workflow's file
name is part of the nuget.org Trusted Publishing policy — renaming it breaks
the OIDC exchange with no matching policy.
nuget.org versions are immutable: a bad version can be unlisted but never deleted or reused. Rehearse on a prerelease tag before spending a stable one.
License
Apache 2.0, matching the spec repository.
| 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 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 was computed. 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. |
-
net8.0
- YamlDotNet (>= 18.1.0)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on Esiipayment.Core:
| Package | Downloads |
|---|---|
|
Esiipayment.AspNetCore
ASP.NET Core integration for the ESIIPayment runtime: dependency-injection registration and webhook endpoint wiring, so an application can accept provider callbacks and place charges without hand-rolling the plumbing. Use alongside Esiipayment.Core and Esiipayment.Providers. |
|
|
Esiipayment.Providers.Telebirr
The Telebirr native provider implementation for ESIIPayment. Telebirr cannot be expressed as a manifest — it signs a canonicalized parameter string on every request, which the manifest DSL deliberately cannot describe (spec/03-manifest-dsl.md#native-providers) — so its behaviour ships as code here instead, reached through the same IPaymentClient surface as any manifest-driven provider. PROVISIONAL: Telebirr's wire format and signing rule are reconstructed from public developer-portal references and third-party open-source clients, not confirmed against current Ethio Telecom documentation or a live sandbox. See providers/telebirr/metadata.yaml in the spec repository. Not sufficient to take real money as-is. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 2.1.0-preview.1 | 87 | 8/4/2026 |
| 2.0.0-preview.1 | 82 | 8/3/2026 |