Create your own
Lesson illustration

Idempotent Renewal Event Processing

Good to continue from cancellation. You now distinguish a subscription that is merely non-renewing from one whose access has actually ended. That distinction makes the next lifecycle event especially important: a successful renewal extends paid access into a new billing period.

Providers deliver webhooks with at-least-once semantics. A successful renewal may arrive twice because an acknowledgement was lost, because your service timed out after committing its database transaction, or because the provider replays an event. The result must be the same as if it arrived once: one billing-period record, one advance of currentPeriodEnd, and one audit transition.

In this lesson, you will implement that property for an already active subscription. You will deliberately keep raw signature verification, inbox workers, and full out-of-order-event recovery for Module 4. Here, the focus is the transactional domain operation that makes a confirmed renewal safe to replay.


What “exactly once” means in a distributed billing system

You cannot force a provider to deliver a webhook only once. Nor can your server guarantee that its HTTP acknowledgement reaches the provider after your database transaction commits.

What you can guarantee is exactly-once observable local processing:

  • The successful recurring charge creates one local billing-period record.
  • The subscription’s current period advances once.
  • Renewal-specific audit records and local outbox intents are written once.
  • A replay returns a harmless duplicate result and does not extend access again.

A useful distinction is between two identifiers:

IdentifierWhat it identifiesWhy it is insufficient on its own
providerEventIdOne delivery event emitted by the providerDifferent provider events can describe the same renewal.
providerRenewalKeyThe business fact that one recurring period was successfully paidThis is the key that prevents the billing period from advancing twice.

For most providers, providerRenewalKey should be an immutable invoice, charge, or recurring-cycle ID. It must be scoped by provider because identifiers are not meaningful across providers.

Do not deduplicate using only the new period-end timestamp. Distinct invoices can sometimes share dates, and timestamps can have precision or correction issues. Do not deduplicate from subscription status either: applying active twice appears harmless, but advancing dates twice is not.

The operation should behave like inserting into a set: the first application adds a renewal fact; later attempts find that same fact already present and leave state unchanged.

Fix Duplicate Messages with the Idempotent Consumer Pattern

Watch “Fix Duplicate Messages with the Idempotent Consumer Pattern” by Milan Jovanović for the core database pattern behind this lesson: a durable processed-message record combined with an atomic transaction.

Begin with the delivery problem for the difference between duplicate delivery and exactly-once processing. Then watch the consumer record, which explains why the message ID and consumer name need a database uniqueness guarantee. Finally, watch the transaction pattern; focus on why a preliminary existence check is not enough under concurrent deliveries, and why the database constraint is the final protection.

The video uses a generic message-consumer table. For renewals, your subscription_billing_periods table is stronger: it records both the idempotency key and the business fact you need for billing history.


Use two layers of idempotency

A production subscription module should eventually apply two complementary defenses.

  1. Transport-event deduplication prevents the exact same webhook event from being processed twice.
  2. Renewal-fact deduplication prevents multiple events that represent one paid renewal from advancing the billing period twice.

The first layer belongs naturally in the verified webhook inbox you will build in Module 4. The second belongs in the subscription domain now, because it protects the actual business invariant even if multiple event types represent the same recurring invoice.

For example, one provider may emit both an invoice.paid event and a subscription.updated event after a monthly renewal. Those events have distinct event IDs. If your adapter classifies both as the same successful renewal, only the first may create the billing-period row.

How to Implement Webhook Idempotency

Read Hookdeck’s “How to Implement Webhook Idempotency” for a concise explanation of why durable database uniqueness is preferable to in-memory deduplication for financial state changes.

In “When to build for idempotency” and “Idempotency strategies,” read the motivating problem, then focus on the two approaches: a unique business identifier and a dedicated event-history table. In “Retries and idempotency,” read the retry explanation and the timeout warning. Finally, in “Storage strategies for idempotency keys,” read the database discussion beginning with the transactional rationale. For renewal state, PostgreSQL is the appropriate durable store because the deduplication record and subscription update must commit together.

The diagram below shows the broader processing path. Although it names Stripe, treat the first box as any provider adapter in your provider-neutral architecture. This lesson implements the transactional “idempotent handler” portion; signature verification and durable webhook inbox claiming come later.

A payment provider emits successful-payment or subscription-update events to a verified webhook endpoint; an idempotent handler writes authoritative subscription data to the database before application access and entitlements are updated.

Normalize a renewal before it reaches the domain

Your subscription service should not decide whether a provider-specific invoice means “a recurring renewal” by inspecting arbitrary provider JSON. That classification belongs in the billing-provider adapter.

A checkout payment, an upgrade proration invoice, a manual invoice, and a recurring renewal can all be “paid.” Only the recurring renewal should advance the normal billing period.

Define a normalized event with the information needed to make a deterministic decision:

export interface SuccessfulRenewalEvent {
  /**
   * Stable key for the adapter/provider configuration, for example
   * "stripe-main" or "recurly-us".
   */
  providerKey: string;

  /**
   * Delivery-level identifier. Persist it in the future webhook inbox and
   * use it for tracing now.
   */
  providerEventId: string;

  providerSubscriptionId: string;

  /**
   * Immutable identifier for this successful recurring cycle.
   * Usually a provider invoice ID or recurring invoice payment ID.
   */
  providerRenewalKey: string;

  providerVersion: string | null;

  period: {
    start: Date;
    end: Date;
  };

  paidAmountMinor: number;
  currency: string;
  paidAt: Date;
}

Your adapter should emit this event only when all of the following are true:

  • The provider considers the payment successful.
  • The paid item represents the recurring subscription cycle.
  • The subscription and resulting billing period are identifiable.
  • The provider exposes a stable providerRenewalKey.

If a provider cannot supply a stable renewal-specific identifier, use the strongest documented immutable cycle identifier it provides and record that adapter limitation explicitly. Avoid inventing an ID by hashing mutable invoice contents unless there is no alternative; corrections and provider-side metadata changes can turn that into a false collision.

A clean adapter boundary might look like this:

export interface BillingProviderEventAdapter {
  normalizeEvent(
    verifiedProviderPayload: unknown,
  ): SuccessfulRenewalEvent | null;
}

Returning null means “not a successful recurring renewal.” The webhook layer can then dispatch the payload to a different domain handler, or store it for inspection.


Persist one row per paid billing period

The subscription row is a fast projection of the current state. The billing-period table is the durable history proving why that projection advanced.

Assume your existing subscriptions table includes:

id
provider_key
provider_subscription_id
status
current_period_start
current_period_end
provider_version

Add a billing-period table or adapt the migration from Module 1 to include the following fields:

CREATE TABLE subscription_billing_periods (
  id uuid PRIMARY KEY,
  subscription_id uuid NOT NULL
    REFERENCES subscriptions(id),

  provider_key varchar NOT NULL,
  provider_renewal_key varchar NOT NULL,

  provider_invoice_id varchar NULL,

  period_start timestamptz NOT NULL,
  period_end timestamptz NOT NULL,

  paid_amount_minor bigint NOT NULL,
  currency varchar(3) NOT NULL,
  paid_at timestamptz NOT NULL,

  created_at timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT subscription_billing_periods_valid_range
    CHECK (period_end > period_start),

  CONSTRAINT subscription_billing_periods_nonnegative_payment
    CHECK (paid_amount_minor >= 0),

  CONSTRAINT subscription_billing_periods_provider_renewal_unique
    UNIQUE (provider_key, provider_renewal_key),

  CONSTRAINT subscription_billing_periods_subscription_range_unique
    UNIQUE (subscription_id, period_start, period_end)
);

The constraints protect different things:

  • (provider_key, provider_renewal_key) is the primary semantic idempotency boundary.
  • (subscription_id, period_start, period_end) is a secondary sanity guard. One subscription should not have two records claiming the same effective paid interval.
  • The period range and amount checks prevent structurally invalid records.

Add an audit uniqueness guard as well. This is defense in depth: a correct renewal handler will not attempt a duplicate audit record, but the database should still make it impossible.

ALTER TABLE subscription_transition_audits
  ADD COLUMN billing_period_id uuid NULL
    REFERENCES subscription_billing_periods(id);

CREATE UNIQUE INDEX subscription_renewal_audit_once
  ON subscription_transition_audits (billing_period_id, event_type)
  WHERE event_type = 'subscription_renewed';

A subscription_billing_periods record is not merely a technical deduplication token. It becomes useful for:

  • showing renewal history in the customer and internal administration UI;
  • reconciling local data against provider invoices;
  • explaining why access remained active for a given date range;
  • preventing accidental duplicate renewal emails or receipt-processing jobs.

Define the renewal invariants before writing the handler

For this lesson, scope the handler to ordinary renewals of an active paid subscription. Trial conversion and payment recovery have their own lifecycle rules.

A successful renewal event may be applied only when these invariants hold:

  1. The local subscription maps to the event’s providerKey and providerSubscriptionId.
  2. The subscription is still in an eligible state, normally active.
  3. The event has a non-empty, provider-scoped renewal key.
  4. The renewal period has a valid increasing range.
  5. The new period is later than the locally recorded current period.
  6. The same provider renewal key has not already been recorded.

A normal recurring renewal should begin exactly when the preceding current period ends. Enforcing that strictly catches a skipped or out-of-order period early:

function assertNextRenewalPeriod(
  subscription: SubscriptionEntity,
  event: SuccessfulRenewalEvent,
): void {
  if (!subscription.currentPeriodEnd) {
    throw new DomainError('subscription_has_no_current_period');
  }

  if (event.period.end <= event.period.start) {
    throw new DomainError('invalid_provider_billing_period');
  }

  if (
    event.period.start.getTime() !==
    subscription.currentPeriodEnd.getTime()
  ) {
    throw new DomainError('renewal_period_is_not_contiguous');
  }
}

Some providers have unusual flows such as pauses, re-anchored billing dates, or backdated corrections. Do not silently “make the dates fit” by changing the local current period. Treat a non-contiguous renewal as ambiguous and reconcile it against an authoritative provider snapshot. Module 4 will make that recovery path systematic.

Also preserve the cancellation distinction from the previous lesson:

  • A normal renewal advances the paid period.
  • It does not automatically reverse cancelAtPeriodEnd.
  • It does not silently modify the plan or purchased seat quantity.
  • It does not send external emails directly inside the transaction.

If the provider renews a subscription your local database believes is scheduled to end, that is meaningful disagreement. Preserve the evidence and reconcile rather than guessing whether the cancellation was reversed or ignored.


Apply the renewal atomically under a subscription lock

The reliable unit of work is a single PostgreSQL transaction:

  1. Lock the subscription row.
  2. Check whether the renewal fact already exists.
  3. Validate that the event is the expected next period.
  4. Insert the billing-period record using its unique renewal key.
  5. Advance the subscription projection.
  6. Append one audit record.
  7. Commit.

If anything fails before commit, PostgreSQL rolls back all writes. If the process crashes after commit but before acknowledging the webhook, a retry finds the already-created billing-period record and performs no second advance.

Here is a TypeORM-oriented implementation. Repository names are illustrative; retain the host-integration and provider-neutral abstractions already built in the earlier modules.

type RenewalApplyResult =
  | { kind: 'applied'; billingPeriodId: string }
  | { kind: 'duplicate'; billingPeriodId: string };

async function applySuccessfulRenewal(
  event: SuccessfulRenewalEvent,
): Promise<RenewalApplyResult> {
  return dataSource.transaction(async function applyInTransaction(manager) {
    const subscription = await subscriptionRepository.findOneOrFail({
      where: {
        providerKey: event.providerKey,
        providerSubscriptionId: event.providerSubscriptionId,
      },
      lock: { mode: 'pessimistic_write' },
    });

    if (subscription.status !== 'active') {
      throw new DomainError('renewal_not_allowed_for_subscription_status');
    }

    const existing = await billingPeriodRepository.findOne({
      where: {
        providerKey: event.providerKey,
        providerRenewalKey: event.providerRenewalKey,
      },
    });

    if (existing) {
      assertMatchingRenewal(existing, event);

      return {
        kind: 'duplicate',
        billingPeriodId: existing.id,
      };
    }

    assertNextRenewalPeriod(subscription, event);

    const period = billingPeriodRepository.create({
      id: randomUUID(),
      subscriptionId: subscription.id,

      providerKey: event.providerKey,
      providerRenewalKey: event.providerRenewalKey,

      periodStart: event.period.start,
      periodEnd: event.period.end,

      paidAmountMinor: event.paidAmountMinor,
      currency: event.currency,
      paidAt: event.paidAt,
    });

    try {
      await manager.insert(SubscriptionBillingPeriodEntity, period);
    } catch (error) {
      if (!isUniqueViolation(error)) {
        throw error;
      }

      const concurrentPeriod = await billingPeriodRepository.findOneOrFail({
        where: {
          providerKey: event.providerKey,
          providerRenewalKey: event.providerRenewalKey,
        },
      });

      assertMatchingRenewal(concurrentPeriod, event);

      return {
        kind: 'duplicate',
        billingPeriodId: concurrentPeriod.id,
      };
    }

    subscription.currentPeriodStart = event.period.start;
    subscription.currentPeriodEnd = event.period.end;
    subscription.providerVersion = event.providerVersion;
    subscription.revision += 1;

    await manager.save(subscription);

    await manager.insert(SubscriptionTransitionAuditEntity, {
      id: randomUUID(),
      subscriptionId: subscription.id,
      billingPeriodId: period.id,
      eventType: 'subscription_renewed',
      actorType: 'billing_provider',
      actorId: event.providerKey,
      details: {
        providerEventId: event.providerEventId,
        providerRenewalKey: event.providerRenewalKey,
        periodStart: event.period.start.toISOString(),
        periodEnd: event.period.end.toISOString(),
      },
      occurredAt: event.paidAt,
    });

    return {
      kind: 'applied',
      billingPeriodId: period.id,
    };
  });
}

The row lock serializes renewal updates for the same subscription. The unique constraint remains essential because application-level checks alone are vulnerable to races across process instances.

The try/catch is not treating every unique-constraint violation as a duplicate. It must only accept a conflict that resolves to the same semantic renewal with matching values.

function assertMatchingRenewal(
  period: SubscriptionBillingPeriodEntity,
  event: SuccessfulRenewalEvent,
): void {
  const matches =
    period.subscriptionId !== null &&
    period.periodStart.getTime() === event.period.start.getTime() &&
    period.periodEnd.getTime() === event.period.end.getTime() &&
    period.paidAmountMinor === event.paidAmountMinor &&
    period.currency === event.currency;

  if (!matches) {
    throw new DomainError('provider_renewal_key_payload_conflict');
  }
}

A repeated delivery with the same renewal key but different dates or amount is not a safe duplicate. It is evidence of a provider correction, adapter bug, or data-integrity issue. Roll back and route it to reconciliation rather than overwriting history.


Keep side effects outside the direct renewal handler

A renewal may cause optional actions:

  • send a renewal confirmation email;
  • refresh a cache;
  • emit an internal analytics event;
  • notify an accounting integration.

Do not perform these directly before the database transaction commits. A timeout or rollback can otherwise leave you with an email claiming a renewal that was never persisted.

Instead, write an outbox intent inside the same transaction:

CREATE TABLE subscription_outbox (
  id uuid PRIMARY KEY,
  event_type varchar NOT NULL,
  billing_period_id uuid NOT NULL
    REFERENCES subscription_billing_periods(id),
  payload jsonb NOT NULL,
  published_at timestamptz NULL,

  CONSTRAINT subscription_outbox_renewal_once
    UNIQUE (event_type, billing_period_id)
);

A background publisher can deliver that intent after commit. The unique constraint means a replayed renewal cannot generate a second subscription_renewed notification intent.

This separation is especially valuable for your reusable module: the core subscription domain records a renewal correctly whether the host product chooses to send an email, post to Slack, update analytics, or do nothing.


Test duplicates, concurrency, and crash recovery

Use PostgreSQL integration tests rather than mocks for the idempotency guarantee. Mocking a repository cannot prove that a real unique constraint and transaction correctly handle concurrent workers.

Your key test cases should be:

ScenarioRequired assertion
Same event delivered twice sequentiallyOne billing-period row, one updated current period, one renewal audit record.
Different event IDs for the same provider renewal keyOne billing-period row and one current-period advance.
Two concurrent handlers receive the same renewalOne result is applied, one is duplicate; database state contains one period.
Handler crashes before transaction commitNo period, no updated subscription projection, no audit row. A retry can apply normally.
Handler commits but webhook acknowledgement is lostRetry returns duplicate; billing period remains unchanged.
Same renewal key with conflicting amount or datesReject with provider_renewal_key_payload_conflict; do not mutate subscription state.
Renewal is non-contiguous with current periodReject as ambiguous; do not advance dates.
A checkout or proration invoice is marked paidAdapter does not dispatch it as SuccessfulRenewalEvent.

The concurrency test should invoke separate transactions rather than merely calling the method twice after the first resolves:

it('advances a renewal exactly once under concurrent delivery', async function () {
  const event = successfulRenewalFixture();

  const results = await Promise.all([
    applySuccessfulRenewal(event),
    applySuccessfulRenewal(event),
  ]);

  expect(results.map(function getKind(result) {
    return result.kind;
  }).sort()).toEqual(['applied', 'duplicate']);

  const periods = await billingPeriodRepository.find({
    where: {
      providerKey: event.providerKey,
      providerRenewalKey: event.providerRenewalKey,
    },
  });

  expect(periods).toHaveLength(1);

  const subscription = await subscriptionRepository.findOneByOrFail({
    id: periods[0].subscriptionId,
  });

  expect(subscription.currentPeriodEnd).toEqual(event.period.end);
});

For a stronger variant, create two separate NestJS application instances or two independent database connections. That more closely resembles two worker processes claiming duplicate events at nearly the same time.


Key takeaways

A successful renewal is a business fact, not merely a webhook delivery. Make it idempotent by recording that fact in PostgreSQL under a provider-scoped unique renewal key.

  • Use the provider event ID for tracing and future webhook-inbox deduplication.
  • Use a stable provider renewal key, usually an immutable recurring invoice or charge ID, to prevent double billing-period advances across distinct events.
  • In one transaction, lock the subscription, insert the billing period, update the current-period projection, and write the audit record.
  • Let PostgreSQL constraints resolve races; application-level “check first” logic is not sufficient.
  • Treat conflicting duplicate payloads and non-contiguous periods as reconciliation problems, not as values to overwrite.
  • Keep external effects behind a transactional outbox.

Next, you will handle failed-payment and recovery events. That will introduce provider-managed dunning, your local grace-period policy, and the moment when payment trouble should eventually affect access.

Can't find a good explanation? Sign up and we'll make it for you

Sign up