Skip to content
Dashboard

Custom Webhook Integration

Connect Brainpercent to any backend, CRM, CMS, or notification channel using outgoing webhooks. This guide covers the full webhook payload schema, HMAC-SHA256 signature verification, retry policy, and ready-to-paste recipes for Discord, Slack, Notion, and Airtable.

Webhook Payload Schema

Every Brainpercent webhook delivers a JSON body with the same top-level envelope. The data object shape varies by event type.

{
  "event":      "article.published",   // event type — see table below
  "delivery_id": "evt_01HZ9K...",      // unique delivery ID (UUID)
  "timestamp":   "2026-04-19T10:23:45.123Z",
  "project_id":  "proj_abc123",
  "data":        { ... }               // event-specific payload (see below)
}
Event typeFires when
article.publishedAn article passes all quality gates and is marked published
article.failedArticle generation failed after all retries
social.generatedSocial media content (caption + images) is ready
social.publishedSocial post was published to the connected platform
credits.lowAccount credit balance drops below the configured threshold

Signature Verification (HMAC-SHA256)

Every request Brainpercent sends includes an x-brainpercent-signature header. Verify it to prevent replay attacks and ensure the payload was not tampered with.

HeaderFormat
x-brainpercent-signaturesha256=<hex-digest>
import crypto from 'crypto';

/**
 * Returns true if the request signature is valid.
 * Call this BEFORE parsing req.body as JSON.
 * rawBody must be the raw Buffer/string — NOT the parsed object.
 */
function verifyBrainpercentSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader?.startsWith('sha256=')) return false;
  const received = signatureHeader.slice(7); // strip "sha256="
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  // Constant-time comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(received, 'hex'),
    Buffer.from(expected, 'hex')
  );
}

// Express usage — must use express.raw() NOT express.json()
app.post('/webhooks/brainpercent',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const valid = verifyBrainpercentSignature(
      req.body,
      req.headers['x-brainpercent-signature'],
      process.env.BP_WEBHOOK_SECRET
    );
    if (!valid) return res.status(401).json({ error: 'Invalid signature' });

    const event = JSON.parse(req.body); // parse AFTER verification
    // handle event...
    res.json({ received: true });
  }
);

Critical: You must pass the raw request body bytes to the HMAC function, not the parsed JSON object. Use express.raw() (not express.json()) and call JSON.parse() only after the signature is verified.

Retry Policy

Brainpercent retries failed webhook deliveries automatically. A delivery is considered failed if your endpoint returns a non-2xx HTTP status, closes the connection, or does not respond within 10 seconds.

AttemptDelay before retry
1st (initial)Immediate
2nd5 minutes
3rd (final)30 minutes

After 3 failed attempts the delivery is marked as permanently failed. You can replay individual deliveries from the Brainpercent webhook delivery log in Settings. Make your handler idempotent — use the delivery_id field as a deduplication key.

Common Recipes

Discord Notification

Post a message to a Discord channel when an article is published. Requires a Discord webhook URL from Channel Settings → Integrations → Webhooks.

async function notifyDiscord(article) {
  await fetch(process.env.DISCORD_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      embeds: [{
        title:       article.title,
        description: article.excerpt ?? '',
        url:         `https://your-site.com/blog/${article.slug}`,
        color:       0x4d7fff,
        image:       article.featured_image_url
          ? { url: article.featured_image_url }
          : undefined,
        footer: { text: `${article.word_count} words · ${article.reading_time_minutes} min read` },
      }],
    }),
  });
}

Slack Notification

Post to a Slack channel via an Incoming Webhook. Create one at api.slack.com/messaging/webhooks.

async function notifySlack(article) {
  await fetch(process.env.SLACK_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      blocks: [
        {
          type: 'section',
          text: {
            type: 'mrkdwn',
            text: `*New article published:* <https://your-site.com/blog/${article.slug}|${article.title}>`,
          },
        },
        {
          type: 'context',
          elements: [{
            type: 'mrkdwn',
            text: `${article.word_count} words · ${article.reading_time_minutes} min read · ${article.category}`,
          }],
        },
      ],
    }),
  });
}

Notion Database Row

Add a row to a Notion database for every published article. Requires a Notion integration token and a database ID. Consult Notion's API docs at developers.notion.com for how to create an integration and share a database with it.

async function addToNotion(article) {
  await fetch('https://api.notion.com/v1/pages', {
    method: 'POST',
    headers: {
      'Authorization':  `Bearer ${process.env.NOTION_TOKEN}`,
      'Content-Type':   'application/json',
      'Notion-Version': '2022-06-28',
    },
    body: JSON.stringify({
      parent: { database_id: process.env.NOTION_DATABASE_ID },
      properties: {
        'Title':      { title:   [{ text: { content: article.title } }] },
        'Slug':       { rich_text: [{ text: { content: article.slug } }] },
        'Category':   { select:  { name: article.category } },
        'Published':  { date:    { start: article.published_at } },
        'Word Count': { number:  article.word_count },
        'URL':        { url:     `https://your-site.com/blog/${article.slug}` },
      },
    }),
  });
}

Airtable Row

Create a row in an Airtable base. Get your API key and base/table IDs from airtable.com/developers. Column names in fields must exactly match your Airtable column headers.

async function addToAirtable(article) {
  const BASE_ID  = process.env.AIRTABLE_BASE_ID;
  const TABLE_ID = process.env.AIRTABLE_TABLE_ID; // or table name

  await fetch(`https://api.airtable.com/v0/${BASE_ID}/${TABLE_ID}`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.AIRTABLE_TOKEN}`,
      'Content-Type':  'application/json',
    },
    body: JSON.stringify({
      records: [{
        fields: {
          'Title':       article.title,
          'Slug':        article.slug,
          'Category':    article.category,
          'Published At': article.published_at,
          'Word Count':  article.word_count,
          'Image URL':   article.featured_image_url ?? '',
          'URL':         `https://your-site.com/blog/${article.slug}`,
        },
      }],
    }),
  });
}

Custom CRM Update

Attach generated content to a CRM deal or contact by POSTing to your CRM's API endpoint. Adjust the payload structure to match your CRM's schema.

async function updateCRM(article) {
  // Generic pattern — adapt URL and payload to your CRM
  await fetch(`${process.env.CRM_BASE_URL}/api/activities`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.CRM_API_KEY}`,
      'Content-Type':  'application/json',
    },
    body: JSON.stringify({
      type:        'content_published',
      title:        article.title,
      url:          `https://your-site.com/blog/${article.slug}`,
      metadata:    {
        word_count: article.word_count,
        category:   article.category,
        image:      article.featured_image_url,
      },
    }),
  });
}

Common Gotchas

  • Always respond quickly (within 10 s). Do any slow downstream work asynchronously. If your handler queues the job internally, return 200 immediately and process in the background.
  • Do not use express.json() before verifying the signature. JSON parsing modifies the body bytes; HMAC verification will fail. Always use express.raw() on the webhook route.
  • Deduplicate using delivery_id. Brainpercent retries on non-2xx responses. If your handler times out but succeeds, you may receive the same event twice. Store processed delivery_id values and skip duplicates.
  • Use featured_image_url, not generated_images[platform].url. The generated_images URLs are often ephemeral GoAPI hosts (img.theapi.app/ephemeral/...) that expire after a few days. featured_image_url is a permanent Supabase Storage URL.

For the full webhook API reference including event filtering and delivery logs, see Webhooks & Async guide.