Docs

Webhooks

A webhook is a URL you own that Bloom posts to when something happens in your workspace, so you find out without polling for it. Made from a workspace's Webhooks page, up to the number your plan allows.

Events

Every event Bloom can send. Some are registered but not sent yet, marked below, because they depend on a checkout Bloom has not opened.

EventGroupFires when
license.issuedLicencesSomebody got a key: you issued it, the API did, or a sale did.
license.validatedLicencesSoftware started up and its key was accepted. The busiest event by far.
license.refusedLicencesA key was rejected, expired, revoked, on a machine too many, or blacklisted.
license.suspendedLicencesPaused, by you or by a rule. It can be turned back on.
license.revokedLicencesEnded for good. The key will never validate again.
license.expiredLicencesIts time was up rather than taken away. Usually where a renewal belongs.
store.viewedBuyersA visitor read one of your listings in the store.
checkout.clickedBuyersThey clicked Buy on a package and were sent to your Discord to open a ticket.
release.downloadedBuyersSomebody followed the download link on one of your releases.
product.publishedProductsIt went live in the store, or came back after being unpublished.
release.publishedProductsA new version was published for a product.
store.approvedProductsBloom reviewed the listing and it is now visible in the public store.
plan.startedMoneySomebody put this workspace on a paid plan for the first time.
plan.renewedMoneyThe monthly charge went through and the plan runs for another month.
plan.endingMoneyCancelled, or the card stopped working. The plan runs to the end of what was paid for.
plan.expiredMoneyThe date passed and the workspace is on Free again. Nothing in it was removed.
plan.reminderMoneySomebody was emailed that this workspace’s plan ends soon.
member.signed_inThe workspaceA member of this workspace signed in.

Registered, not sent yet

  • payment.started A buyer reached checkout, not yet paid. Nothing emits this until Bloom takes payments.
  • payment.completed Paid and cleared, with the licence issued off the back of it. Waiting on Bloom’s checkout.

Payload shape

Every delivery is a POST with a JSON body shaped the same way regardless of event:

json
{
  "event": "license.issued",
  "sentAt": "2026-01-01T00:00:00.000Z",
  "data": { /* event-specific fields */ }
}

Headers on every delivery: X-Bloom-Event (the event name, matching the body) and X-Bloom-Signature (the signature, see below).

Verifying the signature

X-Bloom-Signature is sha256=<hex>, an HMAC-SHA256 of the exact raw request body using the webhook's own secret (shown once when you view the webhook's secret in the dashboard, prefixed whsec_). Compare it in constant time, not with ===, the same way Bloom's own code does.

javascript
const crypto = require('node:crypto');

function isFromBloom(rawBody, signatureHeader, secret) {
  // Header looks like "sha256=<hex>".
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader || '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// In your route handler, before you parse the body as JSON:
if (!isFromBloom(rawBody, req.headers['x-bloom-signature'], webhookSecret)) {
  res.statusCode = 401;
  return res.end('bad signature');
}

Retries and failure

A delivery is not retried on failure. Consecutive failures are counted, and after 10 in a row the webhook is switched off automatically; turning it back on from the dashboard clears the count. Redirects are never followed: an endpoint that answers 3xx is treated as a failure.

Where a URL can point

A webhook URL must resolve to a public address, both when it is saved and again every time it is sent to. Private and internal addresses are refused, so a URL that answers publicly today cannot be pointed at something inside Bloom's own network tomorrow.