Custom Webhook Integration
Connect Skribra to any platform or custom application using webhooks. Receive article data via HTTP POST requests whenever articles are published.
Custom webhooks allow you to integrate Skribra with any system that can receive HTTP requests, including custom CMS platforms, static site generators, headless CMS systems, or your own backend services.
Overview#
When you publish articles in Skribra, we send the content directly to your webhook endpoint as a JSON payload. You can then process this data however you need — store it in a database, publish to a custom CMS, trigger site rebuilds, or anything else your system requires.
The webhook integration supports both Markdown and HTML content formats, Bearer token authentication, and automatic retries for failed deliveries.
Setting Up Webhook Integration#
Navigate to Integrations → Custom Webhook in your Skribra dashboard. You'll need to provide the following information:
- Integration Name: A unique name for your webhook integration (2-30 characters, no special characters).
- Webhook Endpoint: The URL where you want to receive webhook events (must be a valid HTTPS URL).
- Access Token: A secret key used to verify the authenticity of incoming webhook requests.
1. Webhook Endpoint
Enter the URL where you want to receive webhook events. This must be a valid HTTPS URL that accepts POST requests.
https://your-app.com/api/webhooks/skribra2. Access Token
Set a secret access token that Skribra will send with every request in the Authorization header. This token is used to verify the authenticity of incoming webhook requests.
Authorization: Bearer your_secure_access_key_hereImportant: Do not expose your access token publicly. Store it securely in environment variables and never hardcode it in your application or commit it to version control.
Verifying Webhook Requests#
Always verify that incoming webhook requests are authentic by checking the Bearer token in the Authorization header.
const express = require("express");
const app = express();
const ACCESS_KEY = process.env.SKRIBRA_ACCESS_KEY;
function verifyWebhook(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "Missing authorization" });
}
const token = authHeader.split(" ")[1];
if (token !== ACCESS_KEY) {
return res.status(401).json({ error: "Invalid access key" });
}
next();
}
app.use(express.json());
app.post("/api/webhooks/skribra", verifyWebhook, (req, res) => {
// Process the webhook payload
res.status(200).json({ success: true });
});from flask import Flask, request, jsonify
import os
app = Flask(__name__)
ACCESS_KEY = os.environ.get("SKRIBRA_ACCESS_KEY")
@app.route("/api/webhooks/skribra", methods=["POST"])
def handle_webhook():
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return jsonify({"error": "Missing authorization"}), 401
token = auth_header.split(" ")[1]
if token != ACCESS_KEY:
return jsonify({"error": "Invalid access key"}), 401
payload = request.get_json()
for article in payload["data"]["articles"]:
print(f"Received: {article['title']}")
# Save to database
return jsonify({"success": True})<?php
$accessKey = getenv("SKRIBRA_ACCESS_KEY");
$authHeader = $_SERVER["HTTP_AUTHORIZATION"] ?? "";
if (!str_starts_with($authHeader, "Bearer ")) {
http_response_code(401);
echo json_encode(["error" => "Missing authorization"]);
exit;
}
$token = substr($authHeader, 7);
if ($token !== $accessKey) {
http_response_code(401);
echo json_encode(["error" => "Invalid access key"]);
exit;
}
$payload = json_decode(file_get_contents("php://input"), true);
foreach ($payload["data"]["articles"] as $article) {
// Save to database
error_log("Received: " . $article["title"]);
}
echo json_encode(["success" => true]);package main
import (
"encoding/json"
"net/http"
"os"
"strings"
)
var accessKey = os.Getenv("SKRIBRA_ACCESS_KEY")
func webhookHandler(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "Missing authorization"})
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
if token != accessKey {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid access key"})
return
}
// Process the webhook payload
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
func main() {
http.HandleFunc("/api/webhooks/skribra", webhookHandler)
http.ListenAndServe(":8080", nil)
}Webhook Payload Structure#
When articles are published, Skribra sends a POST request with the following JSON payload:
{
"event_type": "publish_articles",
"timestamp": "2025-01-29T14:32:18.421Z",
"data": {
"articles": [
{
"id": "65c8a9d2e3b1f92a0f4a1c77",
"title": "How to Build a High-Converting SaaS Landing Page",
"slug": "how-to-build-high-converting-saas-landing-page",
"content_markdown": "# Introduction\\n\\nLanding pages are...",
"content_html": "<h1>Introduction</h1><p>Landing pages are...</p>",
"meta_description": "Learn the key elements of high-converting SaaS landing pages...",
"image_url": "https://images.skribra.com/articles/landing-page.webp",
"created_at": "2025-01-29T00:00:00.000Z",
"tags": ["saas", "landing-page", "conversion"]
}
]
}
}Payload Fields
| Field | Type | Description |
|---|---|---|
| event_type | string | Event identifier (always "publish_articles") |
| timestamp | string | ISO 8601 timestamp when the event occurred |
| data.articles | array | Array of article objects |
| article.id | string | Unique identifier for the article |
| article.title | string | The title of the article |
| article.slug | string | URL-friendly version of the title for permalinks |
| article.content_markdown | string | The article content in Markdown format |
| article.content_html | string | The article content in HTML format |
| article.meta_description | string | Brief description of the article for SEO purposes |
| article.image_url | string | URL of the main image associated with the article |
| article.created_at | string | Timestamp when the article was created (ISO 8601) |
| article.tags | array | Array of tags associated with the article |
| article.reading_time | string | Estimated reading time (e.g., "5 min read") |
| article.related_articles | array | Array of related article objects, each with 'url' and 'title' fields |
| article.cta | string | Call-to-action HTML containing text and an <a> tag with class="cta-button" |
About the Slug Field
The slug field is an SEO-optimized, URL-friendly string automatically generated from the article title. Use it as the pathname for the page displaying the article content (e.g., /blog/your-article-slug). If you're storing articles in a database, consider indexing this field for better query performance.
Response Requirements#
Your webhook endpoint should:
- Return a 2xx status code (200-299) to indicate success
- Respond within 30 seconds
- Return any status code outside 2xx to indicate failure
If your endpoint returns an error or times out, Skribra will automatically retry the request.
Use Cases#
Custom webhooks are ideal for:
- Static Site Generators: Trigger rebuilds when new content is published (Hugo, Jekyll, Gatsby, Astro, Next.js)
- Headless CMS: Push content to Strapi, Sanity, Contentful, or other headless platforms
- Custom Applications: Integrate with your own backend services or databases
- Notification Systems: Send alerts to Slack, Discord, or email when articles are published
- Content Syndication: Automatically distribute content to multiple platforms
Rendering Article Content#
Skribra provides article content in both Markdown and HTML formats via the content_markdown and content_html fields. Choose the format that best fits your application's needs.
Option 1: Using HTML Content (Recommended)
The content_html field contains pre-rendered HTML that's ready for display. This is the simplest approach — just insert the HTML and add your styles.
// React/Next.js component
interface ArticleProps {
title: string;
contentHtml: string;
imageUrl?: string;
}
export function Article({ title, contentHtml, imageUrl }: ArticleProps) {
return (
<article className="prose">
<h1>{title}</h1>
{imageUrl && <img src={imageUrl} alt={title} />}
<div dangerouslySetInnerHTML={{ __html: contentHtml }} />
</article>
);
}{# Jinja2 template.html #}
<article class="prose">
<h1>{{ article.title | e }}</h1>
{% if article.image_url %}
<img src="{{ article.image_url | e }}" alt="{{ article.title | e }}">
{% endif %}
<div class="content">
{{ article.content_html | safe }}
</div>
</article><?php
$article = $payload["data"]["articles"][0];
?>
<article class="prose">
<h1><?= htmlspecialchars($article["title"]) ?></h1>
<?php if (!empty($article["image_url"])): ?>
<img src="<?= htmlspecialchars($article['image_url']) ?>" alt="<?= htmlspecialchars($article['title']) ?>">
<?php endif; ?>
<div class="content">
<?= $article["content_html"] ?>
</div>
</article>package main
import (
"html/template"
"net/http"
)
type Article struct {
Title string
ContentHTML template.HTML
ImageURL string
}
var tmpl = template.Must(template.New("article").Parse(`
<article class="prose">
<h1>{{.Title}}</h1>
{{if .ImageURL}}<img src="{{.ImageURL}}" alt="{{.Title}}">{{end}}
<div class="content">{{.ContentHTML}}</div>
</article>
`))
func renderArticle(w http.ResponseWriter, article Article) {
tmpl.Execute(w, article)
}Styling HTML Content
The HTML output uses standard semantic tags (h1, h2, p, ul, table, etc.). Add CSS to style these elements:
/* Article styles */
.prose {
line-height: 1.75;
color: #374151;
}
.prose h2 {
font-size: 1.5rem;
font-weight: 600;
margin-top: 2rem;
margin-bottom: 1rem;
}
.prose p {
margin-bottom: 1.25rem;
}
.prose ul, .prose ol {
padding-left: 1.5rem;
margin-bottom: 1.25rem;
}
.prose table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
}
.prose th, .prose td {
border: 1px solid #e5e7eb;
padding: 0.75rem;
}
.prose code {
background: #f3f4f6;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.875em;
}Option 2: Using Markdown Content
The content_markdown field gives you raw Markdown that you parse yourself. This provides more control over rendering and is ideal for custom styling or static site generators.
First, install a Markdown parser for your platform:
# Install markdown-it (recommended for server-side)
npm install markdown-it @types/markdown-it
# Or for client-side React components
npm install react-markdown remark-gfmpip install markdowncomposer require erusev/parsedowngo get github.com/gomarkdown/markdownThen render the Markdown content:
// lib/markdown.ts
import MarkdownIt from "markdown-it";
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
});
export function renderMarkdown(markdown: string): string {
if (!markdown) return "";
return md.render(markdown);
}
// Usage in a page component
export default function ArticlePage({ article }) {
return (
<article className="prose">
<h1>{article.title}</h1>
<div
dangerouslySetInnerHTML={{
__html: renderMarkdown(article.content_markdown)
}}
/>
</article>
);
}import markdown
def render_article(article):
html_content = markdown.markdown(
article["content_markdown"],
extensions=["tables", "fenced_code"]
)
return html_content
# In your Flask route or Django view
from flask import render_template
@app.route("/article/<slug>")
def show_article(slug):
article = get_article_by_slug(slug)
html_content = render_article(article)
return render_template(
"article.html",
article=article,
content=html_content
)<?php
require "vendor/autoload.php";
$parsedown = new Parsedown();
$article = $payload["data"]["articles"][0];
$htmlContent = $parsedown->text($article["content_markdown"]);
?>
<article class="prose">
<h1><?= htmlspecialchars($article["title"]) ?></h1>
<?= $htmlContent ?>
</article>package main
import (
"html/template"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
)
func renderMarkdown(md []byte) template.HTML {
extensions := parser.CommonExtensions |
parser.AutoHeadingIDs |
parser.Tables
p := parser.NewWithExtensions(extensions)
opts := html.RendererOptions{Flags: html.CommonFlags}
renderer := html.NewRenderer(opts)
doc := p.Parse(md)
return template.HTML(markdown.Render(doc, renderer))
}
// Usage in handler
func articleHandler(w http.ResponseWriter, r *http.Request) {
article := getArticle(r.URL.Query().Get("slug"))
htmlContent := renderMarkdown([]byte(article.ContentMarkdown))
tmpl.Execute(w, map[string]any{
"Title": article.Title,
"Content": htmlContent,
})
}Troubleshooting#
Webhook Not Receiving Requests
- Verify your endpoint URL is correct and publicly accessible
- Check that your server accepts POST requests at the specified path
- Ensure your SSL certificate is valid (HTTPS is required)
- Check your firewall rules allow incoming connections
Authentication Failures
- Confirm you're checking the
Authorizationheader correctly - Verify the access token in your environment matches the one set in Skribra
- Check for trailing whitespace or newlines in your environment variables
- Ensure the header format is exactly
Bearer your_access_token(with a space)
Timeout Errors
- Ensure your endpoint responds within 30 seconds
- Process heavy operations asynchronously after sending the response
- Consider using a queue system (Redis, RabbitMQ) for time-consuming tasks
Payload Parsing Errors
- Verify your endpoint expects
application/jsoncontent type - Check that you're parsing the request body as JSON
- Validate all expected fields exist before accessing them
Tables Not Rendering
If tables appear as dashes and pipes instead of formatted tables, you're likely using a Markdown renderer that doesn't support GFM tables. Install and configure the remark-gfm plugin as shown in the Rendering section above.
Security Best Practices#
- Always verify the webhook access token to ensure the request is genuine
- Implement proper error handling and logging in your webhook receiver
- Set up monitoring for your webhook endpoint to ensure it's always available
- Consider implementing a retry mechanism for failed webhook deliveries
- Store access tokens in environment variables, never in code
- Use HTTPS for your webhook endpoint (HTTP is not supported)
Frequently Asked Questions#
Can I have multiple webhook endpoints?
Currently, each Skribra project can have one webhook endpoint. Contact support if you need to send to multiple destinations.
What happens if my endpoint is down?
Skribra automatically retries failed requests. If all attempts fail, you can manually retry from your dashboard.
Can I change the webhook URL after creation?
Yes, you can update your webhook configuration at any time in the Integrations settings.
Can I receive webhooks for article updates?
Currently, webhooks are sent when articles are published. Republishing an existing article will trigger a new webhook with the updated content.
Should I use Markdown or HTML format?
Use Markdown if you want more control over rendering and styling. Use HTML if you want pre-formatted content ready for display without additional processing.
For additional support, contact our team through the in-app chat or at [email protected].