Skip to main content

Service Webhook Receiver Guide

This guide explains what bbserver sends to your endpoint, how the secret is used, and what your receiver should validate.

What bbserver sends

When a service webhook fires, bbserver sends an HTTPS POST request with:

  • A JSON request body
  • Metadata headers describing the event and delivery
  • An HMAC-SHA256 signature derived from your webhook secret

The secret is not used to encrypt the request body. It is used only to sign the request so your receiver can verify authenticity and integrity.

Headers

Current outbound headers:

  • User-Agent: bbserver-service-webhook/1
  • X-BB-Event: certificate.issued
  • X-BB-Webhook-Id: <webhook_id>
  • X-BB-Delivery-Id: <delivery_id>
  • X-BB-Cert-Id: <cert_id>
  • X-BB-Issue-History-Id: <issue_history_id>
  • X-BB-Timestamp: <unix_ms>
  • X-BB-Signature: sha256=<hex_digest>

Body

Typical request body:

{
"event": "certificate.issued",
"cert_id": 123,
"issue_history_id": 456,
"user_id": 789,
"ts_ms": 1700000000000,
"domain_name": "example.com"
}

Field notes:

  • event: current event key
  • cert_id: certificate record id
  • issue_history_id: issuance history id for this successful issuance
  • user_id: owner of the webhook/certificate
  • ts_ms: enqueue-time timestamp in milliseconds
  • domain_name: primary domain if available; it may be omitted

How the secret is used

When bbserver sends the webhook, it computes:

signing_input = <X-BB-Timestamp> + "." + <raw_request_body>
signature = HMAC-SHA256(secret, signing_input)

Then bbserver sends the result as:

X-BB-Signature: sha256=<hex_digest>

That means:

  • The body is plain JSON over HTTPS
  • The secret is shared between bbserver and your receiver
  • Your receiver should recompute the HMAC and compare it

Receiver verification steps

Your endpoint should:

  1. Read the raw request body bytes exactly as received.
  2. Read X-BB-Timestamp.
  3. Read X-BB-Signature.
  4. Rebuild the signing input as <timestamp>.<raw_body>.
  5. Compute HMAC-SHA256(secret, signing_input).
  6. Hex-encode the digest and compare it against the signature value after the sha256= prefix.
  7. Reject stale timestamps, for example anything older than 5 minutes.

Important:

  • Do not parse and re-stringify JSON before verification.
  • Do not trim whitespace or normalize line endings before verification.
  • Use a constant-time comparison when checking the signature.

Minimal receiver behavior

If you only want a safe baseline:

  • Verify the signature
  • Verify the timestamp window
  • Check event === "certificate.issued"
  • Queue the real work asynchronously
  • Return 204 quickly

Node.js example

The example below uses Express and verifies the signature against the raw request body before parsing JSON.

const crypto = require('crypto');
const express = require('express');

const app = express();
const webhookSecret = process.env.BB_WEBHOOK_SECRET;

app.post('/bb/webhook', express.raw({ type: '*/*' }), (req, res) => {
const timestamp = req.header('x-bb-timestamp');
const signatureHeader = req.header('x-bb-signature') || '';

if (!timestamp || !signatureHeader.startsWith('sha256=')) {
return res.status(400).send('missing signature headers');
}

const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
const signingInput = Buffer.concat([
Buffer.from(String(timestamp), 'utf8'),
Buffer.from('.', 'utf8'),
rawBody,
]);

const expected = crypto
.createHmac('sha256', webhookSecret)
.update(signingInput)
.digest('hex');

const provided = signatureHeader.slice('sha256='.length);
const expectedBuf = Buffer.from(expected, 'hex');
const providedBuf = Buffer.from(provided, 'hex');

if (expectedBuf.length !== providedBuf.length) {
return res.status(401).send('invalid signature');
}
if (!crypto.timingSafeEqual(expectedBuf, providedBuf)) {
return res.status(401).send('invalid signature');
}

const now = Date.now();
const skewMs = Math.abs(now - Number(timestamp));
if (!Number.isFinite(skewMs) || skewMs > 5 * 60 * 1000) {
return res.status(401).send('stale timestamp');
}

let payload;
try {
payload = JSON.parse(rawBody.toString('utf8'));
} catch {
return res.status(400).send('invalid json');
}

if (payload.event !== 'certificate.issued') {
return res.status(400).send('unexpected event');
}

console.log('verified webhook', {
cert_id: payload.cert_id,
issue_history_id: payload.issue_history_id,
user_id: payload.user_id,
domain_name: payload.domain_name,
});

return res.status(204).end();
});

app.listen(3000, () => {
console.log('listening on :3000');
});

Notes:

  • Use express.raw() for this route. If JSON middleware consumes and rewrites the body first, signature verification will fail.
  • Store the secret outside source control, for example in an environment variable.
  • Return 204 quickly and hand off slow work to a queue or background job.

What happens if you do not verify

The endpoint will still receive requests and may appear to work, but it becomes an unauthenticated public POST endpoint.

Without verification:

  • Anyone who can reach the URL can forge events
  • Payload integrity is not protected
  • Replay protection is effectively lost

bbserver treats any 2xx response as success.

Retryable responses are:

  • 408
  • 429
  • 5xx

Recommended receiver behavior:

  • Return 204 or another 2xx as soon as the request is accepted
  • Perform slow work after the response, usually through a job queue
  • Return 4xx only for real permanent problems such as an invalid signature

Using the payload after verification

The webhook is a notification, not a certificate bundle. After verification, use cert_id to fetch the current certificate through your existing authenticated API flow.