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 type | Fires when |
|---|---|
article.published | An article passes all quality gates and is marked published |
article.failed | Article generation failed after all retries |
social.generated | Social media content (caption + images) is ready |
social.published | Social post was published to the connected platform |
credits.low | Account 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.
| Header | Format |
|---|---|
x-brainpercent-signature | sha256=<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.
| Attempt | Delay before retry |
|---|---|
| 1st (initial) | Immediate |
| 2nd | 5 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
200immediately 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 useexpress.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 processeddelivery_idvalues and skip duplicates. - Use
featured_image_url, notgenerated_images[platform].url. Thegenerated_imagesURLs are often ephemeral GoAPI hosts (img.theapi.app/ephemeral/...) that expire after a few days.featured_image_urlis a permanent Supabase Storage URL.
For the full webhook API reference including event filtering and delivery logs, see Webhooks & Async guide.