OpenAI Webhook - securing

Hi everyone,

I hope someone can point me in the right direction because I have hit a wall with OpenAI webhook verification in Bubble.

What I am trying to do

  • Receive a webhook from the OpenAI Assistants API.
  • Verify the request with HMAC‑SHA256 so I am sure it really came from OpenAI.
  • Continue the backend workflow only when the signature is valid.

Current setup

  • Bubble backend workflow exposed as a public endpoint.
  • Endpoint set to Manual definition so I can capture the request body as raw text (raw_body).
  • X-Hook-Signature (or webhook-signature) header provided by OpenAI.
  • Webhook secret copied from the OpenAI dashboard (whsec_xxx...).
  • Server‑side action plugin written in JavaScript:

javascript

KopiërenBewerken

function(properties, context) {
  const crypto = require('crypto');
  const body      = properties.raw_body || "";
  const secret    = properties.secret   || "";
  const signature = context.headers["webhook-signature"] || "";

  // Bubble supposedly signs only the body, no timestamp
  const computed = crypto
    .createHmac('sha256', secret)
    .update(body, 'utf8')
    .digest('base64');

  return { verified: computed === signature.split(',')[1] };
}

The action returns a simple yes/no field verified.

The problem

No matter what I try verified is always false. Things I have tested:

  1. Secret – copied and pasted again, even rotated it once.
  2. Payload – tried hashing
  • only raw_body
  • timestamp + "." + raw_body (Standard Webhooks spec)
  • the entire Bubble envelope { body: {...}, headers: {...} }
  1. Base‑64 vs hex digests.
  2. Removing the whsec_ prefix just in case.

The header signature never matches the computed value.

Sample data

Secret (from OpenAI): whsec_XXXX

Header webhook-signature: v1,XXXX

Header webhook-timestamp: 1753789340

Raw body captured by Bubble (compact JSON):

json

KopiërenBewerken

{"id":"evt_6888b39ca814819099bdb15bbcfac96b","object":"event","created_at":1753789340,"type":"response.completed","data":{"id":"resp_abc123"}}

My questions

  1. Does Bubble actually alter the body before I see it in raw_body?
  2. Does OpenAI sign something different from raw_body when it sends the request to Bubble?
  3. If anyone has a working verification flow in Bubble, could you share the exact steps or a code snippet?
  4. Is a proxy (Cloudflare Worker, Lambda) the only reliable fix?

Any hint, checklist or example would be greatly appreciated. I am at the point where I suspect Bubble never gives me the byte‑perfect body that OpenAI used to compute the signature, but I hope I am missing something obvious.

NB.

It is highly unlikely that someone will actually post something in the API and I can stop any workflow if the resp_ id is not matching something in the database nevertheless, if something can be secured it should..

Thanks in advance!

Yes, there is at least one plugin that fixes this (but in the context of Stripe).

You can also use an intermediary (hookdeck etc.)

Thanks!
I’ll be testing some existing HMAC plugins as well. As soon as i’ve figured out what works i’ll try to rember to post something here as likely this is something many will bump into (at least if they are trying to secure the setup)

What is the API call and the event that triggers the webhook?

OpenAI has a background mode. Especially convenient when operating more complicated o3 requests in the backend (no sight on when they time-out) When you use this you can choose a variety of things. like polling the server, but openAI now also allows to connect a webhook (on a Project) so they will send a post that a response is ready (so you can pick up the response via the GET API call).

Funny thing now is that i got a work around, the “text” object (containg the JSON-SCHEMA) is actually a Text string with escaped JSON… complete nightmare to parse!

Anyone have a proper solution for that? RIght now the workflow even won’t save the raw text as it is so messed up :slight_smile:

@DRRR I’d suggest to use Hookdeck. You’ll probably just use the free plan for now. Bubble simply changes the response that is sent through the endpoint, so it’s nearly impossible to get it right, let alone making sure it stays working.

Developer

Always $0 /month

Features

  • Up to 10,000 events
  • 3-day retention
  • 1 user
  • SOC2

You don’t need webhooks for this, just poll using “schedule backend workflow” and a parameter that includes the response ID. It’s not “correct” but it doesn’t matter, works fine. You can use an exponential backoff built around the estimated time of the return call.

However, if you are dead set on using webhooks, you could include metadata with a large randomly generated key and then look for it on the return.

Thanks, I have been polling in the past as well (sort of when using the openai v2 assistant API to GET run) but I like the idea of a webhook a bit better. Also overtime it may be interesting to perform some batch analysis overnight. Nevertheless I think i’m working on a theoretic risk on the odd chance that someone/thing would a. find the api b. thinks about using it c. intercepts a resp key d. submits data that is actually mathching the key values in the subsequent json parsers..

Not too fond of adding additional tech dependencies into the setup. By the time I sort of figure out the security and setup it is outdated :slight_smile:

TO deal with the openAI json text output (in responses) which is text and not proper json, using plugins works quite nicely.

Input is API Calls output.firts-item.content.firtsitem.text

output is whatever key/value you want to extract (in the example below x, y, z respectively strings or arrays of strings. Below the message (pimped a bit by the AI for readability):

If you’re calling the OpenAI API and find that the model’s output—though wrapped in JSON—is actually delivered as a quoted string, here’s a minimal, fully-anonymized example showing how to detect, unquote, and parse it into structured fields x, y, z, etc.

javascript

function(a, b) {
  // 1. Grab & trim raw input
  var u = (a.i || "").trim();
  if (!u) throw new Error("Input is empty.");

  // 2. If it’s a quoted JSON string, unquote it
  if ((u.startsWith('"') && u.endsWith('"')) ||
      (u.startsWith("'") && u.endsWith("'"))) {
    try {
      u = JSON.parse(u);
    } catch (e) {
      throw new Error("Failed to decode quoted JSON: " + e.message);
    }
  }

  // 3. Find the JSON bounds
  var p = u.indexOf("{");
  var q = u.lastIndexOf("}") + 1;
  if (p < 0 || q < 1) {
    throw new Error("No JSON object found.");
  }
  var j = u.substring(p, q);

  // 4. Parse the JSON payload
  var y;
  try {
    y = JSON.parse(j);
  } catch (e) {
    throw new Error("JSON parse error: " + e.message);
  }

  // 5. Helper: flatten [{t, d}, …] → ["t: d", …]
  function flatten(arr) {
    if (!Array.isArray(arr)) return [];
    return arr.map(item => {
      if (typeof item === "string") return item;
      var t = item && item.t ? item.t : "";
      var d = item && item.d ? item.d : "";
      return t + (t && d ? ": " : "") + d;
    });
  }

  // 6. Safely extract arrays or default to []
  var x = Array.isArray(y.xList) ? y.xList : [];
  var z = Array.isArray(y.zList) ? y.zList : [];
  var w = flatten(y.wList);

  // 7. Return anonymized, structured output
  return {
    x:                      Array.isArray(y.xList) ? y.xList : [],
    y:                      y.yValue || "",
    z:                      Array.isArray(y.zList) ? y.zList : [],
    w:                      w
  };
}

How it works

  1. Trim and validate
  • Reads the raw payload (a.i) and ensures it isn’t empty.
  1. Unquote if needed
  • Detects leading/trailing quotes and runs JSON.parse once to unescape.
  1. Locate JSON substring
  • Finds the first { and last }, then slices out the JSON object.
  1. Parse into an object
  • Safely parses the extracted substring, throwing on errors.
  1. Normalize nested arrays
  • Provides a helper to flatten arrays of {t, d} into "t: d" strings.
  1. Anonymized fields
  • Pulls out xList, yValue, zList, and wList into generic outputs x, y, z, and w.
  1. Structured return
  • Returns a clean object with only the fields you need, defaulting to empty arrays or strings.