Paymentify 1.5.3
dotnet add package Paymentify --version 1.5.3
NuGet\Install-Package Paymentify -Version 1.5.3
<PackageReference Include="Paymentify" Version="1.5.3" />
<PackageVersion Include="Paymentify" Version="1.5.3" />
<PackageReference Include="Paymentify" />
paket add Paymentify --version 1.5.3
#r "nuget: Paymentify, 1.5.3"
#:package Paymentify@1.5.3
#addin nuget:?package=Paymentify&version=1.5.3
#tool nuget:?package=Paymentify&version=1.5.3
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 copysamples/SampleApp/Migrations/*— those were scaffolded against SQLite, so they hard-code SQLite column types (TEXT,INTEGER) that Npgsql won't accept. Runningdotnet ef migrations addagainst 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/pricingshow an error instead of starting a session. - The waitlist form on
/paymentify/pricingrecords purchase intent. [RequireActiveSubscription]always passes (useful for testing gated pages).ApiKeyandWebhookSecretare 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 UserIdResolver — null 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 | 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
- DodoPayments.Client (>= 6.24.0)
- Microsoft.EntityFrameworkCore (>= 10.0.0)
- Microsoft.EntityFrameworkCore.Sqlite (>= 10.0.0)
- Npgsql.EntityFrameworkCore.PostgreSQL (>= 10.0.0)
- SQLitePCLRaw.lib.e_sqlite3 (>= 3.50.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.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 |