Resource library

QA How-To

Test Stripe Webhook API Version Mismatches (2026)

Learn to test stripe webhook api version mismatches with signed fixtures, runtime contracts, negative cases, Stripe CLI checks, and safe migration gates.

22 min read | 2,949 words

TL;DR

Test version mismatches with signed raw fixtures that carry explicit `api_version` values, then assert both accepted mappings and rejected version-shape combinations. Keep signature verification first, normalize only supported schemas, and compare the deployed endpoint's pinned version with the version expected by your code.

Key Takeaways

  • Treat the webhook endpoint API version, event api_version, account default, and stripe-node pinned version as separate values.
  • Verify the Stripe signature against raw bytes before reading or trusting api_version.
  • Keep signed fixtures from both sides of every breaking release boundary your integration supports.
  • Normalize version-specific Stripe objects into one small internal contract before business processing.
  • Reject a payload when its declared API version disagrees with its field layout.
  • Check the registered webhook endpoint version in CI or a scheduled deployment gate to catch configuration drift.
  • Use parallel old and new Stripe endpoints for a reversible production migration.

To test stripe webhook api version mismatches, do not merely change the API version on a Stripe client and rerun a happy-path test. Build signed webhook fixtures for every supported event version, prove each payload matches the schema declared by event.api_version, and reject unknown or contradictory combinations before business logic runs.

A Stripe integration can have four relevant versions at once: the account default, the version pinned on a webhook endpoint, the api_version recorded in a delivered Event, and the version bundled with your server SDK. Confusing those values causes failures that look like missing properties, stale TypeScript definitions, or unexplained deserialization errors. This tutorial isolates that boundary with Node.js, Express, stripe-node, Vitest, and Supertest. For the broader delivery lifecycle, start with the Webhook API Testing Complete Guide for 2026.

The concrete schema change is real: Checkout Session shipping information moved from top-level shipping_details to collected_information.shipping_details in the 2025-03-31.basil release. You will represent the older shape with a 2024-06-20 fixture and the current shape with 2026-07-29.dahlia, the API version pinned by stripe-node 22.4.0.

What You Will Build

By the end, you will have:

  • Two raw checkout.session.completed fixtures on opposite sides of a breaking Stripe API release.
  • A real Stripe signature verification path using constructEvent().
  • A version-aware adapter that produces one stable shipping contract.
  • Positive tests for both supported payload versions.
  • Negative tests for forged signatures, unsupported versions, missing versions, and dishonest field layouts.
  • A deployment check that reads the configured webhook endpoint from Stripe and fails on drift.
  • A local Stripe CLI check for transport, signing-secret, routing, and current-event compatibility.
Version value Where it lives What it controls Test assertion
Account default Stripe Workbench Requests and unpinned endpoints that inherit it Do not assume it equals production code
Endpoint version Webhook endpoint configuration Shape used to render snapshot event objects Compare it with the expected deployed version
event.api_version Delivered Event payload Version recorded for that snapshot event Route only after signature verification
stripe-node version package-lock.json SDK types and default version for outbound API requests Pin it and review its changelog
Explicit client apiVersion new Stripe() options Outbound requests from that client Do not use it to infer webhook shape

The last row is the usual trap. Constructing a Stripe client with apiVersion: '2026-07-29.dahlia' does not transform an older webhook body. The body remains the bytes Stripe signed and delivered.

Prerequisites

Use these exact tutorial versions:

  • Node.js 24.12.0 and npm 11.8.0.
  • stripe-node 22.4.0, pinned to Stripe API 2026-07-29.dahlia.
  • Express 5.2.1.
  • Vitest 4.1.10.
  • Supertest 7.2.1.
  • Stripe CLI 1.44.0 for the optional sandbox verification.

Check your local tools:

node --version
npm --version
stripe version

Expected output begins with v24.12.0, 11.8.0, and stripe version 1.44.0. You also need a Stripe sandbox account for the final live configuration check. The deterministic contract suite does not make network calls and uses a fake webhook secret.

Never place sk_live_ or a real webhook signing secret in a fixture, command history, or repository. Use sandbox keys through environment variables. Keep webhook secrets separate from API keys because they authenticate different boundaries.

Step 1: Test Stripe Webhook API Version Mismatches at the Boundary

Start by writing down the versions you actually operate. Do not begin with the latest API reference. Begin with the endpoint that produces the payload your service receives. In Workbench, open Webhooks, select the destination, and record its API version, enabled event types, mode, URL, and whether it receives connected-account events.

You can retrieve the same evidence through Stripe's API. Set sandbox values in your shell, then request the endpoint object:

export STRIPE_SECRET_KEY='sk_test_replace_me'
export STRIPE_WEBHOOK_ENDPOINT_ID='we_replace_me'

curl -sS "https://api.stripe.com/v1/webhook_endpoints/$STRIPE_WEBHOOK_ENDPOINT_ID" \
  -u "$STRIPE_SECRET_KEY:" \
  -H 'Stripe-Version: 2026-07-29.dahlia'

The response's api_version is the version used to render events for that endpoint. A null value means the endpoint follows the account default, which is a drift risk because a later account upgrade can change its payloads without a webhook-code deployment. The Stripe-Version request header above controls this retrieval request; it does not rewrite the endpoint's stored api_version.

Build a small support matrix before coding. This tutorial accepts exactly 2024-06-20 and 2026-07-29.dahlia. It rejects everything else until a fixture and adapter branch are reviewed. That closed set is safer than date comparison because named releases can introduce breaking changes while monthly versions inside one named release are additive. Review how to test API versioning when your service supports a wider compatibility window.

Verify the step: confirm the endpoint ID, mode, URL, and api_version match the environment you intended to inspect. If api_version is null, record the account default separately and plan to pin the replacement endpoint during migration.

Step 2: Create the Runnable Stripe Webhook Lab

Create an isolated project. Exact package pins make a future failure attributable to your code, fixture, or deliberate upgrade rather than an unreviewed dependency range.

mkdir stripe-webhook-version-lab
cd stripe-webhook-version-lab
npm init -y
npm pkg set type=module
npm pkg set scripts.dev="node src/server.js"
npm pkg set scripts.test="vitest run"
npm install --save-exact stripe@22.4.0 express@5.2.1
npm install --save-dev --save-exact vitest@4.1.10 supertest@7.2.1
mkdir -p src test/fixtures scripts

Create src/stripe-client.js. The placeholder key is sufficient because signature helpers are local and no Stripe request occurs in the test suite.

import Stripe from 'stripe';

export const CURRENT_API_VERSION = '2026-07-29.dahlia';
export const LEGACY_API_VERSION = '2024-06-20';

export const stripe = new Stripe('sk_test_placeholder', {
  apiVersion: CURRENT_API_VERSION,
  maxNetworkRetries: 0,
  telemetry: false
});

The explicit apiVersion makes outbound API behavior visible. It also aligns with stripe-node 22.4.0. It does not authorize the webhook parser to cast an old event as the current shape. Runtime checks still own that decision.

Verify the step: inspect the exact dependency tree and import the client:

npm ls stripe express vitest supertest
node -e "import('./src/stripe-client.js').then(m => console.log(m.CURRENT_API_VERSION))"

The tree should contain only the pinned versions above, and the import check should print 2026-07-29.dahlia. Commit both package.json and package-lock.json in a real project.

Step 3: Store Old and New Snapshot Event Fixtures

Create test/fixtures/checkout-completed.legacy.json with the pre-Basil top-level shipping field:

{
  "id": "evt_legacy_checkout",
  "object": "event",
  "api_version": "2024-06-20",
  "created": 1785974400,
  "livemode": false,
  "type": "checkout.session.completed",
  "data": {
    "object": {
      "id": "cs_test_legacy",
      "object": "checkout.session",
      "shipping_details": {
        "name": "Ada Lovelace",
        "address": {
          "line1": "10 Test Street",
          "city": "London",
          "country": "GB",
          "postal_code": "SW1A 1AA"
        }
      }
    }
  }
}

Create test/fixtures/checkout-completed.current.json with the current nested location:

{
  "id": "evt_current_checkout",
  "object": "event",
  "api_version": "2026-07-29.dahlia",
  "created": 1785974400,
  "livemode": false,
  "type": "checkout.session.completed",
  "data": {
    "object": {
      "id": "cs_test_current",
      "object": "checkout.session",
      "collected_information": {
        "shipping_details": {
          "name": "Ada Lovelace",
          "address": {
            "line1": "10 Test Street",
            "city": "London",
            "country": "GB",
            "postal_code": "SW1A 1AA"
          }
        }
      }
    }
  }
}

These are minimal contract fixtures, not claimed copies of every field Stripe returns. Keep fields your handler reads plus envelope fields needed for routing and diagnostics. Sanitized payloads captured from a Stripe sandbox provide stronger coverage, but preserve the raw body before pretty-printing because signature verification is byte-sensitive.

Add test/load-fixture.js:

import { readFileSync } from 'node:fs';

export function loadFixture(name) {
  return readFileSync(
    new URL(`./fixtures/checkout-completed.${name}.json`, import.meta.url),
    'utf8'
  );
}

Verify the step: load both files and print their version and shipping path:

node -e "import('./test/load-fixture.js').then(({loadFixture}) => { for (const n of ['legacy','current']) { const e=JSON.parse(loadFixture(n)); console.log(n, e.api_version, Object.keys(e.data.object)); } })"

The legacy keys include shipping_details; the current keys include collected_information. If both fixtures have the same shape, they cannot expose the migration defect this suite is designed to catch.

Step 4: Verify the Signature Before Reading the Version

Create src/event-contract.js. It treats the version as untrusted input until constructEvent() authenticates the raw payload. The adapter then enforces that the field layout agrees with the declared version.

import { CURRENT_API_VERSION, LEGACY_API_VERSION } from './stripe-client.js';

const supported = new Set([LEGACY_API_VERSION, CURRENT_API_VERSION]);

function normalizeShipping(details) {
  if (details == null) return null;
  if (typeof details.name !== 'string' || typeof details.address !== 'object') {
    throw new Error('invalid shipping details contract');
  }

  return {
    name: details.name,
    line1: details.address.line1 ?? null,
    city: details.address.city ?? null,
    country: details.address.country ?? null,
    postalCode: details.address.postal_code ?? null
  };
}

export function normalizeCheckoutCompleted(event) {
  if (!supported.has(event.api_version)) {
    throw new Error(`unsupported Stripe event API version: ${event.api_version}`);
  }
  if (event.type !== 'checkout.session.completed') {
    throw new Error(`unexpected Stripe event type: ${event.type}`);
  }

  const session = event.data?.object;
  if (session?.object !== 'checkout.session' || typeof session.id !== 'string') {
    throw new Error('invalid Checkout Session event object');
  }

  let shippingDetails;
  if (event.api_version === LEGACY_API_VERSION) {
    if ('collected_information' in session) {
      throw new Error('legacy version declared with current Checkout Session shape');
    }
    shippingDetails = session.shipping_details ?? null;
  } else {
    if ('shipping_details' in session) {
      throw new Error('current version declared with legacy Checkout Session shape');
    }
    shippingDetails = session.collected_information?.shipping_details ?? null;
  }

  return {
    eventId: event.id,
    apiVersion: event.api_version,
    sessionId: session.id,
    shipping: normalizeShipping(shippingDetails)
  };
}

Now create src/app.js:

import express from 'express';
import { normalizeCheckoutCompleted } from './event-contract.js';
import { stripe } from './stripe-client.js';

export function createApp({ webhookSecret, handleCheckout = async () => {} }) {
  const app = express();

  app.post(
    '/webhooks/stripe',
    express.raw({ type: 'application/json', limit: '256kb' }),
    async (req, res) => {
      const signature = req.get('stripe-signature');
      let event;

      try {
        event = stripe.webhooks.constructEvent(
          req.body,
          signature,
          webhookSecret
        );
      } catch {
        return res.status(400).json({ code: 'invalid_signature' });
      }

      if (event.type !== 'checkout.session.completed') {
        return res.status(200).json({ received: true, ignored: true });
      }

      let normalized;
      try {
        normalized = normalizeCheckoutCompleted(event);
      } catch (error) {
        return res.status(422).json({
          code: 'version_contract_rejected',
          message: error.message
        });
      }

      await handleCheckout(normalized);
      return res.status(200).json({ received: true });
    }
  );

  app.use(express.json());
  return app;
}

The raw parser is mounted on the Stripe route before the general JSON parser. Reversing that order changes the signed material into an object and makes verification impossible. The receiver also keeps version failure separate from authentication failure, which makes triage faster without exposing secrets. For more attack and raw-body cases, use the step-by-step webhook signature verification guide.

Verify the step: import the Express application without starting a socket:

node -e "import('./src/app.js').then(({createApp}) => console.log(typeof createApp({webhookSecret:'whsec_test'})))"

Expected output is function, because an Express application is callable. A missing API key error usually means the Stripe client was instantiated from an absent environment variable during import instead of the test placeholder.

Step 5: Prove Both Supported Versions Produce One Contract

Create test/app.test.js. The test uses Stripe's real generateTestHeaderString() API to sign the exact fixture string and Supertest to send those bytes through Express.

import request from 'supertest';
import { describe, expect, it, vi } from 'vitest';
import { createApp } from '../src/app.js';
import { stripe } from '../src/stripe-client.js';
import { loadFixture } from './load-fixture.js';

const WEBHOOK_SECRET = 'whsec_deterministic_test_secret';

function signedPost(app, payload, secret = WEBHOOK_SECRET) {
  const signature = stripe.webhooks.generateTestHeaderString({
    payload,
    secret
  });

  return request(app)
    .post('/webhooks/stripe')
    .set('content-type', 'application/json')
    .set('stripe-signature', signature)
    .send(payload);
}

describe('Stripe webhook API version contracts', () => {
  it.each([
    ['legacy', '2024-06-20', 'cs_test_legacy'],
    ['current', '2026-07-29.dahlia', 'cs_test_current']
  ])('normalizes the %s payload', async (name, apiVersion, sessionId) => {
    const handleCheckout = vi.fn();
    const app = createApp({
      webhookSecret: WEBHOOK_SECRET,
      handleCheckout
    });

    const response = await signedPost(app, loadFixture(name));

    expect(response.status).toBe(200);
    expect(handleCheckout).toHaveBeenCalledOnce();
    expect(handleCheckout).toHaveBeenCalledWith({
      eventId: `evt_${name}_checkout`,
      apiVersion,
      sessionId,
      shipping: {
        name: 'Ada Lovelace',
        line1: '10 Test Street',
        city: 'London',
        country: 'GB',
        postalCode: 'SW1A 1AA'
      }
    });
  });
});

The important assertion is not that both requests return 200. It proves both external schemas converge on the same internal shape. Downstream fulfillment code no longer needs to know whether shipping lived at session.shipping_details or session.collected_information.shipping_details.

Keep the adapter small. If the business handler consumes dozens of Stripe properties directly, every API upgrade becomes a broad regression. Normalize only the IDs, amounts, states, and customer data required for the use case, then preserve the source event ID and version for audit.

Verify the step: run the complete test command:

npm test

Vitest should report two passing cases from the parameterized test. If the legacy case returns 422, inspect the old fixture for an accidental collected_information field. If a case returns 400, compare the exact payload passed to the signing helper with the string sent by Supertest.

Step 6: Test Stripe Webhook API Version Mismatches as Negative Contracts

Happy-path compatibility does not prove that version enforcement works. Add these tests inside the existing describe block. Each one changes a single dimension, resigns the modified bytes when appropriate, and asserts that no business action occurs.

it('rejects a current version declaration with the legacy field layout', async () => {
  const handleCheckout = vi.fn();
  const app = createApp({ webhookSecret: WEBHOOK_SECRET, handleCheckout });
  const event = JSON.parse(loadFixture('legacy'));
  event.api_version = '2026-07-29.dahlia';

  const response = await signedPost(app, JSON.stringify(event));

  expect(response.status).toBe(422);
  expect(response.body.code).toBe('version_contract_rejected');
  expect(response.body.message).toContain('legacy Checkout Session shape');
  expect(handleCheckout).not.toHaveBeenCalled();
});

it.each([null, '2025-09-30.clover', '2099-01-01.future'])(
  'rejects unsupported api_version %s',
  async (apiVersion) => {
    const handleCheckout = vi.fn();
    const app = createApp({ webhookSecret: WEBHOOK_SECRET, handleCheckout });
    const event = JSON.parse(loadFixture('current'));
    event.api_version = apiVersion;

    const response = await signedPost(app, JSON.stringify(event));

    expect(response.status).toBe(422);
    expect(response.body.message).toContain('unsupported Stripe event API version');
    expect(handleCheckout).not.toHaveBeenCalled();
  }
);

it('authenticates before trusting a supported api_version', async () => {
  const handleCheckout = vi.fn();
  const app = createApp({ webhookSecret: WEBHOOK_SECRET, handleCheckout });

  const response = await signedPost(
    app,
    loadFixture('current'),
    'whsec_wrong_secret'
  );

  expect(response.status).toBe(400);
  expect(response.body).toEqual({ code: 'invalid_signature' });
  expect(handleCheckout).not.toHaveBeenCalled();
});

The second case deliberately rejects 2025-09-30.clover even though it is a real Stripe version. Supporting a version is a product decision backed by fixtures, not a syntax check. A parser that accepts every date-shaped string will eventually run an untested branch against a breaking named release.

Add a reverse mismatch too: take the current fixture, declare 2024-06-20, resign it, and expect the adapter to reject collected_information. That case detects a developer who updates a fixture body but forgets its version label. Add missing event type, wrong object type, absent session ID, shipping_details: null, and malformed addresses according to your business contract.

Webhook delivery is at least once, so a passing version adapter still needs event-ID idempotency. Do that after authentication and contract validation, using an atomic uniqueness constraint rather than an in-memory Set. The event ordering and duplicate validation tutorial covers that separate state problem.

Verify the step: rerun npm test. You should now have six passing executions: two supported payloads, one version-shape mismatch, three unsupported-version rows, and one forged-signature case. Vitest counts the parameterized rows individually, so the displayed number can be seven depending on reporter grouping. More importantly, handleCheckout remains at zero for every rejection.

Step 7: Detect Deployed Endpoint Version Drift

Local fixtures protect code, but they cannot prove which version Stripe is configured to deliver. Add a small real-API check for a sandbox or protected deployment environment. Create scripts/check-webhook-version.js:

import Stripe from 'stripe';
import { CURRENT_API_VERSION } from '../src/stripe-client.js';

const key = process.env.STRIPE_SECRET_KEY;
const endpointId = process.env.STRIPE_WEBHOOK_ENDPOINT_ID;

if (!key || !endpointId) {
  throw new Error('STRIPE_SECRET_KEY and STRIPE_WEBHOOK_ENDPOINT_ID are required');
}

const client = new Stripe(key, {
  apiVersion: CURRENT_API_VERSION,
  maxNetworkRetries: 1,
  telemetry: false
});

const endpoint = await client.webhookEndpoints.retrieve(endpointId);
const actual = endpoint.api_version;

if (actual !== CURRENT_API_VERSION) {
  throw new Error(
    `webhook ${endpoint.id} uses ${actual ?? 'account default'}, expected ${CURRENT_API_VERSION}`
  );
}

console.log(`verified ${endpoint.id}: ${actual}, ${endpoint.status}`);

Register and run it:

npm pkg set scripts.check:webhook-version="node scripts/check-webhook-version.js"
STRIPE_SECRET_KEY='sk_test_replace_me' \
STRIPE_WEBHOOK_ENDPOINT_ID='we_replace_me' \
  npm run check:webhook-version

Expected output includes verified, the endpoint ID, 2026-07-29.dahlia, and its status. Use a restricted sandbox key or CI secret, never a fixture credential. Run the offline suite on every pull request and the drift check after deployment or on a schedule where protected secrets are available.

For an actual version upgrade, Stripe recommends creating a second endpoint at the new version, initially disabled, then enabling both while code distinguishes them. Point both at version-labeled URLs or query parameters. During shadowing, authenticate each endpoint with its own signing secret, process only one side, compare normalized outcomes, and retain a quick rollback path. Never let the two deliveries execute fulfillment twice.

You can also verify current transport locally. Start the Stripe CLI first, copy the printed whsec_ secret into STRIPE_WEBHOOK_SECRET, start the app, and trigger a sandbox event:

// src/server.js
import { createApp } from './app.js';

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) throw new Error('STRIPE_WEBHOOK_SECRET is required');

createApp({
  webhookSecret,
  handleCheckout: async (checkout) => console.log(checkout)
}).listen(4242, () => console.log('listening on http://localhost:4242'));
stripe listen \
  --events checkout.session.completed \
  --forward-to localhost:4242/webhooks/stripe

STRIPE_WEBHOOK_SECRET='whsec_from_stripe_listen' npm run dev

stripe trigger checkout.session.completed \
  --stripe-version 2026-07-29.dahlia

The CLI check proves routing and a real CLI-issued signature, while the frozen fixtures prove historical schemas. Keep both layers because stripe trigger follows current trigger fixtures and is not a replacement for your old-version regression corpus. For a fuller sandbox journey, follow testing webhooks end to end.

Verify the step: observe a 200 delivery in the stripe listen terminal and one normalized object in the server terminal. Then run the drift script against the exact sandbox endpoint used by the environment. A local success with a drift failure means code and Stripe configuration disagree, which is precisely the release condition this gate should block.

Troubleshooting

Problem: Webhook payload must be provided as a string or a Buffer -> A JSON parser consumed the body before express.raw(). Mount the Stripe route before express.json(), and send the exact bytes used to generate the signature.

Problem: every fixture returns invalid_signature -> Confirm the signing helper and application use the same fake secret. Do not pretty-print, parse, or append a newline after generating the test header. If the CLI is involved, use the whsec_ printed by the active stripe listen process, not a Dashboard endpoint secret.

Problem: the Event says an old version even though stripe-node is current -> This is expected when the webhook endpoint is pinned to an older API version. SDK configuration controls outbound requests and types; it does not mutate delivered snapshot events. Route on the authenticated event.api_version.

Problem: api_version is null on the webhook endpoint object -> The endpoint inherits the account default. Record that default for diagnosis, then create a pinned replacement endpoint for a controlled migration instead of relying on implicit configuration.

Problem: both old and new endpoints perform fulfillment -> Shadow delivery lacks an ownership rule. Make one endpoint acknowledge without processing, or place both through a shared inbox keyed by Stripe event ID and a migration-safe logical operation key. Test the rollback sequence before enabling dual delivery.

Problem: the CLI event passes but a captured production fixture fails -> The CLI's current trigger template does not reproduce every historical account setting or object expansion. Sanitize the real sandbox payload, add it as a raw fixture, and update the adapter only after identifying the documented schema difference.

Interview Questions and Answers

Q: Which Stripe API version determines a webhook snapshot object's shape?

The API version configured for the webhook endpoint determines how Stripe renders its snapshot events. If the endpoint has no explicit version, it follows the account default. I confirm the delivered event.api_version after signature verification and do not infer it from the server SDK.

Q: Why is updating stripe-node insufficient for a webhook version migration?

A newer SDK changes outbound request defaults and compile-time types, but historical or old-endpoint payloads remain old shapes. The receiver needs fixtures for both versions, runtime narrowing, and a controlled endpoint configuration change. Static types cannot authenticate or reinterpret raw network data.

Q: What should happen when a signed event has an unsupported API version?

I prevent business processing, emit a low-cardinality reason code with event ID and received version, and follow the team's retry or quarantine policy. During a migration rehearsal, a non-2xx response can preserve retries. In a mature inbox design, the endpoint can durably quarantine first and acknowledge only after that write succeeds.

Q: Why test a version-shape contradiction if Stripe would not normally send one?

It catches mislabeled fixtures, proxy transformations, hand-built test events, and routing defects before they mask real incompatibility. It also proves the adapter uses the declared contract rather than opportunistically reading whichever property happens to exist. That makes upgrade coverage reviewable.

Q: How do you prevent duplicate effects while shadowing two webhook versions?

Give exactly one path processing ownership or ingest both into a durable inbox with atomic deduplication. Compare their normalized records outside the side-effecting worker. A query parameter alone distinguishes routes but does not provide idempotency.

Q: What belongs in a Stripe webhook version regression fixture?

Keep the raw, sanitized envelope, api_version, event type, object discriminator, identifiers, and every field the adapter reads. Preserve meaningful null, omitted, enum, and nested-object cases. Exclude unrelated customer data and never retain secrets or full payment details.

Common Mistakes

  • Assuming the latest API reference describes an older endpoint's event payload.
  • Trusting event.api_version before authenticating the signature.
  • Parsing JSON globally before the webhook route captures raw bytes.
  • Treating a stripe-node major upgrade as an automatic webhook endpoint upgrade.
  • Supporting any date-shaped version without an approved fixture and adapter branch.
  • Mutating an old fixture into a new shape and forgetting to change its version label.
  • Testing only 200 responses without asserting the normalized business input.
  • Sharing one webhook secret across endpoint generations when separate rotation is possible.
  • Processing both endpoints during a shadow migration.
  • Logging full webhook bodies, signatures, addresses, or API keys during mismatch triage.
  • Letting an endpoint inherit the account default without deployment monitoring.
  • Replacing historical fixtures with the latest Stripe CLI trigger output.

Where To Go Next

First, expand the matrix to every event type that causes money movement, entitlement changes, fulfillment, refunds, or account access. Keep one fixture per supported version boundary, not one arbitrary payload per calendar month. Add null and omitted-field cases whenever your adapter treats them differently.

Then strengthen the surrounding webhook system:

For a production upgrade, compare the old and new normalized events during a bounded shadow window. Measure counts by event type and version, investigate every normalization difference, rehearse rollback, then disable the old endpoint only after queued deliveries and retry behavior are understood.

Conclusion

To test Stripe webhook API version mismatches reliably, separate configuration facts from payload evidence. Authenticate raw bytes, read the event's version, enforce a closed set of supported contracts, normalize each approved schema, and prove that contradictory or unknown payloads cannot reach business logic.

The finished suite gives you two kinds of confidence: deterministic fixtures protect historical compatibility, and the deployed endpoint check protects the Stripe configuration that chooses future payload shapes. Keep both gates in the release process, and a Stripe API upgrade becomes an observable migration instead of a surprise deserialization incident.

Interview Questions and Answers

How would you test Stripe webhook API version mismatches?

I would capture sanitized raw fixtures for every supported version boundary, sign them with Stripe's test header helper, and send them through the real HTTP middleware. I would assert the normalized business contract for supported shapes and reject unsupported versions, missing versions, and version-shape contradictions. I would also retrieve the deployed endpoint configuration to detect drift.

Which version controls the schema of a Stripe snapshot webhook event?

The webhook endpoint's configured API version controls its rendered snapshot event shape. An unpinned endpoint inherits the account default. I verify the signature first, then use the delivered Event's `api_version` as runtime evidence.

Why must signature verification happen before version routing?

The version field is attacker-controlled until the raw body is authenticated. Routing or parsing deeply before verification lets forged input select code paths and consume resources. I verify the exact bytes with the endpoint secret, then inspect event type and version.

How do stripe-node types affect old webhook payloads?

stripe-node types reflect the SDK's current supported API shape, not every historical webhook schema. Casting an old Event to the current type hides rather than solves the mismatch. I treat webhook input as unknown at runtime and narrow it through version-specific contracts.

How would you test a Stripe webhook version migration without duplicate fulfillment?

I would run old and new endpoints in parallel but give only one path side-effect ownership. Both can write normalized comparison records to a durable inbox keyed by Event ID, while the worker processes one approved stream. I would test retries, rollback, and the final old-endpoint shutdown.

What negative cases matter for Stripe webhook version testing?

I cover an unsupported real version, a future-looking version, null version, valid signature with contradictory schema, invalid signature with a supported version, wrong event type, wrong object discriminator, and missing required business fields. Each rejection must leave business handlers untouched and produce safe diagnostics.

How do you detect Stripe webhook configuration drift?

I retrieve the webhook endpoint in a protected deployment check and compare its `api_version`, URL, mode, event selection, and status with environment expectations. A null version is flagged because it inherits the account default. The check complements offline fixtures rather than replacing them.

Frequently Asked Questions

What causes a Stripe webhook API version mismatch?

A mismatch occurs when the webhook endpoint renders an Event using a schema different from the one the receiver or SDK expects. Common causes include account upgrades, endpoint drift, SDK upgrades without webhook migration, stale fixtures, and multiple endpoints pinned to different versions.

Does the stripe-node apiVersion setting change incoming webhook events?

No. The client setting controls outbound Stripe API requests made by that client. Incoming webhook bytes keep the shape selected by the webhook endpoint, and the authenticated Event records that shape in `api_version`.

How do I find the API version of a Stripe webhook event?

After verifying the Stripe signature, read the Event object's `api_version`. Also retrieve the webhook endpoint and inspect its `api_version`; a null endpoint value means it inherits the account default.

Can Stripe CLI test old webhook API versions?

The CLI can select versions for Stripe requests and is valuable for local forwarding, signatures, and current sandbox flows. Keep sanitized historical raw fixtures as the authoritative old-schema regression corpus because trigger templates and account configuration can evolve.

Should a receiver accept every Stripe API version?

No. Accept only versions backed by reviewed fixtures and explicit adapter behavior. Unknown named releases can contain breaking changes, so a date-shaped value is not evidence of compatibility.

What status should a webhook return for an unsupported Stripe API version?

Choose it with your delivery and quarantine design. A non-2xx response preserves Stripe retries during a controlled migration, while a durable inbox may quarantine the event and return 2xx only after the write succeeds. Never acknowledge and silently discard an event that should drive business work.

How do I migrate a Stripe webhook endpoint to a new API version safely?

Create a separate endpoint at the new version, test its signing secret and payloads, shadow both versions without duplicate side effects, compare normalized results, and keep rollback available. Disable the old endpoint only after monitoring and retry queues are clear.

Related Guides