Skip to main content

Webhooks and External Integrations

Almost every Strapi project needs to talk to external systems - search indexes, notification services, payment gateways, or static site rebuilds. This page covers Strapi's built-in webhook system, custom event-driven integrations, and patterns for common third-party services.

Built-in webhooks​

Strapi has a webhook system configurable from the admin panel (Settings > Webhooks).

Available events​

EventFires when
entry.createA content entry is created
entry.updateA content entry is updated
entry.deleteA content entry is deleted
entry.publishA content entry is published
entry.unpublishA content entry is unpublished
entry.draft-discardA content draft is discarded
media.createA media file is uploaded
media.updateA media file is updated
media.deleteA media file is deleted
review-workflows.updateEntryStageAn entry changes review stage (Enterprise)
releases.publishA release is published (Growth/Enterprise)

Webhook payload​

{
"event": "entry.publish",
"createdAt": "2025-01-15T10:30:00.000Z",
"model": "article",
"uid": "api::article.article",
"entry": {
"id": 42,
"documentId": "abc123",
"title": "My Article",
"slug": "my-article",
"publishedAt": "2025-01-15T10:30:00.000Z"
}
}

Webhook configuration via admin​

  1. Go to Settings > Webhooks > Create new webhook
  2. Enter the target URL
  3. Select which events to trigger on
  4. Optionally add custom headers (for example X-Webhook-Token: <shared-token>)

Webhook configuration in config/server.js​

module.exports = ({ env }) => ({
webhooks: {
defaultHeaders: {
'X-Webhook-Token': env('WEBHOOK_SHARED_TOKEN'),
},
},
});

Strapi 5 no longer supports the Strapi 4 webhooks.populateRelations option. Entry webhook payloads are populated by Strapi's webhook implementation and are not configurable per webhook.

Built-in outgoing webhooks do not automatically generate an HMAC signature. If the receiver requires tamper-proof verification, add your own shared header/token or send the request yourself from custom code where you can compute a signature.

Media webhook payloads use { event, createdAt, media }; they do not include model, uid, or entry.


Custom event listeners (programmatic)​

For more control, use Document Service middleware or lifecycle subscribers in code.

If you need a custom event to appear in the built-in webhook UI, register it with the webhook store and emit it through Strapi's event hub. The webhook runner listens to strapi.eventHub and sends { event, createdAt, ...info } to matching webhooks.

// src/index.js
module.exports = {
register({ strapi }) {
strapi.webhookStore.addAllowedEvent('SEARCH_SYNC', 'search.sync');
},

async bootstrap({ strapi }) {
await strapi.eventHub.emit('search.sync', {
uid: 'api::article.article',
entry: {
documentId: 'article-document-id',
},
});
},
};

Trigger a static site rebuild on publish​

// src/index.js
module.exports = {
register({ strapi }) {
strapi.documents.use(async (context, next) => {
const result = await next();

if (['publish', 'unpublish', 'delete'].includes(context.action)) {
// Fire and forget - don't block the response
setImmediate(async () => {
try {
await fetch(process.env.DEPLOY_HOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: context.action,
contentType: context.uid,
documentId: context.params.documentId || result?.documentId,
}),
});
strapi.log.info(`[webhook] Deploy hook triggered for ${context.action}`);
} catch (error) {
strapi.log.error('[webhook] Deploy hook failed:', error.message);
}
});
}

return result;
});
},
};

Vercel / Netlify deploy hooks​

# .env
DEPLOY_HOOK_URL=https://api.vercel.app/v1/integrations/deploy/prj_xxxxx/yyyyyyy
# or for Netlify:
# DEPLOY_HOOK_URL=https://api.netlify.com/build_hooks/xxxxx

Search integration: Algolia​

Setup​

npm install algoliasearch
// src/services/algolia.js
const algoliasearch = require('algoliasearch');

const client = algoliasearch(
process.env.ALGOLIA_APP_ID,
process.env.ALGOLIA_ADMIN_KEY
);

module.exports = ({ strapi }) => ({
getIndex(indexName) {
return client.initIndex(indexName);
},

async indexDocument(uid, document) {
const indexName = uid.replace('api::', '').replace('.', '_');
const index = this.getIndex(indexName);

await index.saveObject({
objectID: document.documentId,
title: document.title,
slug: document.slug,
content: document.content?.replace(/<[^>]*>/g, '').substring(0, 5000),
publishedAt: document.publishedAt,
locale: document.locale,
});

strapi.log.debug(`[algolia] Indexed ${uid} ${document.documentId}`);
},

async removeDocument(uid, documentId) {
const indexName = uid.replace('api::', '').replace('.', '_');
const index = this.getIndex(indexName);

await index.deleteObject(documentId);
strapi.log.debug(`[algolia] Removed ${uid} ${documentId}`);
},

async reindexAll(uid) {
const indexName = uid.replace('api::', '').replace('.', '_');
const index = this.getIndex(indexName);

const documents = await strapi.documents(uid).findMany({
status: 'published',
fields: ['title', 'slug', 'content', 'publishedAt'],
limit: -1,
});

const objects = documents.map(doc => ({
objectID: doc.documentId,
title: doc.title,
slug: doc.slug,
content: doc.content?.replace(/<[^>]*>/g, '').substring(0, 5000),
publishedAt: doc.publishedAt,
}));

await index.replaceAllObjects(objects);
strapi.log.info(`[algolia] Reindexed ${objects.length} ${uid} documents`);
},
});

Auto-sync on publish/unpublish​

// src/index.js
const searchableTypes = [
'api::article.article',
'api::page.page',
];

module.exports = {
register({ strapi }) {
strapi.documents.use(async (context, next) => {
const result = await next();

if (!searchableTypes.includes(context.uid)) return result;

setImmediate(async () => {
try {
const algolia = strapi.service('api::algolia.algolia');

if (context.action === 'publish') {
const doc = await strapi.documents(context.uid).findOne({
documentId: result.documentId,
status: 'published',
fields: ['title', 'slug', 'content', 'publishedAt'],
});
if (doc) await algolia.indexDocument(context.uid, doc);
}

if (['unpublish', 'delete'].includes(context.action)) {
const docId = context.params.documentId || result?.documentId;
if (docId) await algolia.removeDocument(context.uid, docId);
}
} catch (error) {
strapi.log.error('[algolia] Sync failed:', error.message);
}
});

return result;
});
},
};

Search integration: Meilisearch​

npm install meilisearch
// src/services/meilisearch.js
const { MeiliSearch } = require('meilisearch');

const client = new MeiliSearch({
host: process.env.MEILISEARCH_HOST || 'http://localhost:7700',
apiKey: process.env.MEILISEARCH_ADMIN_KEY,
});

module.exports = ({ strapi }) => ({
async indexDocument(uid, document) {
const indexName = uid.replace('api::', '').replace('.', '-');
const index = client.index(indexName);

await index.addDocuments([
{
id: document.documentId,
title: document.title,
slug: document.slug,
content: document.content?.replace(/<[^>]*>/g, ''),
publishedAt: document.publishedAt,
},
]);
},

async removeDocument(uid, documentId) {
const indexName = uid.replace('api::', '').replace('.', '-');
const index = client.index(indexName);
await index.deleteDocument(documentId);
},
});

Slack notifications​

// src/services/slack.js
module.exports = ({ strapi }) => ({
async notify({ channel, text, blocks }) {
const webhookUrl = process.env.SLACK_WEBHOOK_URL;
if (!webhookUrl) return;

await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel, text, blocks }),
});
},

async notifyContentPublished(uid, document) {
const typeName = uid.split('.').pop();

await this.notify({
text: `📢 New ${typeName} published: *${document.title}*`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `📢 *New ${typeName} published*\n*${document.title}*\nBy: ${document.createdBy?.firstname || 'Unknown'}\n<${process.env.PUBLIC_URL}/${document.slug}|View on site>`,
},
},
],
});
},
});

Consuming external APIs​

Fetching data from an external API in a service​

// src/api/weather/services/weather.js
module.exports = ({ strapi }) => ({
async getCurrentWeather(city) {
const apiKey = process.env.OPENWEATHER_API_KEY;
const url = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&appid=${apiKey}&units=metric`;

const response = await fetch(url);

if (!response.ok) {
strapi.log.error(`[weather] API error: ${response.status}`);
return null;
}

const data = await response.json();

return {
city: data.name,
temperature: data.main.temp,
description: data.weather[0].description,
icon: data.weather[0].icon,
};
},
});

Consuming inbound webhooks (e.g., Stripe)​

// src/api/stripe/routes/stripe.js
module.exports = {
routes: [
{
method: 'POST',
path: '/stripe/webhook',
handler: 'api::stripe.stripe.handleWebhook',
config: {
auth: false, // Stripe can't authenticate with Strapi JWT
},
},
],
};

// src/api/stripe/controllers/stripe.js
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

module.exports = {
async handleWebhook(ctx) {
const sig = ctx.request.headers['stripe-signature'];
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;

let event;
try {
event = stripe.webhooks.constructEvent(
ctx.request.body[Symbol.for('unparsedBody')] || ctx.request.body,
sig,
endpointSecret
);
} catch (err) {
strapi.log.error(`[stripe] Webhook signature verification failed: ${err.message}`);
return ctx.badRequest('Invalid signature');
}

switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
await strapi.service('api::order.order').fulfillOrder(session);
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object;
await strapi.service('api::subscription.subscription').sync(subscription);
break;
}
default:
strapi.log.debug(`[stripe] Unhandled event: ${event.type}`);
}

ctx.body = { received: true };
},
};

Webhook retry and reliability​

Built-in Strapi webhooks are convenient, but they are not a durable outbox for critical integrations. In multi-instance deployments, plan retries, dead-letter handling, and idempotency explicitly; see Running Strapi on multiple instances.

For critical integrations, consider:

// src/services/webhook-queue.js
const queue = [];
const MAX_RETRIES = 3;

module.exports = ({ strapi }) => ({
async enqueue(url, payload, retries = 0) {
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10000), // 10s timeout
});

if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}

strapi.log.debug(`[webhook-queue] Delivered to ${url}`);
} catch (error) {
if (retries < MAX_RETRIES) {
const delay = Math.pow(2, retries) * 1000; // Exponential backoff
strapi.log.warn(
`[webhook-queue] Retry ${retries + 1}/${MAX_RETRIES} for ${url} in ${delay}ms`
);
setTimeout(() => this.enqueue(url, payload, retries + 1), delay);
} else {
strapi.log.error(
`[webhook-queue] Failed after ${MAX_RETRIES} retries: ${url}`,
error.message
);
// Store failed webhook for manual retry
await strapi.documents('api::failed-webhook.failed-webhook').create({
data: { url, payload: JSON.stringify(payload), error: error.message },
});
}
}
},
});

Common pitfalls​

PitfallProblemFix
Blocking side effects in middlewareSlow API responsesUse setImmediate() for external calls
No webhook signature verificationAnyone can fake webhook callsVerify signatures (Stripe, GitHub, etc.)
Algolia admin key in frontendKey leaks, index gets corruptedUse the search-only key in the frontend
No error handling on external callsOne failed webhook blocks the chainWrap in try/catch, use a queue with retries
Webhook endpoint missing auth: falseExternal services get 403Set auth: false on inbound webhook routes
Syncing on every updateToo many API calls to search/notificationsDebounce or only sync on publish

See also​