How to Extend Subscrio
Use hooks to add behavior around customer and subscription mutations without forking Subscrio. This page covers inline handlers, the first-party audit-log and payments packages, and how to package your own extension.
1. What You Can Extend
You can subscribe to before/after pairs for:
- Customer lifecycle:
customer.created.*,customer.updated.*,customer.archived.*,customer.unarchived.*,customer.deleted.* - Subscription lifecycle: create/update/archive/unarchive/delete, feature override changes, temporary override clears
- Inbound Stripe:
stripe.received.before(verified event, before Subscrio processes it) andstripe.received.after(after processing completes)
*.before runs before persistence and may mutate new or abort by throwing. *.after runs after a successful write; throwing does not roll back the row. source tells you whether the write came from the public API (api), Stripe sync (stripe), or an internal job such as expired transitions (system).
2. Inline Extension
Register handlers on a Subscrio instance for app-local behavior.
import { Subscrio, HookEvents } from 'core.typescript';
const subscrio = new Subscrio({
database: { connectionString: process.env.DATABASE_URL! },
});
subscrio.hooks.on(HookEvents.CustomerCreatedBefore, async ({ new: customer, source }) => {
console.log('customer creating', customer?.key, source);
});
subscrio.hooks.on(HookEvents.CustomerCreatedAfter, async ({ entityId, new: customer }) => {
console.log('customer created', entityId, customer?.key);
});
using Subscrio.Core;
using Subscrio.Core.Application.Hooks;
var subscrio = new Subscrio(config);
subscrio.Hooks.OnCustomerCreatedBefore(async (evt, ct) =>
{
Console.WriteLine($"customer creating {evt.New?.Key} {evt.Source}");
});
subscrio.Hooks.OnCustomerCreatedAfter(async (evt, ct) =>
{
Console.WriteLine($"customer created {evt.EntityId} {evt.New?.Key}");
});
Throw from a before handler to abort the mutation. Prefer fast, reliable handlers; do heavy work asynchronously only if you accept eventual consistency.
3. First-Party Audit Log Extension
Subscrio ships installable audit packages that register *.after hooks and store rows in Postgres (subscrio.transaction_logs). Schema is created only when you initialize the extension.
| Language | Package | Location |
|---|---|---|
| TypeScript | subscrio-audit-log | extensions/audit-log.typescript |
| .NET | Subscrio.AuditLog | extensions/audit-log.dotnet |
import { Subscrio } from 'subscrio';
import { createAuditLog } from 'subscrio-audit-log';
const subscrio = new Subscrio({ database: { connectionString } });
await subscrio.installSchema();
const audit = createAuditLog(subscrio, { database: { connectionString } });
await audit.installSchema();
const { data, total } = await audit.list({ customerKey: 'acme', limit: 50 });
await audit.dispose();
See each package README for schema columns, filters, nullable FKs, and after-hook failure semantics.
3.1 First-Party Payments Extension
Subscrio ships installable payments packages that register stripe.received.after and store a row in Postgres (subscrio.payments) for each invoice.payment_succeeded event. Schema is created only when you initialize the extension.
| Language | Package | Location |
|---|---|---|
| TypeScript | subscrio-payments | extensions/payments.typescript |
| .NET | Subscrio.Payments | extensions/payments.dotnet |
import { Subscrio } from 'subscrio';
import { createPaymentTracker } from 'subscrio-payments';
const subscrio = new Subscrio({ database: { connectionString } });
await subscrio.installSchema();
const payments = createPaymentTracker(subscrio, { database: { connectionString } });
await payments.installSchema();
const { data, total } = await payments.list({ customerId: 1, limit: 50 });
await payments.dispose();
See each package README for columns (amount_paid, billing-cycle duration, Stripe invoice id), idempotency, and after-hook failure semantics.
4. Distributable Extension Pattern
Ship your own reusable package that only registers hooks. Do not replace Subscrio services or fork the library. For Postgres audit storage, prefer the first-party packages in section 3.
Package Setup
- Create an npm package (e.g.
@acme/subscrio-my-extension). - Peer-depend on
subscrio. - Export a register function that calls
subscrio.hooks.on(...).
- Create a NuGet class library (e.g.
Acme.Subscrio.MyExtension). - Depend on
Subscrio.Core. - Export an extension method that calls
subscrio.Hooks.On*(...).
Register Function
import type { Subscrio } from 'subscrio';
import { HookEvents } from 'subscrio';
export interface AuditLogOptions {
sink: AuditLogSink;
}
export function registerAuditLog(subscrio: Subscrio, options: AuditLogOptions): () => void {
const unsubs = [
subscrio.hooks.on(HookEvents.CustomerCreatedAfter, (e) => options.sink.write(e)),
subscrio.hooks.on(HookEvents.CustomerUpdatedAfter, (e) => options.sink.write(e)),
subscrio.hooks.on(HookEvents.CustomerArchivedAfter, (e) => options.sink.write(e)),
subscrio.hooks.on(HookEvents.CustomerUnarchivedAfter, (e) => options.sink.write(e)),
subscrio.hooks.on(HookEvents.CustomerDeletedAfter, (e) => options.sink.write(e)),
subscrio.hooks.on(HookEvents.SubscriptionCreatedAfter, (e) => options.sink.write(e)),
subscrio.hooks.on(HookEvents.SubscriptionUpdatedAfter, (e) => options.sink.write(e)),
subscrio.hooks.on(HookEvents.SubscriptionArchivedAfter, (e) => options.sink.write(e)),
subscrio.hooks.on(HookEvents.SubscriptionUnarchivedAfter, (e) => options.sink.write(e)),
subscrio.hooks.on(HookEvents.SubscriptionDeletedAfter, (e) => options.sink.write(e)),
subscrio.hooks.on(HookEvents.SubscriptionFeatureOverrideAddedAfter, (e) => options.sink.write(e)),
subscrio.hooks.on(HookEvents.SubscriptionFeatureOverrideRemovedAfter, (e) => options.sink.write(e)),
subscrio.hooks.on(HookEvents.SubscriptionTemporaryOverridesClearedAfter, (e) => options.sink.write(e)),
subscrio.hooks.on(HookEvents.StripeReceivedAfter, (e) => options.sink.writeStripe(e)),
];
return () => unsubs.forEach((off) => off());
}
using Subscrio.Core;
using Subscrio.Core.Application.Hooks;
public static class AuditLogExtensions
{
public static IDisposable UseCustomAuditLog(this Subscrio subscrio, AuditLogOptions options)
{
var offs = new List<Action>
{
subscrio.Hooks.OnCustomerCreatedAfter((e, ct) => options.Sink.WriteAsync(e, ct)),
subscrio.Hooks.OnCustomerUpdatedAfter((e, ct) => options.Sink.WriteAsync(e, ct)),
subscrio.Hooks.OnCustomerArchivedAfter((e, ct) => options.Sink.WriteAsync(e, ct)),
subscrio.Hooks.OnCustomerUnarchivedAfter((e, ct) => options.Sink.WriteAsync(e, ct)),
subscrio.Hooks.OnCustomerDeletedAfter((e, ct) => options.Sink.WriteAsync(e, ct)),
subscrio.Hooks.OnSubscriptionCreatedAfter((e, ct) => options.Sink.WriteAsync(e, ct)),
subscrio.Hooks.OnSubscriptionUpdatedAfter((e, ct) => options.Sink.WriteAsync(e, ct)),
subscrio.Hooks.OnSubscriptionArchivedAfter((e, ct) => options.Sink.WriteAsync(e, ct)),
subscrio.Hooks.OnSubscriptionUnarchivedAfter((e, ct) => options.Sink.WriteAsync(e, ct)),
subscrio.Hooks.OnSubscriptionDeletedAfter((e, ct) => options.Sink.WriteAsync(e, ct)),
subscrio.Hooks.OnSubscriptionFeatureOverrideAddedAfter((e, ct) => options.Sink.WriteAsync(e, ct)),
subscrio.Hooks.OnSubscriptionFeatureOverrideRemovedAfter((e, ct) => options.Sink.WriteAsync(e, ct)),
subscrio.Hooks.OnSubscriptionTemporaryOverridesClearedAfter((e, ct) => options.Sink.WriteAsync(e, ct)),
subscrio.Hooks.OnStripeReceivedAfter((e, ct) => options.Sink.WriteStripeAsync(e, ct)),
};
return new DelegateDisposable(() => offs.ForEach(off => off()));
}
}
Consumer Install and Usage
Conventions
- Register hooks only; do not replace repositories or services.
- Accept options for storage/sink configuration.
- Prefer
*.afterfor audit so rows reflect committed state (includingentityId). - If you register
*.beforeand fail-fast, document that a sink outage blocks mutations. - If the extension owns storage, manage schema like core (
installSchema/verifySchema/migrate) and only when the consumer initializes the extension.
5. Custom Sink Example
Goal: append one record per committed mutation (and one per processed Stripe event) with type, source, occurredAt, entityId when present, and full old / new JSON. For the first-party Postgres implementation, use section 3.
Sink Interface
export interface AuditLogRecord {
type: string;
source?: string;
occurredAt: string;
entityId?: number | null;
old: unknown | null;
new: unknown | null;
stripeEvent?: unknown;
}
export interface AuditLogSink {
write(event: {
type: string;
source: string;
occurredAt: string;
entityId: number | null;
old: unknown | null;
new: unknown | null;
}): Promise<void>;
writeStripe(event: {
type: string;
occurredAt: string;
data: unknown;
}): Promise<void>;
}
public sealed record AuditLogRecord(
string Type,
string? Source,
string OccurredAt,
long? EntityId,
object? Old,
object? New,
object? StripeEvent = null
);
public interface IAuditLogSink
{
Task WriteAsync(CustomerMutationHookEvent evt, CancellationToken cancellationToken = default);
Task WriteAsync(SubscriptionMutationHookEvent evt, CancellationToken cancellationToken = default);
Task WriteStripeAsync(StripeReceivedHookEvent evt, CancellationToken cancellationToken = default);
}
In-Memory Sink (Tests / Demos)
export class MemoryAuditSink implements AuditLogSink {
readonly records: AuditLogRecord[] = [];
async write(event) {
this.records.push({ ...event });
}
async writeStripe(event) {
this.records.push({
type: event.type,
occurredAt: event.occurredAt,
old: null,
new: null,
stripeEvent: event.data,
});
}
}
public sealed class MemoryAuditSink : IAuditLogSink
{
public List<AuditLogRecord> Records { get; } = new();
public Task WriteAsync(CustomerMutationHookEvent evt, CancellationToken cancellationToken = default)
{
Records.Add(new AuditLogRecord(evt.Type, evt.Source, evt.OccurredAt, evt.EntityId, evt.Old, evt.New));
return Task.CompletedTask;
}
public Task WriteAsync(SubscriptionMutationHookEvent evt, CancellationToken cancellationToken = default)
{
Records.Add(new AuditLogRecord(evt.Type, evt.Source, evt.OccurredAt, evt.EntityId, evt.Old, evt.New));
return Task.CompletedTask;
}
public Task WriteStripeAsync(StripeReceivedHookEvent evt, CancellationToken cancellationToken = default)
{
Records.Add(new AuditLogRecord(evt.Type, null, evt.OccurredAt, null, null, null, evt.Data));
return Task.CompletedTask;
}
}
Inline App Usage
Packaged Usage
Phase Trade-Offs
*.after(recommended for audit): Record reflects committed data and includesentityId. If the after-handler throws, the row remains; the API call still fails.*.before: Useful for validation and enrichment. An audit row can be written even if the later database write fails. Compensating strategies:- Use the same DB transaction only if your sink shares the Subscrio connection (advanced; not provided by Subscrio).
- Treat audit as best-effort and reconcile from
old/newplus application logs. - Prefer fail-fast handlers so a sink outage blocks mutations until audit is healthy.
Related
- Hooks API reference
- TypeScript audit package:
extensions/audit-log.typescript - .NET audit package:
extensions/audit-log.dotnet - TypeScript payments package:
extensions/payments.typescript - .NET payments package:
extensions/payments.dotnet - Customers
- Subscriptions
- Stripe Integration