Skip to content

v0.1.0

Release Date: 2026-10-28

The first release: the billing runtime for a product whose pricing and packaging is described by a pricing spec. The spec says what is offered; payforge does what that needs at the payment provider. It computes the products and prices the provider should hold, starts checkout and the customer portal, verifies webhooks, and turns a paid subscription into an entitlement through ports the host supplies. Stripe is the provider today; the core knows nothing about it.

Highlights

  • The spec is the source. payforge reads plans, prices, one-time products and the marketplace fee from the pricing spec and keeps nothing of its own beside it. ProviderPlanFor computes what a provider account should hold, found again by stable lookup keys and tagged with payforge_app so several products can share one account.
  • Safe to start, safe with no provider. CheckStartup stops a host whose billing is half configured: a key without a webhook secret, a secret without a key, a marketplace without its second secret, or nothing to sell. Errors name what is missing and never contain a secret. With no key, Disabled puts every account on the spec's default plan.
  • A Stripe adapter. Customers keyed to the host's subject, plan and one-time checkout, customer portal sessions, Connect Express sellers with onboarding links and destination-charge sales with a platform fee, and catalog sync with a dry run. See the catalog, checkout and marketplaces.
  • Verified webhooks. Signatures are checked against several secrets, so a platform and a Connect endpoint can share a handler, and events whose mode does not match Webhook.Mode are ignored. What is left is reduced to a small normalized event. See webhooks.
  • Order does not matter, with a provider and a locker. The lifecycle is idempotent by event. With Lifecycle.Provider set it decides an account's entitlement from what the provider holds now, not from the order webhooks arrived in; without it the event alone decides, and a late event can leave a stale entitlement. With Lifecycle.Locker set it serializes work per account; without it two events handled at once can apply the older state last. Refunds, seller account changes and disputes reach sinks the host supplies. A failed delayed payment (EventCheckoutFailed) is ignored by the lifecycle: the host handles it, revoking a one-time purchase it granted on completion, or grants only once Checkout.Paid is true. See the lifecycle.
  • Check your own ports. In-memory stores, sinks and a locker for tests, and conformance suites in payforgetest to run against your own.

What is in it

Part Contents
payforge The provider-neutral core: Billing, Connect, CheckStartup, Disabled, ProviderPlanFor, the normalized event, Lifecycle, the ports and in-memory versions of them
payforge/stripe The Stripe adapter: Client (customers, checkout, portal, Connect, sync, subscription reads), Webhook, KeyMode
payforge/payforgetest Conformance suites for stores, entitlement sinks, sale sinks and seller sinks
examples/ A catalog sync, a pricing page, a webhook endpoint and limit checks, each run by a test without a Stripe account
docs/ A user guide: the catalog, checkout and the portal, webhooks, the lifecycle, marketplaces, known limitations
if err := payforge.CheckStartup(spec, payforge.ProviderConfig{
    SecretKey: key, WebhookSecrets: []string{endpointSecret},
}); err != nil {
    return err
}

// With a key; without one, use payforge.Disabled(spec) instead.
billing, err := payforgestripe.New(spec, payforgestripe.Config{
    SecretKey:       key,
    AllowedURLHosts: []string{"app.example.com"},
})
if err != nil {
    return err
}
report, err := billing.Sync(ctx, true) // dry run

hook := payforgestripe.Webhook{Mode: billing.Mode(), Secrets: secrets}
ev, err := hook.Read(r)
if err != nil {
    return err // answer 4xx or 5xx: see examples/webhook
}
res, err := life.Handle(ctx, ev) // applied, duplicate, ignored or skipped

Rules the runtime enforces

  • A subscription grants while it is active, trialing or past_due, so a plan is kept while a payment is retried. A price the spec does not have never grants.
  • Sync never deletes and never edits an amount: a changed amount creates a new price, moves the lookup key to it and leaves the old price for its subscribers.
  • A subject that already has a granting subscription cannot start another plan checkout, and a new plan checkout expires the subject's other open ones.
  • Idempotency keys cover every parameter Stripe compares, so a changed request is never refused or answered with an earlier response.
  • Events from a seller's own Stripe account never change entitlements, and a sale refuses a seller this app did not create or that cannot take charges.

Known limitations

The full list is in Known limitations. The ones to know before adopting this release:

  • Not yet run against real Stripe. Every test uses a stand-in for the Stripe API or hand-written payloads. An opt-in live test against Stripe's test mode exists (stripe/live_test.go) but has not been run yet; see running the live test for the behavior it is meant to confirm.
  • Keyless legacy prices only through LegacyPrices. A price without a lookup key grants nothing unless the host lists its ID, with the plan it is for, in Lifecycle.LegacyPrices. Metadata never names a plan. See the catalog.
  • A redirect host allowlist is required. Checkout success and cancel URLs, the portal return URL and onboarding links must be https and name a host in stripe.Config.AllowedURLHosts. With a test-mode key, localhost, 127.0.0.1 and ::1 are also accepted, over http too, for local development; with a live key they are refused, and a live client whose list names one is refused when it is made. A client used only to sync the catalog may leave it empty. See checkout.

Installation

go get github.com/plexusone/payforge@v0.1.0

Requires Go 1.26. It depends on systemspec-pricingpackaging v0.1.0 and stripe-go v87.

Upgrade notes

None: this is the first release.