Signature Verification, the Stripe Way
HMAC-SHA256 over the exact bytes Stripe signed, compared in constant time. Every v1 signature in the header is checked, not just the first, so legitimate events keep passing while you rotate the signing secret.
Webhook Sentinel (Stripe) is the webhook security layer Bubble was always missing. It verifies the authenticity of incoming Stripe webhook requests using cryptographic signature verification, ensuring that only legitimate, unmodified events from Stripe are processed by your workflows.
No data collected This plugin does not collect user data.
HMAC-SHA256 over the exact bytes Stripe signed, compared in constant time. Every v1 signature in the header is checked, not just the first, so legitimate events keep passing while you rotate the signing secret.
Stripe asks for two protections, not one: the signature proves the event was not written by a third party, and the allowlist refuses anything that did not come from Stripe servers. Comes pre-filled and off by default.
A timestamp window stops captured payloads from being reused, defaulting to the same five minutes Stripe libraries use. The environment check reads live or test from inside the signed payload, and follows your app instead of being switched by hand.
Eight failure causes, each one as a text code for your logs and as one yes/no for your workflow conditions. Exactly one boolean is true whenever verification fails, so a condition never has to compare text.
Event id, type, environment, creation time, age and the inner object id come back ready to use, so idempotency and reconciliation cost no extra parsing. Audit logging costs nothing at all: a log text field returns every value as one block, one per line, ready to store. They stay empty on rejection, because an unverified body is a body chosen by whoever sent it.
Everything runs server-side, so your signing secret never reaches the client. The action stores nothing, creates nothing and changes nothing: what happens after verification is entirely up to your workflow.
In detail
In Backend Workflows, create an API event, tick "This workflow can be run without authentication" and "Include headers in the detected data", and set the request data type to raw. Stripe needs the body exactly as it was sent.
In the Stripe Dashboard, under Developers and then Webhooks, add your Bubble endpoint URL and copy the signing secret. It starts with whsec_ and is different for test mode and live mode.
Add Stripe - Verify Webhook Signature as the first step. Fill the secret, the Stripe-Signature header, the raw body and the tolerance. Optionally turn on the livemode check to refuse events from the other environment.
Terminate the workflow when is valid is false. Use the yes/no per cause in the conditions and write the rejection reason text into your log. Everything after this step should only run for legitimate events.
Stripe redelivers events. Save the returned event id in a field with a unique constraint and stop when it already exists, so the same event is never processed twice.
A webhook is a notice, not a verdict. Use the inner object id to fetch the current object from the Stripe API and act on what comes back, because events can arrive out of order.
Use this action only in Backend Workflow events. The inputs must be passed exactly as they arrive: any parsing or reformatting of the raw body breaks the signature even when the request is legitimate.
The webhook signing secret associated with your Stripe Webhook. Find this in the Stripe Dashboard under Developers → Webhooks → Your endpoint → Signing secret. Always starts with "whsec_". Never expose this value in client-side code or logs.
defaultno default
The Stripe-Signature header sent by Stripe with every webhook request. In Bubble, use Event Request Data's headers stripe-signature.
defaultno default
The raw body string of the incoming request. In Bubble, use Event Request Data's raw body text. Must be the original unmodified body, any parsing or reformatting will cause the signature verification to fail, even if the request is legitimate.
defaultno default
Tolerance in seconds for validating the event timestamp. Events older than this value are rejected to prevent replay attacks. Defaults to 300 (5 minutes), which is the same tolerance Stripe's own libraries use. The minimum is 30 seconds and lower values are raised to it on purpose: Stripe documents a tolerance of 0 as a common mistake, because it turns the recency check off entirely. Increase only if your server has known clock sync issues, and prefer fixing the clock with NTP, which is what Stripe recommends.
default300
The 30 second floor is not arbitrary: Stripe lists a tolerance of 0 as a common mistake, because it turns the recency check off entirely. If events are being rejected as expired, fix the server clock with NTP rather than raising this value, which is also what Stripe recommends.
Turns on the livemode check. Off by default, and off means the environment is not verified at all. When it is on, [Accept events from the live version] must have a value, otherwise every event is rejected with rejection_reason "livemode_check_misconfigured". The environment is read from inside the signed payload, so it cannot be forged. This does not replace the separation the signing secrets already provide, since the test and live endpoints have different secrets: it is the net for when the same secret ends up configured in both environments.
defaultno
optionalThe plugin reads the environment from inside the signed event and compares it with this field. Yes accepts only live events, no accepts only test events, and a test version includes any development branch. Point it at Bubble's own Isn't live version with is "no" to build an environment check that compares the two modes, the event's and the app's, so the check follows your deploys. Pointing it at Isn't live version alone inverts the answer and rejects every real event. Only read when [Enable livemode check] is on.
defaultno default
optionalTurns on the IP allowlist check. Off by default, and off means this action behaves exactly as it did before: only the signature, the timestamp and the environment are verified. When it is on, both [IP allowlist] and [Request IP] must be filled, otherwise every event is rejected with rejection_reason "ip_check_misconfigured". That is deliberate: a security check you turned on and left unconfigured should fail loudly, not pretend to protect you.
defaultno
optionalThe IP addresses allowed to deliver webhooks, one per line or separated by commas. Comes pre-filled with the list Stripe published when this plugin version was released. That list is a snapshot, not a live feed: check https://stripe.com/files/ips/ips_webhooks.json before relying on it. Stripe changes these addresses over time, and an address they add that is missing here becomes a legitimate event rejected with "ip_not_allowed". Exact addresses only, CIDR ranges are not supported. Only read when [Enable IP check] is on.
default3.18.12.63
3.69.109.8
3.120.168.93
3.130.192.231
13.235.14.237
13.235.122.149
18.211.135.69
35.154.171.200
35.157.207.129
52.15.183.38
54.88.130.119
54.88.130.237
54.187.174.169
54.187.205.235
54.187.216.72
The field comes pre-filled with the list Stripe published when this plugin version was released. That is a snapshot, not a live feed: confirm the current list at https://stripe.com/files/ips/ips_webhooks.json, because an address Stripe adds that is missing here turns a legitimate event into a rejection.
The IP address the request came from. Use Request Data's header cf-connecting-ip, not x-forwarded-for. The difference matters: x-forwarded-for is written by whoever makes the request and can be forged, so an attacker can simply claim to be a Stripe address, while cf-connecting-ip is written by Cloudflare, which sits in front of Bubble, and is overwritten on every request. If a chained value is passed anyway, only the last address in the chain is checked, which is the one added by the infrastructure and not by the caller. Only read when [Enable IP check] is on.
defaultno default
optionalUse Request Data's header cf-connecting-ip, not x-forwarded-for. The second one is written by whoever makes the request, so an attacker can simply claim to be a Stripe address and pass this check; the first is written by Cloudflare, which sits in front of Bubble, and is overwritten on every request. If a chained value arrives anyway, only the last address in the chain is checked, which is the one the infrastructure added and not the one the caller invented. Stripe asks for this check and signature verification, not one or the other: they cover different attacks.
What the action hands back to the workflow that called it.
The event fields are filled in only when the signature is valid. On a rejection they come back empty, on purpose: an unverified body is not a source of data.
Yes when the signature matches, the event is within the tolerance window, and the environment check passed. No otherwise. Treat it as the gate: terminate the workflow when it is no, and do not process the event any further.
The cause of the rejection, as a stable text code. Empty when [is valid] is yes. This is the value to write into logs and audit records; for branching the workflow, use the yes/no below instead, because a typo in a text comparison never matches and never warns.
missing_inputsone or more required fields are empty. The action cannot proceed without a secret, a signature and a raw body.malformed_headerthe Stripe-Signature header is missing or has an unexpected format. The timestamp or the signature value could not be extracted.signature_mismatchthe computed signature does not match any signature received. The request did not come from Stripe, or the payload was modified in transit.timestamp_expiredthe event is older than the configured tolerance. The request may be a replay attack.livemode_check_misconfiguredthe livemode check is on but Accept events from the live version has no value, so there was nothing to compare the event against. The action refuses instead of guessing one of the two answers, because guessing would silently reject every event from the other environment.livemode_mismatchthe event is authentic, but it comes from the environment you chose to refuse in Accept events from the live version.ip_check_misconfiguredthe IP check is on but the allowlist or the request IP is empty, so there was nothing to check against. The action refuses instead of letting the event through, because a check you turned on and left unconfigured should fail loudly rather than quietly protect nothing.ip_not_allowedthe signature, the age and the environment all checked out, but the request came from an address that is not in your allowlist. The most common cause is not an attack: it is Stripe having added an address that your list does not have yet.Yes when the secret, the signature or the raw body arrived empty. Nothing was verified: this is a configuration problem in your own workflow, not a suspicious request, and it deserves an alert to the developer rather than a silent log line.
Yes when the Stripe-Signature header is missing or has an unexpected format, so the timestamp or the signature could not be extracted. Usually means the wrong header was mapped into the field.
Yes when no signature in the header matches the one computed from your secret and the body. Either the request did not come from Stripe, or the body was altered on the way, or the secret belongs to another endpoint.
Yes when the signature is authentic but the event is older than the tolerance window. The request may be a replayed copy of a legitimate event, or your server clock may be out of sync.
True when the livemode check is on but Accept events from the live version is empty. Like the IP one, this is not about the event: it means your own setup is incomplete, so treat it as an alert rather than something to log and move on from.
True when the event is authentic but comes from the environment you chose to refuse in Accept events from the live version. The environment is read from inside the signed payload, so it cannot be forged.
True when the IP check is on but IP allowlist or Request IP is empty, so there was nothing to check against. This one is not about the event: it means your own setup is incomplete and the check is protecting nothing, so treat it as an alert rather than something to log and move on from.
True when the event is authentic but came from an address that is not in IP allowlist. It is the only cause that can be true while the signature itself is valid, so it means the event is genuine and was refused on origin alone.
The environment the event was created in, live or test. It is read from inside the signed payload, so it cannot be forged. Useful in the audit line, where live or test reads better than a yes/no.
livethe event was created in live mode.testthe event was created in test mode.The Stripe event id, such as evt_1UMBru... This is the key to store so a redelivered event is never processed twice: Stripe retries delivery, and the same event arrives with the same id.
The event type, such as checkout.session.completed. Use it to route the workflow without parsing the body a second time.
When Stripe created the event, as a Unix timestamp in seconds. It is the envelope's own timestamp, which can be a few seconds later than the creation time of the object inside it.
How many seconds passed between the signature timestamp and the moment the action ran. The same number the tolerance check uses, exposed so your audit trail can record how late an accepted event was.
The id of the object carried by the event, such as cs_test_... Look it up in the Stripe API to confirm the current state at the source, instead of trusting what the payload says, because events can arrive out of order.
The type of the object carried by the event, such as checkout.session. It tells you which Stripe endpoint to call with [inner object id].
Every returned value above, as one text block, one per line, in the form name: value. Booleans come as yes and no, and empty values keep their line so two executions stay comparable. Built for storing an audit line or pasting into a support ticket without rebuilding the list by hand in a workflow. It carries only returned values: the signing secret, the signature and the raw body are never in it, so this text is safe to store.
It is a server-side action that proves an incoming webhook really came from Stripe. It recomputes the HMAC-SHA256 signature over the exact payload Stripe signed, compares it with the Stripe-Signature header, checks how old the event is, and returns the verdict along with the data of the verified event. It does nothing else: what happens after the verdict is your workflow's decision.
No. Bubble does not support native webhook signature verification. Without it, anyone who knows your endpoint URL can send a fake event and trigger your workflow. Signature verification is the only layer that cryptographically proves a request came from Stripe and that the payload was not modified in transit. Stripe recommends it as the primary security layer for webhook endpoints, and while other layers like event idempotency or environment isolation are valuable additions, none of them replace it.
Yes. The action checks the timestamp embedded in the Stripe-Signature header and rejects events older than the configured tolerance window. This prevents a captured valid payload from being reused after the window expires. The tolerance is configurable with a minimum floor of 30 seconds and a recommended default of 300 seconds (5 minutes) by Stripe.
Stripe asks for it alongside signature verification, because the two cover different attacks: the signature proves the event was not written or modified by a third party, and the allowlist refuses anything that did not come from Stripe servers in the first place. The check is off by default. When you turn it on, pass Request Data's header cf-connecting-ip, not x-forwarded-for: the second one is written by whoever makes the request and can be forged. The allowlist comes pre-filled with the list Stripe published when this plugin version was released, and it is a snapshot, not a live feed, so confirm the current list at https://stripe.com/files/ips/ips_webhooks.json before relying on it.
As the first step. If the signature is invalid or the timestamp is expired, terminate the workflow immediately, do not process the event further. Optionally, log the rejection reason before terminating to keep a record of invalid requests. Everything after the action should only run for legitimate events.
A stable text code identifying why the request was rejected. There are eight: missing_inputs, malformed_header, signature_mismatch, timestamp_expired, livemode_check_misconfigured, livemode_mismatch, ip_check_misconfigured and ip_not_allowed. It is the value to write into logs and audit records. For branching the workflow, use the yes/no returned for each cause instead, because a typo in a text comparison never matches and never warns. Exactly one of those booleans is true whenever verification fails.
Nothing breaks. While a rotation is in progress Stripe sends more than one signature in the same header, one per active secret. The action checks all of them, so events signed with either secret keep being accepted until you finish the rotation.
Because an unverified body is a body chosen by whoever sent it. Returning its event id would hand your workflow the exact value it uses to deduplicate and to write into the audit log, supplied by the sender. Event id, type, environment, creation time and the inner object id only come back when the signature checks out.
Two of them are now inside it. Stripe asks for signature verification and IP allowlisting, and both are available here, with the allowlist optional and off by default. The environment check, accepting only live or only test events, is also built in. What remains for your workflow is event idempotency, blocking an event id you already processed, minimum data validation, and a reconciliation job to catch events that never arrived. None of these replace signature verification; they build on top of it.
No. The action only verifies the signature and returns the result. It does not store any data, create any records, or produce any side effects. Any logging or record creation is entirely up to your workflow.
The frequency with which we update our plugins depends on several factors: 1 - If there are bugs (known or reported by users) that need to be fixed. 2 - If we receive requests for new features that we consider essential for users. 3 - If the plugin depends on a library and it has been updated with a stable version. 4 - If we decide to add more features to the plugin or make it easier to use. 5 - Whenever we determine that the code can be optimized for better performance. 6 - When plugin information, such as the logo, description, demo links, etc., needs to be updated. 7 - When Bubble makes changes to its terms or updates its API version.