After integrating payment providers such as Stripe, Skrill, PAY.nl and Mollie, I keep coming back to the same questions: how do we start a payment, send the customer to checkout, receive the result, and update our own system reliably?
The API fields differ. The integration lifecycle is familiar.
This article focuses on one-time hosted-checkout payments. The examples are original architectural samples, not code copied from a client system or a complete payment SDK. Embedded checkout, subscriptions, capture, refunds and disputes need additional capabilities.
The shared lifecycle
- Validate the order and calculate its amount on the server.
- Persist a payment attempt with its provider, merchant-account configuration and stable request key.
- Initialize the payment through the selected adapter.
- Store the provider reference and return the checkout URL.
- Redirect the customer to hosted checkout.
- Verify a server notification or reconcile through an authenticated provider API.
- Match the result to the stored attempt and commit an allowed state change.
- Trigger fulfillment from durable application state.
The browser return is a separate path. It may arrive before the notification, after it, or never.
Return URL is not payment confirmation
The return URL answers what should the customer see? A server callback answers what payment state can the application verify?
A success-looking query string is not evidence of payment. The return page reads local status and displays paid, pending or unsuccessful. If it performs server-side reconciliation, it uses the same verification and idempotent update path as the webhook. Never call a public callback from the browser to simulate confirmation.
Do not fail a payment simply because the customer closed checkout.
Where the providers differ
| Integration | Adapter responsibilities |
|---|---|
| Stripe Checkout | Create a Checkout Session and obtain its checkout URL. Verify webhook signatures against the raw body and interpret event/payment status, including delayed outcomes. |
| Mollie Payments API | Create a payment and use its checkout link. In the classic payment webhook flow, retrieve payment state through the authenticated API using the notified payment ID. |
| PAY.nl Order API | Create an order and handle Exchange notifications. Follow the selected version's verification, status lookup and acknowledgement protocol. |
| Skrill Quick Checkout | Prepare checkout with separate return/status URLs. Validate status notifications using the documented signature and merchant/transaction fields. |
These are specific integration examples. Providers offer multiple products and API generations; Mollie also documents newer webhook flows. Pin the integration you actually use.
A small shared contract
Use an explicit key instead of selecting a provider by searching its class name.
public enum PaymentProviderKey
{
Stripe,
Skrill,
PayNl,
Mollie
}
public enum PaymentState
{
Pending,
Authorized,
Paid,
Failed,
Canceled,
Expired
}
public sealed record InitializePayment(
Guid AttemptId,
long AmountMinor,
string Currency,
Uri ReturnUrl,
string RequestKey);
public sealed record InitializedPayment(
string ProviderReference,
Uri CheckoutUrl);
public sealed record CallbackEnvelope(
byte[] RawBody,
IReadOnlyDictionary<string, string[]> Headers,
string RawQuery,
string ContentType);
public sealed record CallbackReply(
int StatusCode,
string ContentType,
string Body);
public sealed record VerifiedPaymentObservation(
string ProviderReference,
string MerchantAccountKey,
long AmountMinor,
string Currency,
PaymentState State,
string? ProviderEventId);
public sealed record CallbackResult(
VerifiedPaymentObservation? Observation,
CallbackReply Reply);
public interface IPaymentProvider
{
PaymentProviderKey Key { get; }
Task<InitializedPayment> InitializeAsync(
InitializePayment request,
CancellationToken cancellationToken);
Task<CallbackResult> HandleCallbackAsync(
CallbackEnvelope callback,
CancellationToken cancellationToken);
}
Authorized is deliberately different from Paid. Each adapter maps only states that its chosen integration can verify.
Amounts use integer minor units here. Currency precision and conversion into provider-specific formats need tested adapter code; not every currency has two decimal places. Return URLs and callback URLs come from trusted server configuration.
The envelope preserves raw bytes for signature verification and multiple header values. The HTTP boundary must enforce size limits before reading the body. Avoid model binding that changes the signed payload.
A successful callback result means the adapter authenticated the notification and retrieved authoritative state where required. Valid but irrelevant events can return a null observation. Invalid authentication stops processing. The adapter does not mark an order paid.
PaymentFactoryProvider
The factory resolves dependency-injected adapters:
public sealed class PaymentFactoryProvider
{
private readonly IReadOnlyDictionary<
PaymentProviderKey, IPaymentProvider> providers;
public PaymentFactoryProvider(
IEnumerable<IPaymentProvider> providers)
{
this.providers = providers.ToDictionary(
provider => provider.Key);
}
public IPaymentProvider Get(PaymentProviderKey key)
{
if (!providers.TryGetValue(key, out var provider))
{
throw new NotSupportedException(
$"Payment provider '{key}' is not registered.");
}
return provider;
}
}
Duplicate keys fail during construction. Register adapters and factory with compatible lifetimes, usually scoped when their dependencies are scoped. Validate required registrations at startup.
The factory selects an adapter; business rules stay in the application. Credentials remain server-side. With multiple merchant accounts, resolve an allowlisted account configuration as well and persist its identity on the attempt. Untrusted callback fields must never choose credentials.
Register the providers in DI
Register each concrete adapter against IPaymentProvider, then register the factory. In Program.cs:
var builder = WebApplication.CreateBuilder(args);
// These are your concrete IPaymentProvider implementations.
// Register their SDK clients and validated options separately.
builder.Services.AddScoped<IPaymentProvider, StripePaymentProvider>();
builder.Services.AddScoped<IPaymentProvider, SkrillPaymentProvider>();
builder.Services.AddScoped<IPaymentProvider, PayNlPaymentProvider>();
builder.Services.AddScoped<IPaymentProvider, MolliePaymentProvider>();
builder.Services.AddScoped<PaymentFactoryProvider>();
var app = builder.Build();
The factory receives all four registrations through its injected provider collection. Resolving IPaymentProvider directly would select the last registration with the built-in container, so application services should inject the factory instead.
Scoped keeps the factory and adapters within the HTTP request scope and supports scoped dependencies such as a DbContext. Do not make the factory a singleton while its adapters are scoped. Background jobs must resolve it inside a scope too. Keep merchant configuration immutable for the operation; avoid shared mutable credentials.
At startup, validate the configured adapters in an explicit scope:
using (var scope = app.Services.CreateScope())
{
var factory = scope.ServiceProvider
.GetRequiredService<PaymentFactoryProvider>();
foreach (var key in Enum.GetValues<PaymentProviderKey>())
{
_ = factory.Get(key);
}
}
Place this after builder.Build() and before app.Run(). For a deployment that supports only some providers, validate its configured enabled set instead. Adapter constructors should not make network calls. The factory's dictionary rejects duplicate keys, while Get rejects missing registrations.
See Microsoft's dependency injection guidance for multiple implementations and service lifetimes.
Initialize a durable attempt
The following orchestration uses application ports. Their persistence and authorization behavior must be implemented, not replaced with an in-memory dictionary.
// Authorize access, calculate the order total, and create or
// reuse a durable attempt under a unique request key.
var attempt = await attempts.GetOrCreateAsync(
orderId, authenticatedCustomerId, cancellationToken);
var provider = factory.Get(attempt.Provider);
var initialized = await provider.InitializeAsync(
new InitializePayment(
attempt.Id,
attempt.AmountMinor,
attempt.Currency,
trustedReturnUrl,
attempt.RequestKey),
cancellationToken);
// Save before exposing the checkout URL to the customer.
await attempts.AttachProviderReferenceAsync(
attempt.Id,
initialized.ProviderReference,
cancellationToken);
return initialized.CheckoutUrl;
Pass the attempt reference through supported merchant-reference or metadata fields. The adapter uses its configured merchant account and notification URL.
Do not hold a database transaction across the provider network call. Where supported, send the stable request key as the provider idempotency key. A timeout does not prove initialization failed: the remote payment may already exist. Keep the attempt uncertain and reconcile before retrying blindly. Providers without equivalent idempotency support need their documented reference/status recovery flow.
A callback can race with saving the provider reference. Use a verified merchant reference for correlation when available, or retain the notification durably for retry. An unknown reference is not permission to create a paid order.
Verify the callback, then apply state
A known provider/account route selects an adapter. The route itself does not authenticate the sender.
var provider = factory.Get(providerKey);
var result = await provider.HandleCallbackAsync(
envelope, cancellationToken);
if (result.Observation is { } observation)
{
// One local transaction: match the attempt, validate
// merchant/amount/currency, deduplicate, apply an allowed
// transition, and insert an outbox message.
await reconciler.ApplyVerifiedAsync(
providerKey, observation, cancellationToken);
}
// Convert this into the HTTP status, content type and body
// expected by the selected provider's protocol.
return result.Reply;
This is orchestration, not a drop-in HTTP endpoint. The reconciler is an application port with real transactional guarantees. Send the provider's acknowledgement only after committing local work or durably accepting verified work into an inbox.
Slow work can run after durable inbox acceptance, with application-owned retries. Do not acknowledge a notification and then rely on an untracked background task. Keep provider-specific signatures, authenticated lookups and acknowledgement formats inside the integration boundary.
Duplicate and out-of-order notifications
Reliable processing needs database invariants:
- Match provider, merchant account and reference to the stored attempt.
- Check amount and currency in the appropriate transaction/settlement context.
- Deduplicate by a provider event identifier where one exists. For status-only notifications, apply state idempotently; do not suppress every future update using payment reference alone.
- Serialize conflicting writes or use optimistic concurrency and retries.
- Do not let stale pending state overwrite verified paid state.
- Commit the state transition and outbox event together. Fulfillment consumers must tolerate redelivery too.
Refunds and disputes are later business events; model them explicitly rather than treating every payment status as permanently immutable.
Keep the abstraction focused
Initialize and callback are a useful starting point for this hosted-checkout scope. Capture, refunds and recurring mandates deserve explicit capability contracts when needed. An interface with many unsupported methods is a warning that it has grown too broad.
Test invalid authentication, duplicate callbacks, wrong merchant/amount/currency, delayed outcomes, initialization timeouts, callback races, concurrent updates and customers who never return. Complement application tests with provider sandbox integration tests.
A new provider should add an adapter and protocol tests. The application should continue to own its attempts, order rules and fulfillment guarantees.
Official references
- Stripe Checkout fulfillment and webhooks
- Mollie: accepting payments, classic webhooks and new webhook flows
- PAY.nl: handling Exchange calls
- Skrill Quick Checkout integration guide
Provider details checked on 18 October 2026. Follow the documentation for your selected product and API version.