PayGate.SDK 0.0.1

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

PayGate.SDK

.NET SDK for the PayGate.to payment gateway — the API behind withoutkyc.to.

Customers pay with cards, bank transfers or local rails through one of ~20 on-ramp providers, and the merchant is settled in USDC on Polygon. No API key is involved: every payment is bound to a freshly created encrypted receiving wallet.

Install

dotnet add package PayGate.SDK

Target framework: net9.0.

Quick start

builder.Services.AddPayGateClient("0xF977814e90dA44bFA03b6295A0616a897441aceC");
public class CheckoutService(IPayGateClient payGate) {
    public async Task<string> StartAsync(Order order, CancellationToken cancellationToken) {
        // 1. One wallet per payment, with a callback URL unique to this order.
        var wallet = await payGate.CreateWalletAsync(
            new CreateWalletRequest {
                callbackUrl = $"https://example.com/paygate/callback?order={order.id}"
            },
            cancellationToken
        );

        // 2. Store what you need to reconcile the callback later.
        order.receivingAddress = wallet.polygonAddressIn;
        order.ipnToken = wallet.ipnToken;

        // 3. Redirect the customer to the hosted checkout.
        return payGate.BuildHostedCheckoutUrl(new HostedCheckoutRequest {
            addressIn = wallet.addressIn,
            amount = order.amount,
            currency = "USD",
            email = order.customerEmail
        });
    }
}

How the HTTP client is used

This is the part worth reading before you ship. A payment gateway SDK is easy to get wrong in a way that only shows up under load, as SocketException: Only one usage of each socket address is normally permittedephemeral port exhaustion.

The SDK avoids it as follows.

One handler per gateway host, shared by every request. A SocketsHttpHandler owns a connection pool; a new handler means a new pool. The SDK registers exactly one named HttpClient per host through IHttpClientFactory, so api.paygate.to and checkout.paygate.to each get one pool and never contend with each other.

A cap on concurrent connections. On .NET MaxConnectionsPerServer is unlimited by default, so 500 concurrent calls will happily open 500 sockets. PayGateOptions.maxConnectionsPerServer (default 32) makes the surplus queue on existing connections instead:

builder.Services.AddPayGateClient(payoutAddress, options => {
    options.maxConnectionsPerServer = 64;
});

Connections are retired on a timer, not the handler. PooledConnectionLifetime (default 5 minutes) recycles connections so a long-lived client cannot pin a stale DNS record — the classic singleton-HttpClient bug. Because the handler already does this, the SDK sets the factory handler lifetime to Timeout.InfiniteTimeSpan; otherwise IHttpClientFactory would build a second handler every two minutes and each one would carry its own pool.

IPayGateClient is a stateless thread-safe singleton. Inject it anywhere, including into singletons. Do not wrap it in using.

Building a checkout URL costs nothing. BuildHostedCheckoutUrl and BuildProviderCheckoutUrl are pure string operations — no socket, no await. Only ResolveProviderPaymentUrlAsync makes a request.

Where the money goes

Funds travel provider → encrypted receiving wallet (polygon_address_in, held by the gateway) → your payout wallet. value_forwarded_coin in the callback is what actually arrived after the 1% platform fee.

The payout wallet can come from four places, in this order of precedence:

Source Use when
request.payoutAddress A different wallet per transaction.
IPayoutAddressProvider The wallet lives in an admin panel or database and can change at runtime.
IPayGateClientFactory.CreateClient(address) Multi-tenant, wallet resolved per tenant. Beats the provider — you named it explicitly.
PayGateOptions.payoutAddress One fixed wallet for the whole application.

A payout on Polygon is irreversible, and the gateway only rejects addresses that are structurally impossible. A typo in an otherwise well-formed address sends real funds to a wallet nobody controls. So every address is checked before the request leaves the process: the shape always, and the EIP-55 checksum whenever the address is mixed-case — which is what catches a single mistyped or transposed character. This applies to payout, affiliate, sub-affiliate and every mass-payout wallet.

EvmAddress is public, so validate on the way in too — at the admin form, not just at payment time:

if (!EvmAddress.IsValid(form.payoutWallet)) {
    return ValidationProblem("Not a valid Polygon wallet address.");
}

settings.payoutWallet = EvmAddress.ToChecksum(form.payoutWallet);   // store canonical form

Note that a mass payout has no merchant address at all — the payout wallets are exactly the keys of fees, so payoutAddress is ignored for that call.

If the gateway ever accepts a format EvmAddress does not recognize, set validateAddresses = false on the request.

Registration

Method Use when
AddPayGateClient(payoutAddress, configure?) One merchant wallet for the whole application.
AddPayGateClient(configure?) The payout wallet is set per request or by a provider.
AddPayGatePayoutAddressProvider(resolve) The wallet is read from an admin panel or database.
AddPayGateClientFactory(configure?) Multi-tenant: payout wallet or branding resolved per request.

Wallet from an admin panel

AddPayGateClient(payoutAddress) captures the address at startup, so it cannot change without a restart. Register a provider instead — it is consulted on each wallet creation, and what it returns is validated the same way:

builder.Services.AddPayGateClient();

builder.Services.AddPayGatePayoutAddressProvider(async (services, cancellationToken) => {
    var db = services.GetRequiredService<AppDbContext>();
    return await db.settings
        .Select(s => s.payoutWallet)
        .FirstOrDefaultAsync(cancellationToken);
});

The callback runs in its own DI scope, so a DbContext can be used directly without a captive-dependency problem. It fires once per wallet creation, so cache inside the callback if the lookup is expensive. Returning null falls back to PayGateOptions.payoutAddress.

Wallet per transaction

var wallet = await payGate.CreateWalletAsync(new CreateWalletRequest {
    callbackUrl = $"https://example.com/paygate/callback?order={order.id}",
    payoutAddress = order.sellerWallet
});
builder.Services.AddPayGateClientFactory(options => {
    options.timeout = TimeSpan.FromSeconds(20);
    options.maxConnectionsPerServer = 64;
});

// later, per tenant — all clients share the same connection pools
var client = factory.CreateClient(tenant.payoutWallet, tenant.branding);

Outside a DI container, keep one PayGateClientFactory for the process lifetime and dispose it on shutdown:

using var factory = new PayGateClientFactory();
var client = factory.CreateClient("0xF977814e90dA44bFA03b6295A0616a897441aceC");

API surface

Member Endpoint
CreateWalletAsync GET /control/wallet.php
CreateAffiliateWalletAsync GET /control/affiliate.php
CreateCustomAffiliateWalletAsync GET /control/custom-affiliate.php
CreateSubAffiliateWalletAsync GET /control/custom-sub-affiliate.php
CreateMassPayoutWalletAsync POST /control/mass-payout.php
GetProvidersAsync GET /control/provider-status/
ConvertToUsdAsync GET /control/convert.php
GetPaymentStatusAsync GET /control/payment-status.php
BuildProviderCheckoutUrl GET /process-payment.php (URL only, no request)
BuildHostedCheckoutUrl GET /pay.php (URL only, no request)
ResolveProviderPaymentUrlAsync GET /process-payment.php, returns the Location target

Callbacks

The gateway notifies the callback URL with a GET request when a payment settles. Callbacks are not signed, so treat one as a hint and verify it:

app.MapGet("/paygate/callback", async (HttpRequest request, OrderStore orders) => {
    if (!PayGateCallbackParser.TryParse(request.QueryString.Value, out var callback)) {
        return Results.BadRequest();
    }

    var order = await orders.FindAsync(callback.GetParameter("order"));

    if (order == null || !callback.MatchesWallet(order.receivingAddress)) {
        return Results.BadRequest();
    }

    if (callback.valueCoin < order.expectedUsd) {
        return Results.Ok();      // underpaid — do not fulfil
    }

    await orders.MarkPaidAsync(order, callback.txidOut);
    return Results.Ok();
});

callback.parameters holds every query parameter, including the ones you put into the callback URL yourself, so GetParameter("order") returns your own order id.

Things the gateway is strict about

  • A wallet per payment. Reusing a callback URL returns the previously created wallet instead of a new one, and two orders end up sharing a receiving address.
  • Fee splits must total 0.99. The remaining 1% is the platform fee. The SDK validates this before the request leaves the process and names the actual total in the exception. Shares are capped at 4 decimal places, which is what the query string carries — accepting more would let a split that sums to 0.99 locally go out rounded and no longer total it.
  • Request objects are single-use. IPayGateClient itself is thread-safe, but do not mutate or share a request instance while a call using it is in flight.
  • Mass payouts are capped at 19 wallets.
  • Affiliate and merchant wallets must differ.
  • Some providers are single-currencystripe, transfi, robinhood and bitnovo are USD only, upi is INR only, interac is CAD only. Check GetProvidersAsync for the live list and minimums.
  • No iframes. The customer must be redirected to the provider.
  • GetPaymentStatusAsync is rate limited. Drive order state from callbacks and use it for reconciliation, not polling. A 429 surfaces as PayGateRateLimitException with retryAfter.

Encoding

Every query value is percent-encoded exactly once, so pass callback URLs, emails and logo URLs unencoded. If you pre-encode a callback URL it goes out double-encoded and the gateway stores the wrong value.

address_in is the one exception: the API returns it already encoded, so it is accepted in either form and normalized to a single level. That is only safe because it is Base64 and cannot contain a literal % — the same trick applied to a callback URL would strip a level from a query that legitimately contains %26 and the callback would arrive split into two parameters.

Exceptions

All SDK exceptions derive from PayGateApiException, which carries statusCode and responseBody.

Exception Cause
PayGateValidationException 400 — bad wallet, reused callback, invalid fee split
PayGateRateLimitException 429 — carries retryAfter
PayGateNotFoundException 404
PayGateHttpException network failure, timeout, other non-success status
PayGateApiException malformed or empty response body

Argument problems are reported as plain ArgumentException before any request is made.

License

MIT

Product Compatible and additional computed target framework versions.
.NET 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 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

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
0.0.1 86 7/26/2026