Could usernames be included in user_badge webhook payload?

Any chance the user_badge webhooks could include username along with user_id? Currently the payload for a user_badge event looks like this:

{
  "user_badge": {
    "id": 123456,
    "granted_at": "2026-09-25T14:54:42.073Z",
    "created_at": "2026-09-26T05:34:12.965Z",
    "post_id": 234567,
    "post_number": 1,
    "badge_id": 123,
    "user_id": 456,
    "granted_by_id": -1,
    "topic_id": 56789
  }
}

Getting the username for the user_id (and granted_by_id if it’s not -1) requires an additional call to /admin/users/{user_id}.json, which not only requires additional requests, but also requires admin privileges as far as I can tell.

Is there a way to get username for a given a user_id using an API key without needing admin privileges?

I’m creating an external service that I was hoping to trigger when specific badge(s) are granted; however, it needs the username and badge webhooks only returns user IDs. :confused:

FWIW, my workaround for non-standard webhooks or when I need additional data is to do it via Workflows and then send that to n8n.

Fwiw, fetching username from user id is just one API call away, unless you’re manually granting thousands of badges in an instant, you should be fine with the default API limits.

Thanks! I’ll look into Workflows.

That’s great to hear! Could you please provide details about that one API call that isn’t /admin/users/{user_id}.json (which requires admin privileges)? I wasn’t able to find an endpoint to do this with an API key that didn’t require admin privileges.

What’s the issue with an admin API key? This is supposed to be a background job ig?

Principal of Least Privilege. This external service will be running on another server and only needs read access to Discourse to do it’s job. The fewer master keys floating around across infrastructure, the better.

This brings up the interesting point of why isn’t there a public-facing endpoint to get a user by id that shows only public fields, if login_required is not selected. It would be useful, no? I wonder why it was not made. Or maybe only logged in users could get such a privilege, but either way, there should be some way to access it without the use of the admin endpoint…

@burke A more onerous workaround would be to look at /groups/trust_level_0/members.json (or any more specific group) and find the user by their id, since that also contains the username.

This is because for all matters and purposes user IDs are essentially an internal convention in discourse and we are expected to rely on usernames that are much more prominent across the board.

Which brings us back to the original request. The webhook should adhere to that concept and send the username, not the user ID.

Agreed! This seems like the best solution. Adding username and granted_by_username and deprecating user_id and granted_by_id could allow for a non-breaking transition period.

Currently, if a badge is changed by the system (i.e., automatically), the webhook returns a granted_by_id of -1. What would be the equivalent for granted_by_username?

  • "" (empty string)
  • "<system>" (a magic value that couldn’t be a username)
  • null
  • Nothing – i.e., omit granted_by_username from the payload if it was a system-driven event

I would assume null or nothing would make the most sense.

-1 is actually a valid user id that does have a username (usually system) associated with it, just like any other user. There is no reason to treat it differently.

Here on meta https://meta.discourse.org/u/system/summary

Oh. Thanks for teaching me that. So, presumably the granted_by_username would then just be "system" for automated changes.

@putty I’ve been looking into Workflows. Until/unless the badge events include usernames (and maybe regardless, since I can use filters in Workflows to only POST the changes my service needs to hear), this looks like a great alternative option! Thanks again!

With webhooks, there’s a X-Discourse-Event-Signature with an HMAC-SHA256 signature of the payload using a secret that allows me to verify the sender. Any chance there’s a way to sign HTTP requests (POSTs) from Workflows? Maybe I just haven’t found it yet. I was hoping there’d be a setting like “Sign request using secret” that I could toggle on & provide a secret. The closest I’ve seen is adding a header_auth credential to send a secret in a header (e.g., X-Discourse-Event-Secret: Discourse Rocks!). That’s better than nothing, but a signature using a secret (not sending the secret itself) would be preferable.

Unfortunately I’ve relied on sending the secret via a header as well. Perhaps @j.jaffeux can suggest a better alternative, which I’ll promptly utilize as well :sweat_smile:

I created a code step to calculate a signature. There’s a Web Crypto API that can calculate an HMAC-SHA256 much more easily, but it uses a promise and that won’t work inside an eval. So, I asked AI to whip up a native javascript method to calculate HMAC-SHA256, added a variable with the key secret and the secret as its value (, and ended up with a Code script like this:

// Available variables:
//   $input.item.json       - current item's JSON data
//   $input.all()           - array of all input items
//   $json                  - shortcut for $input.item.json
//   $("NodeName").item     - access the paired item from another node
//   $vars.KEY              - workflow variables
//   $site_settings.NAME    - site settings
//   $execution             - execution metadata (id, workflow_name, ...)
//   $current_user          - user running the workflow
//   $helpers.absoluteUrl(path) - convert a relative path to an absolute URL
//   console.log/warn/error - logging

function hmacSha256(message, key) {
  function utf8Bytes(str) {
    const bytes = [];

    for (let i = 0; i < str.length; i++) {
      let code = str.charCodeAt(i);

      // Handle surrogate pairs
      if (
        code >= 0xd800 &&
        code <= 0xdbff &&
        i + 1 < str.length
      ) {
        const next = str.charCodeAt(i + 1);

        if (next >= 0xdc00 && next <= 0xdfff) {
          code =
            0x10000 +
            ((code - 0xd800) << 10) +
            (next - 0xdc00);
          i++;
        }
      }

      if (code <= 0x7f) {
        bytes.push(code);
      } else if (code <= 0x7ff) {
        bytes.push(
          0xc0 | (code >> 6),
          0x80 | (code & 0x3f)
        );
      } else if (code <= 0xffff) {
        bytes.push(
          0xe0 | (code >> 12),
          0x80 | ((code >> 6) & 0x3f),
          0x80 | (code & 0x3f)
        );
      } else {
        bytes.push(
          0xf0 | (code >> 18),
          0x80 | ((code >> 12) & 0x3f),
          0x80 | ((code >> 6) & 0x3f),
          0x80 | (code & 0x3f)
        );
      }
    }

    return bytes;
  }

  function rotr(x, n) {
    return (x >>> n) | (x << (32 - n));
  }

  function sha256(bytes) {
    const K = [
      0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
      0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
      0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
      0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
      0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
      0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
      0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
      0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
      0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
      0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
      0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
      0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
      0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
      0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
      0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
      0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
    ];

    const H = [
      0x6a09e667,
      0xbb67ae85,
      0x3c6ef372,
      0xa54ff53a,
      0x510e527f,
      0x9b05688c,
      0x1f83d9ab,
      0x5be0cd19
    ];

    const data = bytes.slice();
    const bitLength = data.length * 8;

    // SHA-256 padding
    data.push(0x80);

    while ((data.length % 64) !== 56) {
      data.push(0);
    }

    // Append 64-bit big-endian message length.
    // JS integers are safe here for normal workflow payload sizes.
    const high = Math.floor(bitLength / 0x100000000);
    const low = bitLength >>> 0;

    data.push(
      (high >>> 24) & 0xff,
      (high >>> 16) & 0xff,
      (high >>> 8) & 0xff,
      high & 0xff,
      (low >>> 24) & 0xff,
      (low >>> 16) & 0xff,
      (low >>> 8) & 0xff,
      low & 0xff
    );

    const w = new Array(64);

    for (let offset = 0; offset < data.length; offset += 64) {
      for (let i = 0; i < 16; i++) {
        const j = offset + i * 4;

        w[i] =
          ((data[j] << 24) |
            (data[j + 1] << 16) |
            (data[j + 2] << 8) |
            data[j + 3]) >>> 0;
      }

      for (let i = 16; i < 64; i++) {
        const s0 =
          rotr(w[i - 15], 7) ^
          rotr(w[i - 15], 18) ^
          (w[i - 15] >>> 3);

        const s1 =
          rotr(w[i - 2], 17) ^
          rotr(w[i - 2], 19) ^
          (w[i - 2] >>> 10);

        w[i] =
          (
            w[i - 16] +
            s0 +
            w[i - 7] +
            s1
          ) >>> 0;
      }

      let a = H[0];
      let b = H[1];
      let c = H[2];
      let d = H[3];
      let e = H[4];
      let f = H[5];
      let g = H[6];
      let h = H[7];

      for (let i = 0; i < 64; i++) {
        const S1 =
          rotr(e, 6) ^
          rotr(e, 11) ^
          rotr(e, 25);

        const ch = (e & f) ^ (~e & g);

        const temp1 =
          (
            h +
            S1 +
            ch +
            K[i] +
            w[i]
          ) >>> 0;

        const S0 =
          rotr(a, 2) ^
          rotr(a, 13) ^
          rotr(a, 22);

        const maj =
          (a & b) ^
          (a & c) ^
          (b & c);

        const temp2 = (S0 + maj) >>> 0;

        h = g;
        g = f;
        f = e;
        e = (d + temp1) >>> 0;
        d = c;
        c = b;
        b = a;
        a = (temp1 + temp2) >>> 0;
      }

      H[0] = (H[0] + a) >>> 0;
      H[1] = (H[1] + b) >>> 0;
      H[2] = (H[2] + c) >>> 0;
      H[3] = (H[3] + d) >>> 0;
      H[4] = (H[4] + e) >>> 0;
      H[5] = (H[5] + f) >>> 0;
      H[6] = (H[6] + g) >>> 0;
      H[7] = (H[7] + h) >>> 0;
    }

    const result = [];

    for (let i = 0; i < H.length; i++) {
      result.push(
        (H[i] >>> 24) & 0xff,
        (H[i] >>> 16) & 0xff,
        (H[i] >>> 8) & 0xff,
        H[i] & 0xff
      );
    }

    return result;
  }

  function bytesToHex(bytes) {
    return bytes
      .map(b => b.toString(16).padStart(2, "0"))
      .join("");
  }

  const blockSize = 64;

  let keyBytes = utf8Bytes(key);
  const messageBytes = utf8Bytes(message);

  // Keys longer than the SHA-256 block size are first hashed.
  if (keyBytes.length > blockSize) {
    keyBytes = sha256(keyBytes);
  }

  // Pad key to 64 bytes.
  while (keyBytes.length < blockSize) {
    keyBytes.push(0);
  }

  const innerPad = new Array(blockSize);
  const outerPad = new Array(blockSize);

  for (let i = 0; i < blockSize; i++) {
    innerPad[i] = keyBytes[i] ^ 0x36;
    outerPad[i] = keyBytes[i] ^ 0x5c;
  }

  const innerHash = sha256(
    innerPad.concat(messageBytes)
  );

  const hmac = sha256(
    outerPad.concat(innerHash)
  );

  return bytesToHex(hmac);
}

const payload = JSON.stringify({
    "the-json-payload": "goes-here"
});

const signature = "sha256=" + hmacSha256( payload, $vars.secret);

return {
    "payload": payload,
    "signature": signature
};

This is followed by an HTTP request step with a header with key X-Discourse-Workflow-Signature and value {{ $("Code").item.json.signature }}, content type JSON, and request body {{ $("Code").item.json.payload }}.

The result is an HTTP request like:

User-Agent: Faraday v2.14.4
Content-Length: 41
Accept: */*
Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Content-Type: application/json
X-Discourse-Workflow-Signature: sha256=9ad2820b545013bdd18d4e7e6139624b91f576498e528201fa7759ec58dd598c

{"the-json-payload": "goes-here"}

The X-Discourse-Workflow-Secret is a HMAC-SHA256 signature using the secret argument’s value… just like a signed webhook! :slight_smile:

It may not be pretty, but it looks like it’s working.

Update: I prefixed the signature with sha256= to match the style of webhook signatures.

That looks pretty legit! Thanks for sharing back!

FWIW, I tested this native HMAC-SHA256 against the Web Crypto API to confirm they give the same results for random messages & keys and the signatures matched. I can’t guarantee there aren’t special cases where they diverge, but I’m satisfied enough to use it in production. :slightly_smiling_face: