Stripe webhooks are the bridge between a subscription that changes in Stripe and paid feature access inside a browser extension. The reliable design is straightforward: verify the raw request, store each event once, derive an entitlement on the server, and let the extension read that entitlement through an authenticated API. The webhook is not a license token, and the extension is not the billing system.
If you are still deciding how checkout, customer identity, and a Manifest V3 extension fit together, start with the Stripe payments browser extension guide. This article focuses on the extension entitlement backend that begins after checkout exists.
TL;DR
- Verify Stripe's signature against the unchanged raw request body before parsing the event.
- Make processing idempotent and order-independent because Stripe can retry delivery and does not guarantee event order.
- Store a server-side entitlement record keyed to your authenticated user, not to an extension-local flag.
- Reconcile from Stripe when events are missed, ambiguous, or inconsistent with the current subscription.
- Let the extension refresh state through an authenticated read and persist only a cache in
chrome.storage.
Before You Begin
You need a Stripe account with a test-mode webhook endpoint, a backend that can receive HTTPS requests, and a user identity that your extension can present to that backend. You should be comfortable with HTTP handlers, JSON, authentication, and basic database transactions.
What you will build:
- A raw-body webhook route at a server URL such as
/api/stripe/webhook. - A durable event table with a unique Stripe event ID.
- An entitlement record for each application user.
- An authenticated endpoint such as
/api/me/entitlements. - A Manifest V3 service worker that refreshes and stores the latest response.
Use Stripe's subscription webhook documentation and the Stripe Dashboard Workbench to select the events your endpoint needs. This is an intermediate implementation. Its difficulty is mostly in state transitions and failure handling, not in the HTTP route itself.
Step 1: Verify the Raw Stripe Webhook Request
By the end of this step, your endpoint will reject requests that cannot be verified as Stripe events. This is the first boundary because every later decision depends on trusting the event.
Stripe signs the exact request payload. Parse or reserialize JSON before verification and the signature can fail. Stripe's signature troubleshooting documentation specifically requires the request body string, the Stripe-Signature header, and the endpoint secret.
- Create a webhook route that receives the body as bytes or as the original UTF-8 string.
- Read the
Stripe-Signatureheader without modifying it. - Pass the raw body, header, and the endpoint's
whsec_...secret to your Stripe SDK's webhook construction function. - Return a client error for an invalid signature. Do not enqueue or persist an unverified event.
- Keep the endpoint secret in server-side configuration. It must never be bundled into the extension.
In Node.js with an Express-style handler, the important ordering is that the webhook route runs before the general JSON parser:
app.post("/api/stripe/webhook", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"], process.env.STRIPE_WEBHOOK_SECRET);
} catch {
return res.status(400).send("Invalid webhook signature");
}
await enqueueVerifiedStripeEvent(event);
return res.sendStatus(200);
});
app.use(express.json());
Verification means authenticity, not correctness of your business policy. A valid event can still represent a failed payment, a canceled subscription, or a customer you cannot map to an application user. Those decisions belong in the next steps.
Step 2: Deduplicate Events Before Changing Access
By the end of this step, retrying the same Stripe event will not grant, revoke, or otherwise mutate access twice. Stripe expects a successful response from your endpoint and can retry unsuccessful deliveries, so the event ID must be a durable idempotency key.
Create an stripe_events table with fields similar to these:
stripe_events(
stripe_event_id text primary key,
event_type text not null,
received_at timestamp not null,
processed_at timestamp null,
status text not null,
payload jsonb not null
)
Then use one transaction or an equivalent atomic insert:
- Insert the event ID with status
received. - If the unique constraint says it already exists, acknowledge the request and stop.
- Process the event and update the row to
processed. - If processing fails, record
failedwith enough diagnostic context to retry safely.
A minimal language-neutral shape is:
event = verify_signature(raw_body, stripe_signature)
inserted = insert_event_if_absent(event.id, event.type, event.data)
if not inserted:
return HTTP 200
apply_entitlement_policy(event)
mark_event_processed(event.id)
return HTTP 200
Stripe also documents idempotent requests, which protect your outbound API operations when your server retries a request. Webhook deduplication and API idempotency solve related but different problems: the first prevents duplicate event application, while the second makes your own repeated Stripe API request safe.
Step 3: Model Entitlements Separately From Subscription Events
By the end of this step, your backend will have a small access model that can answer whether a user may use a paid feature without asking the extension to interpret Stripe objects.
Do not make the popup inspect customer.subscription.updated and decide access locally. Store normalized application state. A useful minimum schema is:
extension_entitlements(
user_id text primary key,
stripe_customer_id text unique not null,
stripe_subscription_id text null,
plan_key text null,
status text not null,
feature_keys jsonb not null,
current_period_end timestamp null,
source_event_id text not null,
updated_at timestamp not null
)
Keep billing identity and application identity linked explicitly. At checkout, record your internal user ID in Stripe metadata or in a server-side checkout session mapping. Never trust a user ID supplied by the extension to choose another user's entitlement.
Define the policy in plain terms before coding. For example:
activeandtrialingmay receive the plan's features.past_duemay remain active only if your product policy allows a grace period.incompleteandincomplete_expireddo not receive paid access by default.canceled,unpaid,paused, and deleted subscriptions do not receive recurring access unless your policy says otherwise.
Stripe's subscription event reference lists events such as customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed, and entitlements.active_entitlement_summary.updated. Use the event that tells you something changed, then derive access from the latest relevant object. Avoid spreading plan logic across individual webhook branches.
This separation also supports Stripe Customer Portal access for extension users later. The portal can manage billing while your entitlement table remains the single application-facing access record.
Step 4: Handle Ordering, Retries, and Revocation
By the end of this step, an old or late event cannot silently restore access after a newer cancellation, and a temporary endpoint failure has a recovery path.
Stripe does not guarantee event delivery order. A new subscription may produce several related events, and your endpoint might receive an invoice event before a subscription event. Design each handler to tolerate missing related rows and retrieve the current Stripe object when necessary.
Use these rules:
- Treat the event as a signal, not an unquestionable snapshot of your final state.
- For subscription changes, retrieve the subscription when the event payload is insufficient or when the event is older than the state you already applied.
- Store a source event ID and the event or object timestamp used for the update.
- Refuse to apply a stale transition when your comparison proves it is older than the current record.
- Make revocation explicit. A
deleted,unpaid, or policy-defined failed state must remove the affected feature keys, not merely stop future grants.
Stripe's webhook retry guidance explains that failed deliveries are retried and that undelivered events can be processed manually. Return a 2xx response after durable acceptance, not after a long chain of unrelated work. If processing is asynchronous, the queue record must already contain the verified event and your worker must be idempotent too.
For a subscription webhook entitlement flow, the safe shape is:
receive -> verify -> persist once -> acknowledge
|
v
retrieve current state if needed
|
v
apply grant or revocation policy
If your integration supports multiple subscriptions or multiple products, calculate the user's feature set from all applicable sources. A single canceled subscription should not revoke a feature still granted by another valid subscription unless that is your documented policy. Conversely, deleting one entitlement should not leave a stale cached feature enabled forever.
Step 5: Add Reconciliation and a Safe Recovery Path
By the end of this step, you can repair entitlement drift caused by downtime, a code bug, an unhandled event type, or a failed downstream database operation.
Webhooks are near-real-time notifications, not a replacement for reconciliation. Add a protected server job or admin action that takes an internal user ID, loads the linked Stripe customer and subscription records, and recomputes the entitlement from current Stripe state. You can also run a bounded scan for records whose updated_at is old, whose source event failed, or whose status needs confirmation.
A reconciliation routine should:
- Load the internal user-to-customer mapping.
- Retrieve the relevant subscriptions or active entitlements from Stripe.
- Apply the same pure policy function used by webhook processing.
- Upsert the entitlement record with a reconciliation marker.
- Record why the repair occurred and who or what initiated it.
Keep the policy function independent from HTTP and queue code:
function entitlementFromStripe({ subscription, activeFeatures }) {
const allowed = ["active", "trialing"].includes(subscription?.status);
return {
status: subscription?.status ?? "none",
featureKeys: allowed ? activeFeatures : [],
currentPeriodEnd: allowed ? subscription.current_period_end : null,
};
}
The example is intentionally small. Your real policy may include grace periods, one-time purchases, team seats, or product-specific feature keys. Keep those rules in one place so webhook handling and reconciliation cannot disagree.
You can also use Stripe's event retrieval and resend workflow during recovery. Manual resends do not replace deduplication. The same event may arrive again, and your unique event ID must make that harmless.
If duplicate subscriptions are a concern during checkout, see how to prevent duplicate subscriptions in extensions. Preventing duplicates upstream makes entitlement calculation simpler, but it does not remove the need for reconciliation.
Step 6: Serve Authenticated Entitlements to the Extension
By the end of this step, the extension can refresh paid feature access without receiving Stripe secrets or trusting local storage as the source of truth.
Expose a backend endpoint that derives the user from the authenticated session or token:
app.get("/api/me/entitlements", requireExtensionAuth, async (req, res) => {
const entitlement = await db.extensionEntitlements.findUnique({
where: { userId: req.user.id },
});
res.json({
status: entitlement?.status ?? "none",
features: entitlement?.featureKeys ?? [],
expiresAt: entitlement?.currentPeriodEnd ?? null,
});
});
The extension should call this endpoint through the service worker or another trusted extension context, not from an arbitrary content script with an identity it cannot protect. Validate the token, issuer, audience, expiration, and user mapping on the server. Use HTTPS and return the least information needed for feature gating.
Manifest V3 service workers are short-lived. Chrome says extension service workers can be terminated and later revived, and recommends persistent extension storage instead of global variables (Chrome service worker lifecycle). Store a cache with a timestamp, but refresh it when the worker starts, after authentication changes, when a paid feature is requested, and on a reasonable bounded schedule.
async function refreshEntitlements() {
const response = await fetch("https://api.example.com/api/me/entitlements", {
credentials: "include",
});
if (!response.ok) throw new Error("Entitlement refresh failed");
const value = await response.json();
await chrome.storage.local.set({ entitlements: value, entitlementsAt: Date.now() });
return value;
}
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type !== "refresh-entitlements") return;
refreshEntitlements()
.then((value) => sendResponse({ ok: true, value }))
.catch(() => sendResponse({ ok: false }));
return true;
});
The cache improves user experience while the backend remains authoritative. On a failed refresh, do not silently turn a known revoked account back on. Use the last state only according to your product's offline policy, and make that policy conservative for sensitive paid operations. Chrome's storage documentation also notes that extension storage is shared across extension contexts, while content scripts use the host page's storage, so use the extension's service worker or extension pages for this cache.
Common Mistakes to Avoid
Parsing JSON before signature verification. Middleware that changes whitespace, encoding, or key order can invalidate the signature. Keep the webhook route on the raw body path.
Granting access from checkout.session.completed alone. A completed checkout is useful context, but recurring access can later change. Recompute access from subscription and entitlement lifecycle events.
Using the customer's email as the primary key. Emails can change and may not uniquely identify your application's account. Store a deliberate internal-user-to-Stripe-customer mapping.
Assuming one event equals one final state. Event delivery can be delayed or reordered. Retrieve current objects and compare state before applying a stale transition.
Returning success only after every side effect finishes. Slow email, analytics, or extension-notification work can cause avoidable retries. Persist the verified event, acknowledge it, and process durable work separately.
Trusting chrome.storage as authorization. Local extension state can be stale. Use it as a cache and ask the authenticated backend before granting operations that require current paid access.
Forgetting revocation. A grant-only implementation creates permanent access after cancellation or failed payment. Every policy-defined loss-of-access state must clear feature keys and invalidate relevant caches.
What Success Looks Like
If everything is working, a test subscription produces a verified event row, one entitlement update, and an authenticated extension response containing the expected feature keys. Resending the same event leaves the entitlement unchanged. Delivering a cancellation or policy-defined payment failure removes access after the backend applies the event. A delayed or reordered event does not restore an older state.
Your next improvement can be a small operational dashboard showing failed webhook rows, processing age, last reconciliation, and users whose Stripe state cannot be mapped. For the paid feature UI itself, continue with how to add a subscription paywall to a Chrome extension.
Frequently Asked Questions
Do I need both subscription events and Stripe entitlements events?
Not always. Stripe's active entitlement summary event can be a useful source for feature provisioning, while subscription and invoice events explain billing lifecycle and payment state. Choose a minimal event set, document the policy, and reconcile from Stripe when the selected event does not provide enough current state.
Should the webhook call the extension directly?
No. Browser instances can be closed, service workers can be asleep, and extension clients are not reliable payment-system endpoints. Update the backend first. The extension should pull its entitlement through authenticated reads, or use a short-lived notification only as a prompt to refresh.
How quickly should revoked access disappear?
Define the interval in your product policy. For ordinary UI features, a short cache can be acceptable. For valuable or sensitive operations, require a fresh backend decision. Do not claim instant revocation unless every paid operation checks a current server-side state.
Where should testing cover the edge cases?
Test invalid signatures, duplicate event delivery, out-of-order events, canceled subscriptions, failed payments, missing customer mappings, worker restarts, expired authentication, and a reconciliation repair. See testing Stripe payments in a browser extension for a broader test plan.
Conclusion
Reliable Stripe webhooks extension entitlements come from a boring division of responsibility: Stripe reports billing changes, the backend verifies and normalizes them, and the Manifest V3 extension reads an authenticated entitlement cache. Add deduplication, order-independent processing, explicit revocation, and reconciliation before polishing the paywall. When the product grows into a platform with connected sellers, review Stripe Connect for extension platforms without changing the core rule: paid access belongs to a server-side policy, not to a client-side flag.
