Esiipayment.Core 2.1.0-preview.1

This is a prerelease version of Esiipayment.Core.
dotnet add package Esiipayment.Core --version 2.1.0-preview.1
                    
NuGet\Install-Package Esiipayment.Core -Version 2.1.0-preview.1
                    
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="Esiipayment.Core" Version="2.1.0-preview.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Esiipayment.Core" Version="2.1.0-preview.1" />
                    
Directory.Packages.props
<PackageReference Include="Esiipayment.Core" />
                    
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 Esiipayment.Core --version 2.1.0-preview.1
                    
#r "nuget: Esiipayment.Core, 2.1.0-preview.1"
                    
#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 Esiipayment.Core@2.1.0-preview.1
                    
#: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=Esiipayment.Core&version=2.1.0-preview.1&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Esiipayment.Core&version=2.1.0-preview.1&prerelease
                    
Install as a Cake Tool

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_id is 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_type says SHA256WithRSA, 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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