Paymentify 1.5.3

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

Paymentify

A drop-in .NET 10 library that integrates Dodo Payments subscriptions into any ASP.NET Core app. Handles webhooks, local subscription state, built-in pricing/billing pages, and authorization gating — wired up in two method calls.

Namespaces

Symbol Namespace
IPaymentifyDbContext Paymentify.Data
LocalSubscription, LocalCustomer, WebhookEvent, PurchaseInterest Paymentify.Data.Entities
SubscriptionStatus Paymentify.Domain
AddPaymentify, MapPaymentify, AddPaymentifyEntities Paymentify.Extensions
PaymentifyOptions, PaymentifyPlan, PaymentsMode Paymentify.Options
RequireActiveSubscriptionAttribute Paymentify.Auth
IPaymentifyEventHandler<TEvent> and all event records Paymentify.Events
SubscriptionService Paymentify.Services
PricingModel, BillingModel Paymentify.Pages
PaymentifyPricingViewComponent Paymentify.ViewComponents

Quick start

1. Add your DbContext (must implement IPaymentifyDbContext and call AddPaymentifyEntities in OnModelCreating):

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Paymentify.Data;
using Paymentify.Data.Entities;
using Paymentify.Extensions;

public class AppDbContext(DbContextOptions<AppDbContext> options)
    : DbContext(options), IPaymentifyDbContext
{
    // IPaymentifyDbContext — expose these four sets
    public DbSet<LocalSubscription> Subscriptions => Set<LocalSubscription>();
    public DbSet<LocalCustomer>     Customers      => Set<LocalCustomer>();
    public DbSet<WebhookEvent>      WebhookEvents  => Set<WebhookEvent>();
    public DbSet<PurchaseInterest>  PurchaseInterests => Set<PurchaseInterest>();
    EntityEntry IPaymentifyDbContext.Entry(object e) => Entry(e);

    protected override void OnModelCreating(ModelBuilder model)
    {
        // your own entity config...
        model.AddPaymentifyEntities(); // registers all Paymentify tables
    }
}

2. Register in Program.cs:

using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Paymentify.Extensions;
using Paymentify.Options;

// Register your DbContext first (Paymentify reuses it)
builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseSqlite("Data Source=app.db"));

builder.Services.AddRazorPages(); // required for built-in pricing/billing pages

builder.Services.AddPaymentify<AppDbContext>(opts =>
{
    opts.ApiKey        = builder.Configuration["Dodo:ApiKey"]!;
    opts.WebhookSecret = builder.Configuration["Dodo:WebhookSecret"]!;

    opts.UserIdResolver  = ctx => ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);
    opts.EmailResolver   = ctx => ctx.User.FindFirstValue(ClaimTypes.Email);

    opts.Plans = [
        new() { ProductId = "prod_starter", Name = "Starter", PriceDisplay = "$9/mo" },
        new() { ProductId = "prod_pro",     Name = "Pro",     PriceDisplay = "$29/mo", Highlighted = true },
    ];
});

// ...

// Apply EF migrations at startup (host owns migrations)
using (var scope = app.Services.CreateScope())
    await scope.ServiceProvider.GetRequiredService<AppDbContext>().Database.MigrateAsync();

app.UseAuthentication();
app.UseAuthorization();
app.MapPaymentify("/paymentify");

3. Add an EF migration (first time, and after any schema change):

dotnet ef migrations add Init --project YourApp --context AppDbContext

Postgres users: always generate this migration against your own Postgres-configured AppDbContext. Don't copy samples/SampleApp/Migrations/* — those were scaffolded against SQLite, so they hard-code SQLite column types (TEXT, INTEGER) that Npgsql won't accept. Running dotnet ef migrations add against a Postgres provider produces correct native types (text, boolean, timestamp with time zone) automatically.

What you get

Route Description
GET /paymentify/pricing Built-in pricing page (checkout, waitlist, highlights active plan) — requires a signed-in visitor; anonymous visitors are sent to RegisterUrl
GET /paymentify/billing Customer portal / subscription management
POST /paymentify/webhooks Dodo webhook ingestion (always anonymous)
GET /paymentify/admin/interest SuperAdmin: browse captured waitlist interest (requires AdminAccessResolver)
GET /paymentify/admin/webhook-failures SuperAdmin: retry dead-lettered webhooks (requires AdminAccessResolver)
GET /paymentify/admin/user SuperAdmin: per-user detail, manual subscription grants/overrides (requires AdminAccessResolver)

MapPaymentify only mounts the webhook route above. Checkout, portal, plan changes, cancellation, reactivation, and invoices are all handled in-process by the built-in Razor Pages calling SubscriptionService directly — there's no separate JSON API for them. Call SubscriptionService yourself (see below) if you need this outside the built-in pages.

Theming

The built-in pages ship structure only — they inherit your app's font and text color, never paint a page background, and derive borders and muted text from your theme automatically (including dark mode: if your pages are dark, so are Paymentify's). To brand them, set CSS variables on :root (or any ancestor):

:root {
  --pf-accent: #0d6efd;          /* primary buttons, featured plan — usually all you need */
  --pf-accent-contrast: #fff;    /* text on top of the accent */
  --pf-muted: ...;               /* secondary text     (default: derived from text color) */
  --pf-border: ...;              /* hairlines          (default: derived from text color) */
  --pf-surface: ...;             /* card background    (default: transparent) */
  --pf-radius: 0.375rem;         /* corner radius */
  --pf-success: ...; --pf-danger: ...; --pf-warning: ...;   /* status colors */
}

For deeper changes, override styles targeting the .pf-* classes, or replace /_content/Paymentify/paymentify.css entirely by shipping your own copy.

Billing identity: users vs. tenants

UserIdResolver returns the billing identity — an opaque string Paymentify stores as ExternalUserId on the customer record. One subscription is bought per unique value.

Per-user billing (each user pays independently):

opts.UserIdResolver = ctx => ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);

Per-tenant billing (one subscription covers the whole org):

opts.UserIdResolver = ctx => ctx.User.FindFirstValue("tenant_id");
// or resolve from a scoped service:
opts.UserIdResolver = ctx =>
    ctx.RequestServices.GetRequiredService<ITenantService>().GetTenantId(ctx);

All users with the same billing identity share the same subscription. [RequireActiveSubscription] works for all of them automatically.

Gating access

using Paymentify.Auth;

[RequireActiveSubscription]
public class DashboardController : Controller { ... }

Requires UserIdResolver to be configured. Registers the "ActiveSubscription" authorization policy backed by SubscriptionService.IsActiveAsync. When PaymentsMode = Disabled, the policy always passes (so you can test gated pages without a real subscription).

OnHold/Failed subscriptions (payment declined, awaiting Dodo's retry) count as active for DunningGracePeriod after their last status change — see Configuration reference.

[RequireActiveSubscription] is boolean (any active subscription passes) — it doesn't know about plan tiers. To gate a specific feature behind a paid plan (or a particular tier), check GetActivePlanAsync directly:

var plan = await subscriptions.GetActivePlanAsync(userId);
var isPaid = plan?.ProductId is not null;   // null ProductId = free tier

If the billing identity isn't the current user — e.g. a project belongs to an organization, and the organization owner holds the subscription — resolve it first, then look up the plan:

var ownerId = await db.Projects
    .Where(p => p.Id == projectId)
    .Select(p => p.Organization.OwnerId)
    .SingleOrDefaultAsync(ct);

var plan = ownerId is not null ? await subscriptions.GetActivePlanAsync(ownerId) : null;
if (plan?.ProductId is null)
    return Results.Json(
        new { success = false, error = "This feature requires a paid plan.", code = "plan_limit" },
        statusCode: StatusCodes.Status402PaymentRequired);

In an MVC controller, use Forbid() instead of the 402 JSON response. Combine with GetLimits<T>() (above) when the gate is a quota rather than a yes/no feature.

Plan configuration

opts.Plans = [
    new()
    {
        ProductId        = null,             // null = free tier (no Dodo product)
        Name             = "Free",
        PriceDisplay     = "$0/mo",
        Description      = "Perfect for getting started",
        Features         = ["Up to 5 projects", "Basic support"],
        Limits           = new MyLimits(MaxProjects: 5),
    },
    new()
    {
        ProductId        = "prod_pro",
        Name             = "Pro",
        PriceDisplay     = "$19/mo",
        YearlyProductId  = "prod_pro_yearly",
        YearlyPriceDisplay = "$190/yr",
        Description      = "For power users",
        Highlighted      = true,             // visually emphasised on the pricing page
        Features         = ["Unlimited projects", "Priority support"],
        Limits           = new MyLimits(MaxProjects: -1),  // -1 = unlimited
        Available        = true,             // false → shows UnavailableLabel instead of buy button
        UnavailableLabel = "Coming soon",
    },
];
Property Type Description
ProductId string? Dodo product ID. null = free tier
Name string Display name
PriceDisplay string Price label shown on pricing page (e.g. "$9/mo")
Description string? Subtitle shown under the plan name
YearlyProductId string? Dodo product ID for the annual variant
YearlyPriceDisplay string? Price label for the annual variant
Highlighted bool Visually emphasises this plan (e.g. "Most popular")
Features IReadOnlyList<string>? Bullet list of features shown on the pricing page
Limits object? Arbitrary limit object — use any type; retrieve with GetLimits<T>()
Available bool true (default) = purchasable; false = shows UnavailableLabel
UnavailableLabel string? Button label when Available = false (e.g. "Coming soon")

Plan limits (entitlements)

Attach any type to Limits and retrieve it typed via GetLimits<T>():

// Define your limits type
record MyLimits(int MaxProjects);

// Assign on plans
new PaymentifyPlan { ..., Limits = new MyLimits(5) }

// Enforce in your app
var plan = await subscriptionService.GetActivePlanAsync(userId);
var limits = plan?.GetLimits<MyLimits>();
var max = limits?.MaxProjects ?? 0;
if (max != -1 && current >= max)
    return Forbid();

GetActivePlanAsync returns the active paid plan, or the first plan with ProductId == null (free tier) if the user has no paid subscription.

Pre-monetization mode

Launch with pricing visible but checkout disabled. Collect visitor interest. Flip one flag when you're ready.

PaymentsMode Behavior
Live Payments proceed normally against BaseUrl
Test Same as Live — use BaseUrl to point at the Dodo test environment (default)
Disabled Checkout is blocked; waitlist signup is active instead

When Disabled:

  • Paid plan cards show UnavailableLabel (default "Join the waitlist") with an inline email form that records interest — works without JavaScript.
  • The billing page explains that billing isn't enabled yet.
  • Admin pages show a banner reminding you the pricing page is interest-only.
  • Checkout attempts on /paymentify/pricing show an error instead of starting a session.
  • The waitlist form on /paymentify/pricing records purchase intent.
  • [RequireActiveSubscription] always passes (useful for testing gated pages).
  • ApiKey and WebhookSecret are not required.
opts.PaymentsMode = PaymentsMode.Disabled;

opts.Plans = [
    new() { ProductId = null,           Name = "Free",    PriceDisplay = "$0/mo" },
    new() { ProductId = "prod_starter", Name = "Starter", PriceDisplay = "$9/mo",
            Available = false, UnavailableLabel = "Join waitlist" },
];

Recording interest

POST /paymentify/interest
{ "productId": "prod_starter", "email": "user@example.com" }

Returns { "recorded": true }. Anonymous — no login required. Returns HTTP 400 if PaymentsMode != Disabled.

Captured rows are browsable at /paymentify/admin/interest (requires AdminAccessResolver), or read them back like any other table via IPaymentifyDbContext:

var waitlist = await db.PurchaseInterests
    .Where(i => i.ProductId == "prod_starter")
    .OrderByDescending(i => i.CreatedAt)
    .ToListAsync();

Going live

opts.PaymentsMode = PaymentsMode.Live;
opts.BaseUrl      = "https://live.dodopayments.com";

Remove Available = false from any plans you're ready to sell.

Configuration reference

Option Required Default Notes
ApiKey yes* Dodo bearer token. *Not required when PaymentsMode = Disabled
WebhookSecret yes* base64 or raw UTF-8 secret. *Not required when PaymentsMode = Disabled
UserIdResolver no Func<HttpContext, string?> — billing identity (user or tenant ID)
EmailResolver no Func<HttpContext, string?> — pre-fills email on checkout
NameResolver no Func<HttpContext, string?> — pre-fills name when creating a Dodo customer
AdminAccessResolver no Func<HttpContext, bool> — gates the /paymentify/admin/* SuperAdmin pages. null (default) makes them 404
Plans no [] drives the built-in pricing page and GetActivePlanAsync
Layout no Razor layout name (e.g. "_Layout") for built-in pages
RegisterUrl no URL of your registration page — shown as a link on built-in pages
CheckoutSuccessUrl no /paymentify/billing redirect after successful checkout
CheckoutCancelUrl no /paymentify/pricing redirect after cancelled checkout
PricingUrl no /paymentify/pricing must match the MapPaymentify prefix you chose
BillingUrl no /paymentify/billing must match the MapPaymentify prefix you chose
MaxWebhookBodyBytes no 1048576 max request body on the webhook endpoint (1 MB)
BaseUrl no https://test.dodopayments.com override for live: https://live.dodopayments.com
PaymentsMode no Live Live, Test, or Disabled
DunningGracePeriod no TimeSpan.Zero how long an OnHold/Failed subscription still counts as active (IsActiveAsync/GetActivePlanAsync), to cover Dodo's payment-retry window

Event handlers

Implement IPaymentifyEventHandler<TEvent> and register with DI to react to Dodo webhook events:

using Paymentify.Events;

public class MyHandler : IPaymentifyEventHandler<SubscriptionActivatedEvent>
{
    public Task HandleAsync(SubscriptionActivatedEvent e, CancellationToken ct)
    {
        // provision access, send welcome email, etc.
        return Task.CompletedTask;
    }
}

services.AddScoped<IPaymentifyEventHandler<SubscriptionActivatedEvent>, MyHandler>();

Subscription events

Event Fields
SubscriptionActivatedEvent SubscriptionId, CustomerId, ProductId, Quantity, TrialPeriodDays, NextBillingDate, ExternalUserId?
SubscriptionCancelledEvent SubscriptionId, CustomerId, ExternalUserId?
SubscriptionPlanChangedEvent SubscriptionId, CustomerId, NewProductId, ExternalUserId?
SubscriptionRenewedEvent SubscriptionId, CustomerId, NextBillingDate, ExternalUserId?
SubscriptionOnHoldEvent SubscriptionId, CustomerId, ExternalUserId?
SubscriptionFailedEvent SubscriptionId, CustomerId, ExternalUserId?
SubscriptionExpiredEvent SubscriptionId, CustomerId, ExternalUserId?
SubscriptionUpdatedEvent SubscriptionId, CustomerId, Status, ExternalUserId?
Reacting to any plan change

There's no single "the plan changed" event — if you want to invalidate cached, plan-dependent data (entitlements, feature flags, etc.) regardless of which subscription event caused it, implement IPaymentifyEventHandler<TEvent> for all the plan-affecting event types on one class and register it against each. WebhookProcessor resolves handlers per event type, so a class implementing multiple IPaymentifyEventHandler<T> interfaces runs for each of them:

public class PlanChangeCacheInvalidator :
    IPaymentifyEventHandler<SubscriptionActivatedEvent>,
    IPaymentifyEventHandler<SubscriptionPlanChangedEvent>,
    IPaymentifyEventHandler<SubscriptionRenewedEvent>,
    IPaymentifyEventHandler<SubscriptionCancelledEvent>,
    IPaymentifyEventHandler<SubscriptionOnHoldEvent>,
    IPaymentifyEventHandler<SubscriptionFailedEvent>,
    IPaymentifyEventHandler<SubscriptionExpiredEvent>,
    IPaymentifyEventHandler<SubscriptionUpdatedEvent>
{
    public Task HandleAsync(SubscriptionActivatedEvent e, CancellationToken ct) => Invalidate(e.ExternalUserId, ct);
    public Task HandleAsync(SubscriptionPlanChangedEvent e, CancellationToken ct) => Invalidate(e.ExternalUserId, ct);
    public Task HandleAsync(SubscriptionRenewedEvent e, CancellationToken ct) => Invalidate(e.ExternalUserId, ct);
    public Task HandleAsync(SubscriptionCancelledEvent e, CancellationToken ct) => Invalidate(e.ExternalUserId, ct);
    public Task HandleAsync(SubscriptionOnHoldEvent e, CancellationToken ct) => Invalidate(e.ExternalUserId, ct);
    public Task HandleAsync(SubscriptionFailedEvent e, CancellationToken ct) => Invalidate(e.ExternalUserId, ct);
    public Task HandleAsync(SubscriptionExpiredEvent e, CancellationToken ct) => Invalidate(e.ExternalUserId, ct);
    public Task HandleAsync(SubscriptionUpdatedEvent e, CancellationToken ct) => Invalidate(e.ExternalUserId, ct);

    private Task Invalidate(string? externalUserId, CancellationToken ct)
    {
        // e.g. cache.Remove($"plan:{externalUserId}")
        return Task.CompletedTask;
    }
}
services.AddScoped<IPaymentifyEventHandler<SubscriptionActivatedEvent>, PlanChangeCacheInvalidator>();
services.AddScoped<IPaymentifyEventHandler<SubscriptionPlanChangedEvent>, PlanChangeCacheInvalidator>();
services.AddScoped<IPaymentifyEventHandler<SubscriptionRenewedEvent>, PlanChangeCacheInvalidator>();
services.AddScoped<IPaymentifyEventHandler<SubscriptionCancelledEvent>, PlanChangeCacheInvalidator>();
services.AddScoped<IPaymentifyEventHandler<SubscriptionOnHoldEvent>, PlanChangeCacheInvalidator>();
services.AddScoped<IPaymentifyEventHandler<SubscriptionFailedEvent>, PlanChangeCacheInvalidator>();
services.AddScoped<IPaymentifyEventHandler<SubscriptionExpiredEvent>, PlanChangeCacheInvalidator>();
services.AddScoped<IPaymentifyEventHandler<SubscriptionUpdatedEvent>, PlanChangeCacheInvalidator>();

Payment events

Event Fields
PaymentSucceededEvent PaymentId, CustomerId, TotalAmount, Currency, SubscriptionId?
PaymentFailedEvent PaymentId, CustomerId, ErrorCode?, ErrorMessage?
PaymentProcessingEvent PaymentId, CustomerId
PaymentCancelledEvent PaymentId, CustomerId

Refund events

Event Fields
RefundSucceededEvent RefundId, PaymentId, CustomerId, Amount?, IsPartial, Reason?
RefundFailedEvent RefundId, PaymentId, CustomerId

Dispute events

Event Fields
DisputeOpenedEvent DisputeId, PaymentId, CustomerId, Amount, Currency
DisputeExpiredEvent DisputeId, PaymentId, CustomerId
DisputeAcceptedEvent DisputeId, PaymentId, CustomerId
DisputeCancelledEvent DisputeId, PaymentId, CustomerId
DisputeChallengedEvent DisputeId, PaymentId, CustomerId
DisputeWonEvent DisputeId, PaymentId, CustomerId
DisputeLostEvent DisputeId, PaymentId, CustomerId

Dunning events

Event Fields
DunningStartedEvent SubscriptionId, CustomerId, PaymentId?, TriggerState?
DunningRecoveredEvent SubscriptionId, CustomerId, PaymentId?

SubscriptionService

SubscriptionService is registered as scoped and injectable directly into your own services and controllers.

public class MyService(SubscriptionService subscriptions) { ... }
Method Description
GetAsync(subscriptionId) Fetch a LocalSubscription by Dodo subscription ID
GetByUserAsync(externalUserId) All subscriptions for a billing identity
GetByCustomerAsync(customerId) All subscriptions for a Dodo customer ID
IsActiveAsync(externalUserId) true if the user has any Active subscription
GetActivePlanAsync(externalUserId) The active PaymentifyPlan, or the free-tier plan if none
CreateCheckoutAsync(...) Create a Dodo checkout session; returns the redirect URL
CancelAsync(subscriptionId, atNextBillingDate) Cancel a subscription
ReactivateAsync(subscriptionId) Undo a scheduled cancellation
ChangePlanAsync(subscriptionId, newProductId, quantity, proration) Change the plan immediately
PreviewChangePlanAsync(...) Preview proration before changing plan
GetPortalUrlAsync(externalUserId) Create a Dodo customer portal session; returns the URL
GetInvoicesAsync(externalUserId, pageSize, pageNumber) Paginated payment history

Database

Four tables, all queryable directly through IPaymentifyDbContext (or your AppDbContext) and all with unique indexes on their Dodo-issued IDs:

Table Entity Key fields
paymentify_subscriptions LocalSubscription DodoSubscriptionId, DodoCustomerId, DodoProductId, Status (SubscriptionStatus), Quantity, TrialPeriodDays, NextBillingDate, CancelScheduled
paymentify_customers LocalCustomer DodoCustomerId, Email, Name, ExternalUserId
paymentify_webhook_events WebhookEvent DodoEventId, EventType, ProcessedAt, ProcessingFailed, FailureMessage
paymentify_purchase_interests PurchaseInterest ProductId, Email, ExternalUserId, CreatedAt

SubscriptionStatus (Paymentify.Domain) is Active | Cancelled | OnHold | Failed | Expired.

The library ships no migrations. The host app owns them:

dotnet ef migrations add PaymentifyInit --project YourApp --context AppDbContext
dotnet ef database update --project YourApp --context AppDbContext

Apply at startup:

using var scope = app.Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<AppDbContext>().Database.MigrateAsync();

Supports Postgres and SQLite (or any EF provider your DbContext is configured with). Generate the migration against your provider — migrations are provider-specific (column types like TEXT/INTEGER vs text/boolean are baked in at scaffold time), so a SQLite-generated migration will not run cleanly on Postgres. samples/SampleApp/Migrations/* is SQLite-only reference output, not a template to copy for other providers.

Health check

A health check named "paymentify" is automatically registered. It verifies database connectivity. Wire it up with your existing health check endpoint:

app.MapHealthChecks("/health");

Pricing view component

/paymentify/pricing is the authenticated pricing page — checkout forms, waitlist, current-plan state — and redirects anonymous visitors to RegisterUrl. For a public marketing/landing page, embed PaymentifyPricingViewComponent instead: it renders static plan cards (no forms, no user context) with a single CTA per plan linking to RegisterUrl (or PricingUrl if unset):

@await Component.InvokeAsync("PaymentifyPricing")

Overriding built-in pages

Paymentify's pricing and billing pages ship as part of a Razor Class Library. ASP.NET Core gives the host app's pages priority: if you place a file at the same relative path, your version wins and the library's is ignored.

Custom look, same logic

Create Pages/Pricing.cshtml + Pages/Pricing.cshtml.cs in your host app, inheriting from the library's model:

// Pages/Pricing.cshtml.cs
using Paymentify.Pages;
using Paymentify.Options;
using Paymentify.Services;

public class PricingModel : Paymentify.Pages.PricingModel
{
    public PricingModel(SubscriptionService svc, PaymentifyOptions opts, ILogger<PricingModel> logger)
        : base(svc, opts, logger) { }

    // Add extra properties or override handlers as needed
}
@* Pages/Pricing.cshtml *@
@page "/paymentify/pricing"
@model YourApp.Pages.PricingModel

@* Your custom HTML — all base model properties and handlers are available *@

Do the same with Pages/Billing.cshtml / BillingModel for the billing page.

Custom URL

Set PricingUrl / BillingUrl in options and create pages at those paths:

opts.PricingUrl = "/pricing";
opts.BillingUrl = "/account/billing";
opts.CheckoutSuccessUrl = "/account/billing";
opts.CheckoutCancelUrl  = "/pricing";

Then create Pages/Pricing.cshtml at @page "/pricing" and Pages/Account/Billing.cshtml at @page "/account/billing". The library's built-in pages at /paymentify/pricing and /paymentify/billing remain registered but the library never redirects users there.

SuperAdmin pages

Three built-in Razor Pages, gated behind AdminAccessResolver (a Func<HttpContext, bool>, same pattern as UserIdResolvernull by default, so the pages 404 until you configure it):

opts.AdminAccessResolver = ctx => ctx.User.IsInRole("admin");
Route What it does
/paymentify/admin/interest Browse PurchaseInterest rows captured by the pricing page's waitlist flow
/paymentify/admin/webhook-failures List dead-lettered webhooks (ProcessingFailed = true) and retry them against currently-registered handlers — no signature re-check, no Dodo API call
/paymentify/admin/user?externalUserId= Everything Paymentify knows about a user: their customer record, every subscription (not just the active one), recent webhook history. From here you can override a subscription's status directly, grant a manual/comp subscription (no real Dodo transaction, manual_-prefixed ID), or cancel one

Manual subscriptions never call the Dodo API (creation or cancellation) and are invisible to webhook processing, since WebhookProcessor only ever looks up by real Dodo-issued IDs. Every status override, manual grant/cancel, and webhook retry writes a row to AdminAuditLog (who, when, what changed).

Product 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. 
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
1.5.3 125 8/17/2026
1.5.2 99 8/17/2026
1.5.1 101 8/17/2026
1.5.0 96 8/17/2026
1.4.1 108 8/14/2026
1.4.0 102 8/14/2026
1.3.5 131 8/9/2026
1.3.4 111 7/17/2026
1.3.3 142 7/3/2026
1.3.2 111 7/2/2026
1.3.1 116 7/2/2026
1.3.0 107 7/2/2026
1.2.0 125 7/2/2026
1.1.2 118 6/30/2026
1.1.1 113 6/30/2026
1.1.0 112 6/29/2026
1.0.0 138 6/29/2026