Reacting to Client Intake Events in Your Own Code

Last updated:

The moment a client finishes an intake, the files, copy, and credentials you asked for already exist — typed, validated, ready to read. The question is how your code finds out. This guide is for developers who'd rather receive a webhook and act on it directly than route the event through an iPaaS like Zapier or n8n first. It assumes you already know what webhooks are and how they're signed — that page is the reference; this one is the how-to.

We'll build a receiver that verifies the signature, decides what to do per event, and fetches typed results once an intake is done — enough to kick off a build, drop files into a repo or bucket, post to your own dashboard, or hand the results to a coding agent.

The examples use Express, since that's what BriefGate's own quickstart snippets use, but the logic ports directly to Fastify, Hono, or a plain Node http server — the only Express-specific part is getting the raw body.

Registering the endpoint

Register your receiver with only the events you plan to act on — if you just need to know when material is ready, you may not need item.submitted:

bash
curl -X POST https://api.briefgate.dev/v1/webhooks \
  -H "Authorization: Bearer bg_live_xxxxx" \
  -H "Content-Type: application/json" \
  -d '{
        "url": "https://yourapp.com/webhooks/briefgate",
        "events": ["intake.completed", "intake.stalled", "chase.bounced"]
      }'

The response includes a secret shown exactly once — save it as BRIEFGATE_WEBHOOK_SECRET or similar; there's no way to fetch it again, only to delete the endpoint and register a new one. Full request/response shapes and the Slack/Discord chat format are in the webhooks reference; this guide covers only the raw format, the one meant for code.

Getting the raw body right

The signature is computed over the exact bytes BriefGate sent, not your parsed object — if a JSON body parser runs first and you re-serialize to check the signature, whitespace or key-order differences make legitimate requests fail. Capture the raw string before parsing:

javascript
import express from 'express';

const app = express();

// Stash the raw bytes for verification; req.body still parses normally.
app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf.toString('utf8'); },
}));

Verifying the signature

BriefGate signs every delivery with X-BriefGate-Signature: t=<unix>,v1=<hex>, where v1 is HMAC-SHA256(webhook_secret, "${t}.${rawBody}") in hex. Reject anything more than 5 minutes old to block replay, and compare v1 with timingSafeEqual, never === — a plain string comparison leaks timing information about how many leading characters matched, which is exactly what a signature check shouldn't do:

javascript
import { createHmac, timingSafeEqual } from 'crypto';

function verifyBriefGateSignature(rawBody, secret, signatureHeader) {
  if (!signatureHeader) throw new Error('Missing signature header');

  const parts = Object.fromEntries(
    signatureHeader.split(',').map(p => p.split('=', 2)),
  );
  const t = parts['t'];
  const v1 = parts['v1'];
  if (!t || !v1) throw new Error('Malformed signature header');

  // 5-minute tolerance window, same as BriefGate enforces on its own side.
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) {
    throw new Error('Timestamp outside tolerance window');
  }

  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(v1, 'hex');
  const b = Buffer.from(expected, 'hex');
  // timingSafeEqual throws on a length mismatch, so check that explicitly first.
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    throw new Error('Signature mismatch');
  }
}

One shortcut to avoid: re-signing the parsed req.body with JSON.stringify(req.body) instead of the raw string. Key order and spacing aren't guaranteed to match what BriefGate actually sent, so that rejects valid requests intermittently — the verify callback above is the fix, capturing the exact bytes once before Express touches them.

Wiring the route

javascript
app.post('/webhooks/briefgate', async (req, res) => {
  try {
    verifyBriefGateSignature(req.rawBody, process.env.BRIEFGATE_WEBHOOK_SECRET, req.header('X-BriefGate-Signature'));
  } catch (err) {
    return res.status(400).send('invalid signature'); // discard without acting on the payload
  }

  const event = req.header('X-BriefGate-Event'); // same as req.body.event, but available pre-parse
  const { intake_id } = req.body;

  res.status(200).send('ok'); // acknowledge before doing any real work

  handleEvent(event, req.body, intake_id).catch(err => {
    console.error('webhook handler failed', { event, intake_id, err });
  });
});

Responding before handleEvent finishes matters: delivery has a 10-second budget, and building files or calling another API can easily run longer. Do the slow work after replying, or hand it to a queue.

Which events to act on, and how

The full event list and payload shapes are in the webhooks reference; here's what each one is for in a code-driven pipeline:

javascript
async function handleEvent(event, payload, intakeId) {
  switch (event) {
    case 'intake.completed':
      await fetchResultsAndKickOffBuild(intakeId);
      break;
    case 'intake.stalled':
      await notifyOwnerToFollowUpManually(intakeId, payload.missing_items);
      break;
    case 'chase.bounced':
      if (!payload.still_chasing) await flagIntakeForManualOutreach(intakeId);
      break;
    default:
      console.log('unhandled event', event, intakeId); // client.viewed, intake.archived, etc.
  }
}

Idempotency and retries

BriefGate retries a delivery on anything other than 2xx, backing off over roughly a day, and can rarely deliver the same event twice even after a 2xx. Your handler should be safe to run twice — key on intake_id + event and skip work already done:

javascript
const seen = new Set(); // swap for Redis/DB in anything beyond a single process

async function fetchResultsAndKickOffBuild(intakeId) {
  const dedupeKey = `intake.completed:${intakeId}`;
  if (seen.has(dedupeKey)) return;
  seen.add(dedupeKey);

  const results = await getIntakeResults(intakeId);
  // ... start the build, write files, whatever comes next
}

Anything other than 2xx — including a crash before you respond — is treated as a failed attempt and retried on the schedule in the reference doc. Returning 410 Gone deliberately deactivates the endpoint, the documented way to unsubscribe without a separate API call.

Pulling typed results

Once you have an intake_id from intake.completed, fetch the results — the event itself deliberately carries only intake_id, project_name, and timestamp, not the answers.

javascript
async function getIntakeResults(intakeId) {
  const url = new URL(`https://api.briefgate.dev/v1/intakes/${intakeId}/results`);
  // A secret item can only be revealed once — don't burn it in a pipeline that logs everything.
  url.searchParams.set('exclude_secrets', 'true');

  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.BRIEFGATE_API_KEY}` },
  });
  if (!res.ok) throw new Error(`results fetch failed: ${res.status}`);
  return res.json();
}

GET /v1/intakes/:id/results is API-key only — no dashboard session — since it also advances an only_new cursor and can reveal secrets, both of which only make sense for a service acting on its own key. See the REST API reference for the full response shape and the only_new/include_pending parameters.

If you do need a credential — say, to log into a client's WordPress install and push a plugin — drop exclude_secrets and read it with the secrets:read scope. Handle it like the one-time value it is: read it, use it, don't write it to your own logs in plaintext. Secret items are encrypted with a libsodium sealed box on arrival at BriefGate's server, never on the client — see the secrets vault reference for what that does and doesn't protect against.

Handing results to a coding agent

If the next step is an agent rather than a script, you don't need to shuttle the payload into a prompt by hand. An MCP-capable agent with BriefGate connected can call get_intake_results itself once it knows the intake_id — your receiver's job becomes "notice intake.completed and wake the agent up," not "extract and reformat the data." See Get client assets into your coding agent.

One thing to watch: an endpoint receives every event on the account, across every intake — filter on intake_id inside your handler if you run several projects through one account.