Aeroverra.PayPalSharp
0.0.0
See the version list below for details.
dotnet add package Aeroverra.PayPalSharp --version 0.0.0
NuGet\Install-Package Aeroverra.PayPalSharp -Version 0.0.0
<PackageReference Include="Aeroverra.PayPalSharp" Version="0.0.0" />
<PackageVersion Include="Aeroverra.PayPalSharp" Version="0.0.0" />
<PackageReference Include="Aeroverra.PayPalSharp" />
paket add Aeroverra.PayPalSharp --version 0.0.0
#r "nuget: Aeroverra.PayPalSharp, 0.0.0"
#:package Aeroverra.PayPalSharp@0.0.0
#addin nuget:?package=Aeroverra.PayPalSharp&version=0.0.0
#tool nuget:?package=Aeroverra.PayPalSharp&version=0.0.0
AeroPayPalSharp
A strongly-typed .NET client for the PayPal REST APIs, generated from PayPal's official OpenAPI specifications with NSwag and a transformer pipeline that fixes PayPal's spec quirks and tightens the models.
You register it once in your DI container, inject a single IPayPalApiClient, and call
resource-scoped sub-clients:
public sealed class Onboarding(IPayPalApiClient paypal)
{
public Task<WebhookList> ListWebhooks() => paypal.Webhooks.ListAsync();
public Task<Create_referral_data_response> ReferSeller(Referral_data data)
=> paypal.PartnerReferralsV2.CreateAsync(data);
}
- Target framework:
net10.0 - JSON: Newtonsoft.Json (PayPal-tuned - nulls omitted from request bodies)
- Auth: OAuth2 client-credentials, fetched + cached + refreshed automatically
- Partner/platform ready: partner-attribution and auth-assertion headers built in
Table of contents
- Install
- Get PayPal credentials
- Quick start
- Configuration
- How authentication works
- Partner & platform (multiparty)
- Multi-tenant: clients from raw credentials
- Using the clients
- Error handling
- Models & nullability
- Regenerating the clients
- Adding a new PayPal API
- Testing
- Project layout
- Roadmap
Install
Reference the Aeroverra.PayPalSharp project (or package, once published). It pulls in
Newtonsoft.Json and the Microsoft.Extensions.* DI/HTTP/Options packages it needs.
<ProjectReference Include="..\Aeroverra.PayPalSharp\Aeroverra.PayPalSharp.csproj" />
Get PayPal credentials
- Go to the PayPal Developer dashboard and create a REST API app. You get a Client ID and Secret for both Sandbox and Live.
- For partner/platform integrations you'll also have a BN code (partner attribution id) and, when acting for sub-merchants, their merchant id.
- Keep the client id/secret out of source control - user-secrets, environment variables, or a secret store (see Configuration).
Quick start
using Aeroverra.PayPalSharp;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddPayPalSharp(options =>
{
options.Environment = PayPalEnvironment.Sandbox; // or Live
options.ClientId = "AXG3..."; // from user-secrets / env in real code
options.ClientSecret = "EBcu...";
options.PartnerAttributionId = "YourPartner_SP_PPCP"; // optional BN code
});
var provider = services.BuildServiceProvider();
var paypal = provider.GetRequiredService<IPayPalApiClient>();
// list the webhook event types PayPal can send you
var catalog = await paypal.Webhooks.WebhooksEventTypesListAsync();
Console.WriteLine($"{catalog.Event_types.Count} event types available");
Configuration
Everything lives on PayPalOptions:
| Option | Type | Purpose |
|---|---|---|
Environment |
PayPalEnvironment |
Sandbox (default) or Live. Picks the base URL. |
ClientId |
string |
OAuth2 client id of your REST app. Secret - user-secrets/env. |
ClientSecret |
string |
OAuth2 client secret. Secret - user-secrets/env. |
PartnerAttributionId |
string? |
BN code, sent as PayPal-Partner-Attribution-Id on every call. |
MerchantId |
string? |
Sub-merchant id, used to build PayPal-Auth-Assertion. |
SendAuthAssertion |
bool |
When true, attach PayPal-Auth-Assertion to every call (act as MerchantId). Default false. |
WebhookId |
string? |
Your webhook id, for signature verification. |
BaseUrlOverride |
string? |
Override the environment-derived base URL. |
TimeoutSeconds |
int |
Per-request timeout (default 100). |
Binding from configuration
// Program.cs
builder.Services.AddPayPalSharp(builder.Configuration); // binds the "PayPal" section
appsettings.json (non-secret only):
{
"PayPal": {
"Environment": "Sandbox",
"PartnerAttributionId": "YourPartner_SP_PPCP"
}
}
Secrets via user-secrets (development) or environment variables (production):
dotnet user-secrets set "PayPal:ClientId" "AXG3..."
dotnet user-secrets set "PayPal:ClientSecret" "EBcu..."
# or: PayPal__ClientId=... PayPal__ClientSecret=... (env vars)
Never commit credentials.
appsettings.jsonshould only hold non-secret settings.
How authentication works
You never touch tokens. AddPayPalSharp wires:
PayPalTokenProvider- POSTsgrant_type=client_credentialsto/v1/oauth2/tokenwith HTTP Basic (clientId:clientSecret), caches the access token, and refreshes it ~60s before expiry. A semaphore collapses concurrent refreshes into a single request.PayPalAuthenticationHandler- aDelegatingHandlerthat attachesAuthorization: Bearer <token>to every request from every sub-client.
If you ever need a raw token (e.g. to call an endpoint not yet wrapped), inject the provider:
public sealed class Raw(IPayPalTokenProvider tokens)
{
public async Task<string> Bearer() => await tokens.GetAccessTokenAsync();
}
Partner & platform (multiparty)
Two partner headers are added automatically by PayPalPartnerHeaderHandler:
PayPal-Partner-Attribution-Id(your BN code) is sent on every request whenPartnerAttributionIdis set.PayPal-Auth-Assertionmakes a call run on behalf of a sub-merchant.
Acting on behalf of a seller
When you process for a seller there are two separate things, and it is easy to conflate them:
- Who receives the money is the order's
payee.merchant_id, in the request body (not a header). - Acting as the seller (using the permissions they granted your platform) is the
PayPal-Auth-Assertionheader.
The cleanest way to send that header is a scope on the client. Everything inside the using runs on
behalf of that merchant, and the value flows across awaits and is restored on dispose, so a single
injected client serves many sellers safely (including concurrently):
using (paypal.ActingAsMerchant(sellerMerchantId))
{
var order = new Order_request
{
Intent = "CAPTURE",
Purchase_units = new List<Purchase_units>
{
new Purchase_units
{
Amount = new Amount3 { Currency_code = "USD", Value = "10.00" },
Payee = new Payee3 { Merchant_id = sellerMerchantId }, // who gets the money
},
},
};
Order created = await paypal.Orders.CreateAsync(order, payPal_Request_Id: Guid.NewGuid().ToString("N"));
}
Other ways to set the assertion:
- Globally, for a client dedicated to one seller: set
SendAuthAssertion = true+MerchantIdin options (or onPayPalCredentialswhen building via the factory). Every call then acts as that merchant. - Explicitly per call, where PayPal exposes it: many methods take a
payPal_Auth_Assertionargument. Build the value withPayPalAuthAssertion.Build(clientId, merchantId)if you want to pass it yourself.
Per-call headers you set yourself always win; the handler only fills in what you did not.
Many create/capture methods also accept a
payPal_Request_Id(idempotency) argument and apreferargument (pass"return=representation"for the full object in the response).
Multi-tenant: clients from raw credentials
If you are NOT a single configured account (for example a service that processes payments for many
merchants, each with their own PayPal client id and secret), use IPayPalClientFactory to build a
client on the fly from any credentials. You do not need to register those credentials in DI.
Register the factory (this is also done automatically by AddPayPalSharp):
services.AddPayPalSharpFactory(); // registers IPayPalClientFactory only
Then, in a service, get a client for whichever account you are handling:
public sealed class PaymentService(IPayPalClientFactory paypalFactory)
{
public async Task<WebhookList> MerchantWebhooks(Merchant m)
{
IPayPalApiClient client = paypalFactory.Create(
m.PayPalClientId,
m.PayPalClientSecret,
PayPalEnvironment.Live,
partnerAttributionId: m.BnCode); // optional
return await client.Webhooks.ListAsync();
}
}
Or with the full PayPalCredentials record (adds merchant id, auth-assertion, base-URL override, timeout):
var client = paypalFactory.Create(new PayPalCredentials
{
ClientId = "...", ClientSecret = "...",
Environment = PayPalEnvironment.Live,
PartnerAttributionId = "YourPartner_SP_PPCP",
MerchantId = "SUBMERCHANTID", SendAuthAssertion = true,
});
No DI at all? Just new it up:
using var factory = new PayPalClientFactory();
var client = factory.Create(clientId, clientSecret, PayPalEnvironment.Sandbox);
The factory reuses one shared transport across every account (so it does not exhaust sockets) and caches one client per distinct credential set, so repeat calls for the same merchant reuse the cached OAuth token instead of fetching a new one each time.
Using the clients
Inject IPayPalApiClient and reach the sub-clients, or inject any sub-client interface directly
(IOrdersV2Client, IWebhooksV1Client, and so on).
All of PayPal's REST APIs are wrapped, each as a sub-client:
IPayPalApiClient member |
PayPal API | Example calls |
|---|---|---|
.Orders |
Orders v2 | CreateAsync, GetAsync, CaptureAsync, AuthorizeAsync, ConfirmAsync |
.Payments |
Payments v2 | AuthorizationsGetAsync, CapturesRefundAsync, RefundsGetAsync |
.Invoices |
Invoicing v2 | CreateAsync, SendAsync, ListAsync, RemindAsync, CancelAsync |
.Subscriptions |
Subscriptions v1 | CreateAsync, GetAsync, CancelAsync, PlansListAsync, PlansCreateAsync |
.CatalogProducts |
Catalog Products v1 | CreateAsync, ListAsync, GetAsync, PatchAsync |
.Disputes |
Disputes v1 | ListAsync, GetAsync, ProvideEvidenceAsync, AppealAsync, AcceptClaimAsync |
.Payouts |
Payouts v1 | PostAsync, GetAsync, PayoutsItemGetAsync, PayoutsItemCancelAsync |
.TransactionSearch |
Transaction Search v1 | SearchGetAsync, BalancesGetAsync |
.ShipmentTracking |
Add Tracking v1 | PostAsync, PutAsync, GetAsync, TrackersBatchPostAsync |
.PaymentTokens |
Payment Method Tokens v3 | CreateAsync, GetAsync, DeleteAsync, SetupTokensCreateAsync |
.WebProfiles |
Payment Experience v1 | CreateAsync, GetListAsync, UpdateAsync, DeleteAsync |
.PartnerReferralsV2 |
Partner Referrals v2 | CreateAsync, ReadAsync |
.PartnerReferralsV1 |
Partner Referrals v1 (deprecated) | CreateAsync, MerchantIntegrationStatusAsync |
.Webhooks |
Webhooks Management v1 | ListAsync, PostAsync, WebhooksEventTypesListAsync, VerifyWebhookSignaturePostAsync |
A few of these are shown in detail below; the rest follow the same shape.
Webhooks
client.Webhooks (IWebhooksV1Client) - manage subscriptions, browse event types, inspect and
resend event notifications, and verify signatures.
var wh = paypal.Webhooks;
// --- event type catalog (everything PayPal can send) ---
EventTypeList catalog = await wh.WebhooksEventTypesListAsync();
// --- create a subscription ---
Webhook created = await wh.PostAsync(new Webhook
{
Url = new Uri("https://your.app/paypal/webhook"),
Event_types = new DefinitionsEvent_type_list
{
new Event_type { Name = "PAYMENT.CAPTURE.COMPLETED" },
new Event_type { Name = "CHECKOUT.ORDER.APPROVED" },
},
});
// --- read / list ---
Webhook one = await wh.GetAsync(created.Id);
WebhookList mine = await wh.ListAsync();
// --- the event types a given webhook is subscribed to ---
EventTypeList subscribed = await wh.EventTypesListAsync(created.Id);
// --- event notifications (history) ---
EventList events = await wh.WebhooksEventsListAsync();
Event evt = await wh.WebhooksEventsGetAsync("<event-id>");
await wh.WebhooksEventsResendAsync("<event-id>", /* resend body */ null);
// --- simulate an event (great for testing your handler) ---
Event simulated = await wh.SimulateEventPostAsync(/* SimulateEvent body */ null);
// --- delete ---
await wh.DeleteAsync(created.Id);
Verify a webhook signature (server-side, when you receive a webhook):
var result = await paypal.Webhooks.VerifyWebhookSignaturePostAsync(new Verify_webhook_signature
{
Auth_algo = request.Headers["PAYPAL-AUTH-ALGO"],
Cert_url = new Uri(request.Headers["PAYPAL-CERT-URL"]),
Transmission_id = request.Headers["PAYPAL-TRANSMISSION-ID"],
Transmission_sig = request.Headers["PAYPAL-TRANSMISSION-SIG"],
Transmission_time = DateTimeOffset.Parse(request.Headers["PAYPAL-TRANSMISSION-TIME"]),
Webhook_id = options.WebhookId,
Webhook_event = deserializedEvent,
});
if (!string.Equals(result.Verification_status, "SUCCESS", StringComparison.OrdinalIgnoreCase))
return Unauthorized();
Partner Referrals v2
client.PartnerReferralsV2 - onboard sellers to PayPal Complete Payments. CreateAsync returns
HATEOAS links; send the seller to the action_url, then poll status.
var referral = new Referral_data
{
Tracking_id = "seller-42",
Operations = new Operation_list
{
new Operation
{
Operation1 = "API_INTEGRATION", // NSwag renamed the "operation" field -> Operation1
Api_integration_preference = new Integration_details
{
Rest_api_integration = new Rest_api_integration
{
Integration_method = "PAYPAL",
Integration_type = "THIRD_PARTY",
Third_party_details = new Third_party_details
{
Features = new Rest_api_integration_rest_endpoint_features_enum_list
{
"PAYMENT", "REFUND",
},
},
},
},
},
},
Products = new Product_list { "EXPRESS_CHECKOUT" },
Legal_consents = new Legal_consent_list
{
new Legal_consent { Type = "SHARE_DATA_CONSENT", Granted = true },
},
};
Create_referral_data_response created = await paypal.PartnerReferralsV2.CreateAsync(referral);
string actionUrl = created.Links.First(l => l.Rel == "action_url").Href; // redirect the seller here
string selfUrl = created.Links.First(l => l.Rel == "self").Href;
string referralId = selfUrl.TrimEnd('/').Split('/').Last();
Referral_data_response read = await paypal.PartnerReferralsV2.ReadAsync(referralId);
Partner Referrals v1 (legacy)
client.PartnerReferralsV1 - deprecated but included for parity. Adds merchant-integration and
partner-config operations beyond v2:
var status = await paypal.PartnerReferralsV1.MerchantIntegrationStatusAsync(/* partner id, merchant id */);
var creds = await paypal.PartnerReferralsV1.MerchantIntegrationCredentialsAsync(/* ... */);
var found = paypal.PartnerReferralsV1.MerchantIntegrationFindAsync(/* find by tracking id */);
New integrations should use v2. v1 exists so existing flows keep working.
Error handling
Every failed request throws a per-namespace PayPalApiException (e.g.
Aeroverra.PayPalSharp.WebhooksV1.PayPalApiException) carrying StatusCode and the raw Response
body. For responses PayPal documents with an error schema, the typed subclass
PayPalApiException<TError> also exposes the parsed error as .Result.
Because the typed subclass is what actually gets thrown, catch the base type (or use
Assert.ThrowsAny in tests):
try
{
await paypal.Webhooks.GetAsync(id);
}
catch (PayPalApiException ex) when (ex.StatusCode == 404)
{
// not found
}
catch (PayPalApiException ex)
{
_logger.LogError("PayPal {Status}: {Body}", ex.StatusCode, ex.Response);
throw;
}
Token acquisition failures throw PayPalAuthenticationException.
Models & nullability
PayPal's specs mark almost nothing required, so a naive generator makes every property nullable. Two transformers tighten this while staying crash-proof:
- Enums are strings. PayPal adds enum values constantly (payment states, dispute reasons,
processor codes...). A generated C# enum would throw on any value it hasn't seen, breaking
deserialization of an otherwise-fine response. So enums become
string, with the allowed values preserved in each property's XML doc comment. - Known fields are non-null. A curated, evidence-based set of always-present response fields
(a webhook's
id, a create-referral response'slinks, ...) is marked required so you don't null-check things that are always there. Request-only and uncertain fields are left nullable so an edge case never blows up parsing.
The set grows conservatively as tests confirm more fields (see MarkKnownRequired).
Regenerating the clients
Clients are produced by the Aeroverra.PayPalSharp.WrapperGenerator console app (build-time only -
nothing references it, so its NSwag dependencies never reach the shipped library). The raw PayPal
specs live in WrapperGenerator/Definitions/; a transformer pipeline runs over each in memory before
NSwag sees it.
# generate from the committed specs
dotnet run --project Aeroverra.PayPalSharp.WrapperGenerator
# refresh the raw specs from PayPal's GitHub first, then generate
dotnet run --project Aeroverra.PayPalSharp.WrapperGenerator -- --download
Output goes to Aeroverra.PayPalSharp/Generated/*.cs. Method names are derived from PayPal's dotted
operationIds with the resource prefix dropped: orders.create → client.Orders.CreateAsync,
event-types.list → client.Webhooks.EventTypesListAsync.
Adding a new PayPal API
Everything is data-driven, so adding e.g. Orders is a few edits:
- Add a
ClientSpecto theClientsarray inWrapperGenerator/Program.cs(name, spec file, class name, namespace, output file, resource key, download URL). - Run the generator - a new
Generated/OrdersV2Client.csappears. - Add the
UpdateJsonSerializerSettingshook for the new namespace inSerialization/GeneratedClientHooks.cs. - Expose it on
IPayPalApiClient/PayPalApiClient(e.g..Orders) and register it inServiceCollectionExtensions.AddApiClient<...>. - Add any resource-specific transformers (extra
MarkKnownRequiredentries, etc.) and tests.
Testing
Aeroverra.PayPalSharp.IntegrationTests runs against the real PayPal sandbox. Credentials come
from user-secrets and are never committed; when they're absent the tests skip (via SkippableFact)
instead of failing.
dotnet user-secrets --project Aeroverra.PayPalSharp.IntegrationTests set "PayPal:ClientId" "..."
dotnet user-secrets --project Aeroverra.PayPalSharp.IntegrationTests set "PayPal:ClientSecret" "..."
# optional: PartnerAttributionId, MerchantId, WebhookId
dotnet test
Current coverage (all green against sandbox): OAuth token issue + cache; webhook event-type catalog;
webhook list; full create → get → list-subscribed → delete → 404 round-trip; signature verification;
partner-referral create (asserts action_url) + read-back.
Project layout
| Project | Role |
|---|---|
Aeroverra.PayPalSharp |
The SDK you consume - options, auth, DI, the aggregate PayPalApiClient, and Generated/. |
Aeroverra.PayPalSharp.WrapperGenerator |
Regenerates Generated/*.cs from the OpenAPI specs (NSwag + transformers). Not shipped. |
Aeroverra.PayPalSharp.IntegrationTests |
Live sandbox xUnit tests. |
Roadmap
All 13 of PayPal's published REST APIs are wrapped. Remaining polish:
- More per-endpoint live tests across the newer clients (Invoices, Subscriptions, Payments, Disputes, Payouts, Payment Tokens, and so on).
- A growing
MarkKnownRequiredset as always-present response fields are confirmed per resource. - Optional cleanup of a few NSwag-numbered type names (for example the renamed
Operation1field).
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | 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
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Http (>= 10.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Options (>= 10.0.0)
- Newtonsoft.Json (>= 13.0.3)
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.17 | 130 | 7/7/2026 |
| 0.0.16 | 104 | 7/7/2026 |
| 0.0.15 | 105 | 7/7/2026 |
| 0.0.14 | 104 | 7/7/2026 |
| 0.0.13 | 108 | 7/7/2026 |
| 0.0.12 | 113 | 7/7/2026 |
| 0.0.11 | 111 | 7/7/2026 |
| 0.0.10 | 104 | 7/7/2026 |
| 0.0.9 | 101 | 7/7/2026 |
| 0.0.8 | 102 | 7/6/2026 |
| 0.0.7 | 110 | 7/6/2026 |
| 0.0.6 | 95 | 7/6/2026 |
| 0.0.5 | 106 | 7/6/2026 |
| 0.0.4 | 103 | 7/5/2026 |
| 0.0.3 | 104 | 7/5/2026 |
| 0.0.2 | 104 | 7/5/2026 |
| 0.0.1 | 102 | 7/5/2026 |
| 0.0.0 | 106 | 7/5/2026 |