Skip to content

Webhooks

Configure webhooks in your Scalar configuration file:

scalar.config.ts
export default defineConfig({
webhooks: {
enabled: true,
endpoints: [
{
url: 'https://your-app.com/api/webhook',
events: ['create', 'update', 'delete'],
models: ['blogPost', 'page'],
secret: process.env.WEBHOOK_SECRET,
headers: {
'X-Custom-Header': 'value',
},
timeout: 5000, // 5 seconds
retries: 3,
},
{
url: 'https://api.vercel.com/v1/integrations/deploy/prj_xxx/yyy',
events: ['update', 'delete'],
models: ['blogPost'],
method: 'POST',
},
],
},
});
OptionTypeDescription
urlstringThe endpoint URL to send webhooks to
eventsstring[]Events to trigger webhooks: create, update, delete
modelsstring[]Content models to watch for changes
secretstringSecret for verifying webhook authenticity
headersobjectCustom headers to include in requests
timeoutnumberRequest timeout in milliseconds (default: 5000)
retriesnumberNumber of retry attempts on failure (default: 3)
methodstringHTTP method (default: ‘POST’)

Scalar sends webhooks for the following events:

  • create - When new content is created
  • update - When existing content is modified
  • delete - When content is deleted
  • publish - When content status changes to published
  • unpublish - When content status changes from published
  • user.create - When a new user is created
  • user.login - When a user logs in
  • backup.complete - When a backup is completed

Webhooks send a JSON payload with the following structure:

{
"id": "webhook_event_123",
"event": "create",
"model": "blogPost",
"timestamp": "2024-01-15T10:30:00Z",
"data": {
"id": "post_456",
"title": "My New Blog Post",
"slug": "my-new-blog-post",
"status": "published",
"author": {
"id": "user_789",
"name": "John Doe"
},
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z"
},
"previous": null,
"user": {
"id": "user_789",
"name": "John Doe",
"email": "john@example.com"
}
}
FieldTypeDescription
idstringUnique webhook event ID
eventstringThe event type that triggered the webhook
modelstringThe content model that was changed
timestampstringISO 8601 timestamp of when the event occurred
dataobjectThe current state of the content
previousobjectPrevious state (only for update events)
userobjectUser who triggered the change
pages/api/webhook.ts
import { NextApiRequest, NextApiResponse } from 'next';
import crypto from 'crypto';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
// Verify webhook signature
const signature = req.headers['x-scalar-signature'] as string;
const isValid = verifyWebhookSignature(
JSON.stringify(req.body),
signature,
process.env.WEBHOOK_SECRET!,
);
if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' });
}
const { event, model, data } = req.body;
try {
switch (event) {
case 'create':
await handleCreate(model, data);
break;
case 'update':
await handleUpdate(model, data);
break;
case 'delete':
await handleDelete(model, data);
break;
default:
console.log(`Unhandled event: ${event}`);
}
res.status(200).json({ success: true });
} catch (error) {
console.error('Webhook processing error:', error);
res.status(500).json({ error: 'Processing failed' });
}
}
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string,
): boolean {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature.replace('sha256=', '')),
Buffer.from(expectedSignature),
);
}
async function handleCreate(model: string, data: any) {
if (model === 'blogPost' && data.status === 'published') {
// Trigger static site rebuild
await fetch('https://api.vercel.com/v1/integrations/deploy/xxx', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` },
});
// Update search index
await updateSearchIndex(data);
// Send notification
await sendNotification(`New blog post published: ${data.title}`);
}
}
async function handleUpdate(model: string, data: any) {
if (model === 'blogPost') {
// Update search index with new content
await updateSearchIndex(data);
// Clear CDN cache for this post
await clearCache(`/blog/${data.slug}`);
}
}
async function handleDelete(model: string, data: any) {
if (model === 'blogPost') {
// Remove from search index
await removeFromSearchIndex(data.id);
// Clear CDN cache
await clearCache(`/blog/${data.slug}`);
}
}
routes/webhook.js
const express = require('express');
const crypto = require('crypto');
const router = express.Router();
router.post(
'/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-scalar-signature'];
const payload = req.body;
// Verify signature
const expectedSignature = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(payload)
.digest('hex');
if (`sha256=${expectedSignature}` !== signature) {
return res.status(401).send('Invalid signature');
}
const webhookData = JSON.parse(payload);
// Process webhook
processWebhook(webhookData)
.then(() => res.status(200).send('OK'))
.catch((error) => {
console.error('Webhook error:', error);
res.status(500).send('Error processing webhook');
});
},
);
async function processWebhook({ event, model, data }) {
switch (event) {
case 'create':
if (model === 'blogPost') {
await notifySlack(`New blog post: ${data.title}`);
}
break;
case 'update':
await invalidateCache(model, data.id);
break;
case 'delete':
await removeFromIndex(model, data.id);
break;
}
}
module.exports = router;

Trigger builds when content changes:

// Trigger Vercel deployment
async function triggerVercelBuild() {
const response = await fetch(
`https://api.vercel.com/v1/integrations/deploy/${process.env.VERCEL_HOOK_ID}`,
{ method: 'POST' }
);
if (!response.ok) {
throw new Error('Failed to trigger build');
}
return response.json();
}

Keep search indexes synchronized:

lib/search.ts
import { Client } from '@elastic/elasticsearch';
const client = new Client({
node: process.env.ELASTICSEARCH_URL,
});
export async function updateSearchIndex(data: any) {
await client.index({
index: 'content',
id: data.id,
body: {
title: data.title,
content: data.content,
slug: data.slug,
publishedAt: data.publishedAt,
model: data.model,
},
});
}
export async function removeFromSearchIndex(id: string) {
await client.delete({
index: 'content',
id: id,
});
}

Clear CDN and application caches:

lib/cache.ts
import { CloudFront } from 'aws-sdk';
const cloudfront = new CloudFront();
export async function clearCDNCache(paths: string[]) {
const params = {
DistributionId: process.env.CLOUDFRONT_DISTRIBUTION_ID!,
InvalidationBatch: {
CallerReference: Date.now().toString(),
Paths: {
Quantity: paths.length,
Items: paths,
},
},
};
await cloudfront.createInvalidation(params).promise();
}
export async function clearApplicationCache(key: string) {
// Clear Redis cache
await redis.del(key);
// Clear in-memory cache
cache.delete(key);
}

Send notifications to team members:

lib/notifications.ts
// Slack notification
export async function notifySlack(message: string) {
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: message }),
});
}
// Discord notification
export async function notifyDiscord(message: string) {
await fetch(process.env.DISCORD_WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: message }),
});
}
// Email notification
export async function sendEmail(subject: string, body: string) {
// Using your preferred email service
await emailService.send({
to: process.env.NOTIFICATION_EMAIL,
subject,
html: body,
});
}

Use tools like ngrok to test webhooks locally:

Terminal window
# Install ngrok
npm install -g ngrok
# Start your local server
npm run dev
# In another terminal, expose local server
ngrok http 3000
# Use the ngrok URL in your webhook configuration
# https://abc123.ngrok.io/api/webhook

Create a simple webhook testing endpoint:

pages/api/webhook-test.ts
import { NextApiRequest, NextApiResponse } from 'next';
export default function handler(req: NextApiRequest, res: NextApiResponse) {
console.log('Webhook received:');
console.log('Headers:', req.headers);
console.log('Body:', req.body);
// Log to file for debugging
const fs = require('fs');
const logEntry = {
timestamp: new Date().toISOString(),
headers: req.headers,
body: req.body,
};
fs.appendFileSync(
'webhook-logs.json',
JSON.stringify(logEntry, null, 2) + '\n',
);
res.status(200).json({ success: true });
}
__tests__/webhook.test.ts
import { createMocks } from 'node-mocks-http';
import handler from '@/pages/api/webhook';
import crypto from 'crypto';
describe('/api/webhook', () => {
it('should process valid webhook', async () => {
const payload = {
event: 'create',
model: 'blogPost',
data: { id: '1', title: 'Test Post' },
};
const signature = crypto
.createHmac('sha256', 'test-secret')
.update(JSON.stringify(payload))
.digest('hex');
const { req, res } = createMocks({
method: 'POST',
headers: {
'x-scalar-signature': `sha256=${signature}`,
},
body: payload,
});
await handler(req, res);
expect(res._getStatusCode()).toBe(200);
});
it('should reject invalid signature', async () => {
const { req, res } = createMocks({
method: 'POST',
headers: {
'x-scalar-signature': 'sha256=invalid',
},
body: { event: 'create' },
});
await handler(req, res);
expect(res._getStatusCode()).toBe(401);
});
});
  1. Webhook not firing

    • Check webhook configuration
    • Verify model names match exactly
    • Ensure events are configured correctly
  2. Invalid signature errors

    • Verify webhook secret matches configuration
    • Check signature generation algorithm
    • Ensure raw body is used for signature verification
  3. Timeout errors

    • Increase timeout value in configuration
    • Optimize webhook handler performance
    • Consider async processing for heavy operations

Enable webhook logging in development:

scalar.config.ts
export default defineConfig({
webhooks: {
enabled: true,
debug: process.env.NODE_ENV === 'development',
logging: {
level: 'debug',
file: './webhook-debug.log',
},
},
});