Every "how to add payments to your SaaS" tutorial starts the same way: create a Stripe account. Stripe operates in a few dozen countries. If yours is not one of them, step one is where the tutorial ends. No workaround, no waitlist, just no.
If you are building from one of the many countries Stripe skips, this is the billing architecture I shipped instead for Reslug, running in production on .NET 9 with real revenue flowing through it.
Paddle, and why merchant of record matters more than the API
I chose Paddle, and the deciding factor was not the API. It was the merchant of record model. Paddle is legally the seller of my product. They charge the customer, they calculate and remit VAT and sales tax in every jurisdiction, they handle the invoices and the chargebacks, and they pay me out.
For a solo founder this is not a nice-to-have. Global SaaS tax compliance is a part-time job you did not apply for. EU VAT MOSS alone is enough paperwork to kill a side project. With merchant of record, that entire category of problem is somebody else's, in exchange for a higher fee than Stripe would charge. I consider it the best money Reslug spends.
No official .NET SDK, and why that turned out fine
Paddle has no official .NET SDK. My first reaction was annoyance. My second was relief, because the integration surface I actually need is small, and a typed HttpClient wrapper covers it in a few hundred lines:
public sealed class PaddleApiClient(
HttpClient http,
IOptions<PaddleOptions> options)
{
public async Task<PaddleSubscription?> GetSubscriptionAsync(
string subscriptionId, CancellationToken ct)
{
var response = await http.GetAsync(
$"subscriptions/{subscriptionId}", ct);
// ... deserialize, handle Paddle's response envelope
}
}Configuration goes through the Options pattern with validation that fails at boot, not at first checkout:
builder.Services.AddOptions<PaddleOptions>()
.Bind(builder.Configuration.GetSection(PaddleOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();If the API key or webhook secret is missing, the container refuses to start. A billing misconfiguration that surfaces as a failed deployment is a good day. One that surfaces as a customer's failed upgrade is not.
Webhooks are the real integration
The REST client is the easy half. The actual state of your billing system arrives through webhooks: subscription created, payment succeeded, subscription canceled, payment past due. Three properties of webhooks will hurt you if you ignore them.
They arrive more than once. Paddle retries on any non-success response, and retries can race the original. My handler is idempotent via a table whose primary key is the Paddle event ID:
var eventId = payload.EventId;
var alreadyProcessed = await db.PaddleWebhookEvents
.AnyAsync(e => e.EventId == eventId, ct);
if (alreadyProcessed)
return Ok(); // retry of an event we already handled
// ... apply the event, then record it in the same transaction
db.PaddleWebhookEvents.Add(new PaddleWebhookEvent
{
EventId = eventId,
ProcessedAt = DateTimeOffset.UtcNow
});Recording the event ID in the same transaction as the state change is the load-bearing detail. Split them and a crash between the two writes gives you either a lost event or a double-applied one.
They must be authenticated. Every incoming webhook is verified against Paddle's signature before a single byte of it is trusted. An unauthenticated billing webhook endpoint is an API for giving yourself free customers, or worse, for letting someone else do it.
They will eventually be missed. Not because Paddle is unreliable, but because your side will be, at some point, for some window. A deployment gone wrong, an expired secret, a bug that 500s for an hour. If webhooks are your only source of billing truth, a missed one leaves a customer paying for a plan your database says they do not have.
The reconcile service: never trust a single delivery channel
This is why Reslug runs SubscriptionReconcileService, a hosted background service that periodically pulls subscription state from the Paddle API and compares it with local state. When they disagree, Paddle wins and the local row is corrected.
The webhook path keeps state fresh within seconds. The reconcile path guarantees state converges even if the webhook path silently fails. Billing is exactly the domain where you want both, because every inconsistency is either lost revenue or a customer who paid and got nothing, and the second one costs trust you cannot buy back.
Fast plan checks without joins
Subscription state lives in a subscriptions table, one row per Paddle subscription, unique on the Paddle subscription ID. But plan checks happen constantly, on link creation, on API calls, on feature gates, and I refuse to join through billing tables on hot paths.
So the current plan is denormalized onto the user row as a string enum, updated by the webhook handler and the reconcile service. Hot paths read one column. The subscriptions table remains the audit trail and the source for reconciliation. Denormalization with a clearly designated writer is not a hack, it is a design.
What I gave up
Honesty section. Paddle's fees are higher than Stripe's. The checkout is Paddle's, so deep checkout customization is limited. Some payment methods popular in specific regions are missing. And because Paddle is the merchant of record, the customer's card statement says Paddle, which occasionally generates a confused support email.
Every one of these is real, and none of them outweighs operating legally in dozens of tax jurisdictions without hiring an accountant, from a country where the default option does not serve.
If Stripe is not available where you are, you are not blocked. You are just off the tutorial path, and the architecture on this path is honestly not worse. It forced me into idempotent webhooks and reconciliation loops that I would have needed at scale anyway.
I am building Reslug in the open as a production .NET case study. The billing system described here charges real customers at reslug.com today.
Keep learning
Full-length .NET courses on every platform I teach on.





