Skip to content

Subscriptions & payment providers ​

Postmill's billing layer runs on pluggable payment providers. Organizations subscribe to one of four plans, each with hard limits on channels, posts, team seats, video exports, and storage. The backend enforces these limits at the API level; when a limit is hit the caller receives a 402 Payment Required response with an upsell link to /billing.

A provider is enabled by setting its keys in the environment — there is nothing to configure in the UI. Billing is on as soon as at least one provider is enabled. For self-hosted instances that set no provider keys, billing is bypassed and every organization is treated as the Agency plan.

Want to plug in a provider that isn't shipped (a regional PSP, say)? See the developer guide: Writing a payment provider.

Choosing the default provider ​

PAYMENTS_PROVIDER=<id> names the provider that serves checkout on the web app. You only need it when more than one web provider is enabled:

  • unset + exactly one web provider enabled → that provider;
  • unset + several enabled → the backend logs an error at boot and defaults to Stripe (if enabled) or the first enabled provider;
  • an id whose keys are not set, or an app-store provider, is rejected (logged) and the rules above apply.

An organization is locked to the provider it first subscribed through, even after its subscription lapses — a workspace that once paid with Stripe cannot later buy through the App Store, and vice-versa. To let a workspace switch, an operator clears Organization.paymentProvider and Organization.paymentId for it (and removes any subscription row from the old provider); the next purchase then binds to the new provider. Refused cross-provider activations are logged at warn with that instruction.

On a deployment with only app-store providers configured, workspaces that have never subscribed see "plans are purchased in the mobile app" on /billing rather than web purchase buttons.

The nightly payments-expire-canceled job also warns when Stripe subscriptions passed their scheduled end more than a day ago without Stripe's customer.subscription.deleted webhook — that means the webhook endpoint is misconfigured or deliveries failed; fix the endpoint and redeliver from the Stripe dashboard.

Stripe ​

Set these in your .env file or container environment:

VariablePurpose
STRIPE_PUBLISHABLE_KEYStripe publishable key (enables the provider; used by the frontend billing page).
STRIPE_SECRET_KEYStripe secret key (used server-side for charges, subscriptions, and the customer portal).
STRIPE_SIGNING_KEYStripe webhook signing secret (see Webhook setup).

If STRIPE_PUBLISHABLE_KEY is absent (and no other provider is enabled), the entire billing gate is disabled and every org gets the Agency defaults.

Stripe supports everything the billing UI offers: embedded checkout, the customer portal, proration previews, coupons, add-on packs, invoices and refunds, period-end cancellation with resume, and the 30-day trial with a card check.

PayPal ​

Hosted checkout through the PayPal Subscriptions API: the buyer is redirected to PayPal to approve the subscription and returned to Postmill. Plans and products are created in your PayPal catalog on first use (Postmill PRO MONTHLY, … TRIAL, …), so nothing needs pre-creating.

VariablePurpose
PAYPAL_CLIENT_IDREST app client id (enables the provider; also exposed to the browser).
PAYPAL_CLIENT_SECRETREST app secret.
PAYPAL_WEBHOOK_IDThe id of the webhook you register below — PayPal verifies deliveries against it.
PAYPAL_ENVlive (default) or sandbox.
PAYPAL_BRAND_NAMEName shown on the PayPal approval page (default Postmill).

Setup: developer.paypal.com → Apps & Credentials → create a REST app (a sandbox app for testing, a live app for production) → copy the client id and secret → Webhooks → add https://<your-domain>/payments/webhooks/paypal subscribed to BILLING.SUBSCRIPTION.* and PAYMENT.SALE.COMPLETED → copy the Webhook ID. For sandbox testing create a business and a personal sandbox account under Sandbox → Accounts and set PAYPAL_ENV=sandbox.

What differs from Stripe: refund amounts are not reported per transaction by PayPal's subscription API, so the invoice list marks refunded transactions without an amount. PayPal has no customer portal (the buyer manages the agreement at paypal.com), no proration preview (PayPal prorates plan revisions itself), no coupons, and no add-on packs. Cancelling is immediate at PayPal, so Postmill keeps the subscription active until the paid-through date and a daily job (payments-expire-canceled) downgrades it after that; a cancelled PayPal subscription cannot be resumed — the customer subscribes again. PayPal webhooks can lag the approval redirect by a minute or two; the post-checkout page reconciles from the subscription_id PayPal appends to the return URL, so the wait is short.

Apple App Store (mobile app) ​

For the Postmill mobile app. Purchases happen in the app through StoreKit; the server verifies the signed transaction the app hands over (POST /billing/native/verify) and consumes App Store Server Notifications. Nothing is sold through the web UI for this provider.

VariablePurpose
APPLE_IAP_BUNDLE_IDThe app's bundle id (enables the provider).
APPLE_IAP_ISSUER_ID, APPLE_IAP_KEY_ID, APPLE_IAP_PRIVATE_KEYAn In-App Purchase key from App Store Connect → Users and Access → Integrations → In-App Purchase (base64 of the .p8). This is a different key from Sign in with Apple's.
APPLE_IAP_APP_APPLE_IDThe numeric Apple ID of the app (App Store Connect → App Information). Required — Apple's verifier refuses production payloads without it.
APPLE_IAP_ENVProduction (default) or Sandbox.
APPLE_IAP_ALLOW_SANDBOXtrue to also accept sandbox/TestFlight purchases on a production backend. Sandbox purchases are free, so this grants real paid tiers to anyone with a TestFlight build — pair it with APPLE_IAP_SANDBOX_ORG_IDS (comma-separated organization ids allowed to activate sandbox purchases) and turn it off after launch testing.
PAYMENTS_APPLE_PRODUCT_PREFIXProduct-id prefix (default postmill).
APPLE_IAP_ROOT_CA_BASE64Override for Apple's root certificates (comma-separated base64 DER); normally unset.

Products: create auto-renewable subscriptions in one subscription group with ids <prefix>.<tier>.monthly / <prefix>.<tier>.yearly (e.g. postmill.pro.monthly); the free trial is the group's introductory offer. Notifications: App Store Connect → your app → App Information → App Store Server Notifications → version 2 → URL https://<your-domain>/payments/webhooks/apple for Production and Sandbox. The app must set the purchase's appAccountToken to the Postmill organization id so the server can bind it.

Google Play (mobile app) ​

Same shape as Apple: the app buys through Play Billing and hands { purchaseToken, productId } to POST /billing/native/verify; Real-time Developer Notifications arrive through Pub/Sub.

VariablePurpose
GOOGLE_PLAY_PACKAGE_NAMEThe app's package name (enables the provider).
GOOGLE_PLAY_SERVICE_ACCOUNT_JSONBase64 of a service-account key JSON with access to the Play Developer API.
GOOGLE_PLAY_RTDN_SERVICE_ACCOUNT_EMAILThe service account Pub/Sub uses to sign push requests; the webhook refuses requests when unset.
GOOGLE_PLAY_RTDN_AUDIENCEAudience of the push OIDC token (default: the webhook URL).
PAYMENTS_GOOGLE_PRODUCT_PREFIXProduct-id prefix (default postmill).

Setup: Google Cloud → Pub/Sub → create a topic → grant google-play-developer-notifications@system.gserviceaccount.com the Pub/Sub Publisher role → add a push subscription to https://<your-domain>/payments/webhooks/google with Enable authentication on (choose a service account; its email goes into GOOGLE_PLAY_RTDN_SERVICE_ACCOUNT_EMAIL, the audience into GOOGLE_PLAY_RTDN_AUDIENCE). Play Console → Monetize → Monetization setup → paste the topic name and send a test notification. Invite the API service account under Users and permissions with View financial data and Manage orders and subscriptions. Products: subscription ids <prefix>.<tier>.monthly / .yearly, or one product per tier with monthly/yearly base plans. The app must set obfuscatedExternalAccountId to the Postmill organization id at purchase time. Postmill acknowledges every verified purchase (Play refunds unacknowledged ones after three days). Purchases that do not carry the organization id are rejected.

Plans ​

Plans are defined in pricing.ts and created dynamically in Stripe as products/prices on first use. You do not need to pre-create Stripe price IDs.

PlanMonthlyYearlyChannelsPosts / monthTeam seatsBrand kitsCampaignsAPIMCPWebhooksCompetitorsAnalytics retentionVideo exportsStorage
Starter$9$90310010NoNoNo11180 days151 GB
Pro$29$290101,000,00032YesYesYes55548 days605 GB
Team$99$990301,000,0001010YesYesYes2020548 days20020 GB
Agency$249$2,4901001,000,000251,000,000YesYesYes1,000,00050548 days600100 GB

All prices are in USD. Yearly billing is roughly two months free compared to monthly.

Trials ​

New organizations start with allowTrial: true. When a user subscribes to any plan, the checkout session is created with trial_period_days: 30. The trial flag is cleared once the subscription is persisted, so each org can trial only once. Operators can force a trial to end immediately via POST /billing/finish-trial.

Metered limits and enforcement ​

The PermissionsService evaluates every billed action against the org's effective limits: the base plan limits plus purchased add-ons (the extra* columns on Subscription) plus any manual limit overrides. The same merged value feeds the storage upload quota, the channel-enable gate, and the dashboard usage read.

DimensionCounted asReset behavior
ChannelsEnabled integrations (not refresh-needed)Hard cap; excess channels are disabled on downgrade.
Posts / monthPosts created since the subscription's monthly anniversaryBilling-month window based on subscription.createdAt.
Team seatsEnabled org membersDisabled members do not count.
Brand kitsRows in the AIBrandProfile table—
WebhooksRows in the Webhooks table—
CompetitorsRows in WatchedAccount—
Video exportsRows in Credits with type = 'video_export'Resets at the start of each billing month.
StorageBytes used in the File tableHard cap (over-cap writes throw 402); BYO storage bypasses it entirely.

A POST or PATCH that would exceed a limit throws SubscriptionException → HTTP 402 with a message naming the specific limit and a url field pointing to /billing.

Add-ons ​

Every capped plan dimension can be expanded without changing plans. Eight add-on types exist; each pack adds a fixed amount and bills monthly alongside the base subscription. Pack sizes and prices are env-overridable per type:

Add-onDefault packDefault pricePack-size envPrice env
Extra storage25 GB$19 / pack / monthADDON_STORAGE_GB_PER_PACKADDON_STORAGE_PRICE_CENTS
Extra video exports50 exports$19 / pack / monthADDON_VIDEO_EXPORTS_PER_PACKADDON_VIDEO_EXPORTS_PRICE_CENTS
Extra channels5 channels$19 / pack / monthADDON_CHANNELS_PER_PACKADDON_CHANNELS_PRICE_CENTS
Extra team seats5 seats$15 / pack / monthADDON_TEAM_SEATS_PER_PACKADDON_TEAM_SEATS_PRICE_CENTS
Extra posts500 posts / month$9 / pack / monthADDON_POSTS_PER_PACKADDON_POSTS_PRICE_CENTS
Extra brand kits5 kits$9 / pack / monthADDON_BRAND_KITS_PER_PACKADDON_BRAND_KITS_PRICE_CENTS
Extra webhooks10 webhooks$9 / pack / monthADDON_WEBHOOKS_PER_PACKADDON_WEBHOOKS_PRICE_CENTS
Extra competitors10 competitors$9 / pack / monthADDON_COMPETITORS_PER_PACKADDON_COMPETITORS_PRICE_CENTS

Add-ons are Stripe subscriptions marked with metadata.addon. Their quantities are synced back to the Subscription table (the matching extra* column) on every relevant Stripe webhook so effective limits update immediately.

Operational notes:

  • Frontend mirrors: the frontend reads pack sizes and prices from NEXT_PUBLIC_ADDON_* variables baked in at build time. If you change a backend ADDON_* value, rebuild the frontend with matching NEXT_PUBLIC_ADDON_* values or the UI shows stale pack sizes/prices. See Configuration.
  • Price grandfathering: changing an ADDON_*_PRICE_CENTS variable creates a new Stripe Price used for new purchases only. Existing add-on subscriptions keep billing the old price; migrating them to the new price is a manual Stripe operation.
  • Downgrades: when a plan downgrade prunes excess channels/team seats, it prunes to the org's effective limits (new plan + surviving add-on packs + overrides) — add-ons survive a downgrade.
  • Lifetime orgs: organizations on a lifetime code cannot purchase add-ons (the UI hides the section and the backend rejects the purchase) — they have no base Stripe subscription for add-on items to ride on.

Manual overrides ​

Super-admins can override any numeric limit for a specific org, replacing base + add-ons for that dimension entirely. This is a backend-only surface — there is no UI for it in this repo; it exists for the separate administration app.

PATCH /admin/orgs/:orgId/limit-overrides

Body: { "overrides": { "<key>": <number|null> } } where key is one of channel, team_members, posts_per_month, brand_kits, webhooks, competitors, storage_gb, video_exports. A number sets the override, null clears it, and an absent key is left untouched. analytics_retention_days is deliberately not overridable (a data-lifecycle decision, not a purchasable quota) and is rejected like any unknown key.

Overrides are stored on Subscription.limitOverrides (JSON) and win last in the effective-limits merge. The endpoint requires super-admin authentication: the admin app sends the super-admin user's JWT in the custom auth header (auth: <jwt>) — CSRF is skipped for header auth, and there is no API-key path.

Self-hosted default ​

If no payment provider is configured (no STRIPE_PUBLISHABLE_KEY, PAYPAL_CLIENT_ID, APPLE_IAP_BUNDLE_ID or GOOGLE_PLAY_PACKAGE_NAME):

  • All billing checks short-circuit to "allowed."
  • Every organization is treated as AGENCY.
  • The /billing page shows empty packages and does not offer checkout.

This is controlled by SELF_HOST_PLAN = 'AGENCY' in the pricing module.

Webhook setup ​

Create a Stripe webhook endpoint that points to:

POST https://<your-domain>/payments/webhooks/stripe

Upgrading from a release before the payments domain

POST https://<your-domain>/stripe still works as a deprecated alias, so an existing dashboard webhook keeps delivering. Re-point it to /payments/webhooks/stripe at your convenience — the alias will be removed in a later release.

Subscribe to these events:

  • customer.subscription.created
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.payment_succeeded
  • invoice.payment_failed

Copy the webhook signing secret into STRIPE_SIGNING_KEY. The controller rejects events whose metadata.service !== 'postmill' (except for the two invoice events, which are inspected per subscription). Events are recorded in the payment-event ledger (the StripeEvent table, shared by every provider) for idempotency; redeliveries of the same event.id are ignored.

Subscription lifecycle ​

Creation and updates ​

customer.subscription.created / .updated read metadata.billing, metadata.period, and metadata.uniqueId, validate the card with a $1 manual-capture authorization (during a trial), and upsert the org's Subscription row. The totalChannels column is set to the plan's channel limit.

Payment failure and dunning ​

invoice.payment_failed does not immediately downgrade the org. Instead it enters a 7-day grace period (GRACE_PERIOD_DAYS = 7), records gracePeriodEnd, and sends a budget notification to the org with a link to /billing. Channels and features remain usable during the grace window.

Terminal cancellation ​

customer.subscription.deleted downgrades the org to STARTER and prunes excess channels/team members. The Subscription row is hard-deleted (subscription.repository.ts:66-72, via deleteMany); the deletedAt column exists but is not used for cancellation.

Plan changes ​

  • Upgrades apply immediately via a new checkout session or a Stripe subscription update with proration_behavior: 'always_invoice'.
  • Downgrades set pendingTier on the subscription, update the Stripe price so the next invoice uses the lower amount, and apply the new limits at the next billing period (triggered by invoice.payment_succeeded).

Lifetime codes ​

Operators can mint signed lifetime codes. POST /billing/lifetime accepts a JWT-signed code (produced out-of-band), decrypts it with AuthService.fixedDecryption, and applies the AGENCY plan permanently (isLifetime: true). A code can only be used once; the plaintext is recorded in UsedCodes to prevent reuse.

This path is intended for special deals, migration credits, or operator-granted exceptions.

Charges, refunds, and cancellation ​

  • GET /billing/charges lists succeeded charges and links to Stripe receipts/PDFs.
  • POST /billing/refund-charges refunds specific charge IDs.
  • POST /billing/cancel schedules cancellation at period end and emails the operator-defined billing address with the user's feedback.
  • POST /billing/cancel-subscription cancels immediately.
  • GET /billing/portal returns the provider's management link (Stripe Customer Portal; the store's subscription page for app-store providers). Providers without one answer 400 PAYMENTS_UNSUPPORTED and the button is hidden.

Most billing-management routes require the billing:manage RBAC permission, but not all — GET /billing/portal and POST /billing/finish-trial are org-scoped without the billing:manage decorator (billing.controller.ts:70,123; the @RequirePermission('billing','manage') gate begins at line 134).

Verified against v1.3.0 (2026-10-21)

The AI-native social media management platform — postmill.ai