Webhooks
A webhook turns every article you publish in Junia AI into an HTTP request to an address you choose. Whatever sits at that address (your own site, a headless CMS, or a Zapier, Make or n8n workflow) receives the finished article as JSON and decides what to do with it.
At a glance
- You publish an article to your webhook, by hand, on a schedule, or from an Autoblog campaign.
- Junia sends a
POSTrequest with the article as JSON. - Your endpoint checks that the request came from Junia, saves the article and replies with a
2xxstatus. - If you publish the same article again later, it arrives with
is_update: trueso you can update it instead of creating a copy.
The no-code route: Zapier, Make or n8n
You don't need to write any code to use webhooks.
- In your automation tool, start a new workflow with a webhook trigger (in Zapier it's called Catch Hook) and copy the URL it gives you.
- In Junia, open Integrations → Publish via Webhook → Connect.
- Paste the URL and set Security to Shared token.
- Click Send Test. Your automation tool now knows every field Junia sends, so you can map
title,content_htmland the rest into the next step. - Click Save Webhook.
Building your own endpoint
Step 1: add the webhook in Junia
Open Integrations → Publish via Webhook → Connect and fill in:
| Setting | What to enter |
|---|---|
| Integration Name | Anything that helps you recognise it, e.g. "Production blog". |
| Webhook URL | A public http(s) address that accepts POST requests. Local and private network addresses are rejected. |
| Security | Signed requests (recommended) or Shared token, see Step 2. |
| Secret Key | Generated for you. Copy it into your server's configuration, e.g. an environment variable called JUNIA_WEBHOOK_SECRET. You can regenerate it at any time. |
Step 2: check that requests really come from Junia
Your endpoint is public, so it should ignore requests that don't carry your secret.
| Signed requests | Shared token | |
|---|---|---|
| Header | X-Junia-Signature | Authorization: Bearer <secret> |
| What your endpoint does | Recalculates an HMAC SHA-256 of the request body with your secret and compares | Compares the header with your secret |
| The secret travels with the request | No | Yes |
| Also proves the body wasn't changed | Yes | No |
| Best for | Your own code | No-code tools |
Every request also carries Content-Type: application/json, User-Agent: Junia-Webhooks/1.0 and a unique X-Junia-Delivery id you can log.
About signed requests: the signature covers the exact bytes Junia sent, so calculate it from the unparsed request body. Parsing the JSON and turning it back into a string can change spacing or escaping, and the signature would no longer match. The examples below read the raw body first and then parse it.
Next.js (App Router)
// app/api/junia/route.js
import crypto from "crypto";
export async function POST(req) {
const body = await req.text();
const signature = crypto
.createHmac("sha256", process.env.JUNIA_WEBHOOK_SECRET)
.update(body)
.digest("hex");
if (req.headers.get("x-junia-signature") !== signature) {
return new Response("Forbidden", { status: 403 });
}
const article = JSON.parse(body);
if (!article.test) {
await saveArticle(article); // see "Keeping articles in sync"
}
return Response.json({ ok: true });
}
Express
import crypto from "crypto";
import express from "express";
const app = express();
// Keep the raw bytes for the signature check; req.body is still parsed JSON.
app.use(express.json({ verify: (req, res, buf) => (req.rawBody = buf) }));
app.post("/junia", async (req, res) => {
const signature = crypto
.createHmac("sha256", process.env.JUNIA_WEBHOOK_SECRET)
.update(req.rawBody)
.digest("hex");
if (req.get("x-junia-signature") !== signature) {
return res.sendStatus(403);
}
if (!req.body.test) {
await saveArticle(req.body);
}
res.json({ ok: true });
});
For a constant-time comparison, use crypto.timingSafeEqual instead of !==.
PHP
<?php
$body = file_get_contents('php://input');
$signature = hash_hmac('sha256', $body, getenv('JUNIA_WEBHOOK_SECRET'));
if (!hash_equals($signature, $_SERVER['HTTP_X_JUNIA_SIGNATURE'] ?? '')) {
http_response_code(403);
exit;
}
$article = json_decode($body, true);
if (empty($article['test'])) {
save_article($article);
}
header('Content-Type: application/json');
echo json_encode(['ok' => true]);
Using a shared token instead
Replace the signature check with a header comparison. Your framework's parsed body can be used directly.
if (req.headers.authorization !== `Bearer ${process.env.JUNIA_WEBHOOK_SECRET}`) {
return res.sendStatus(403);
}
if (!hash_equals('Bearer ' . getenv('JUNIA_WEBHOOK_SECRET'), $_SERVER['HTTP_AUTHORIZATION'] ?? '')) {
http_response_code(403);
exit;
}
Step 3: send a test
Before (or after) saving, click Send Test in the webhook settings. Junia posts a sample article with "test": true and shows you the status code, the response time and whatever your endpoint replied. Skip saving when test is true, as in the examples above.
Step 4: save and publish
Click Save Webhook. Your webhook now appears everywhere you can publish: the Publish menu in the editor, bulk generation and Autoblog campaigns.
Keeping articles in sync
Every article keeps the same id and slug for as long as it is sent to the same webhook. That lets you treat each delivery as "here is the latest version of this article" rather than "here is a new article".
When you'll receive an update
- You edit an article in Junia, open Publish → Webhook and click Update Article.
- An article that was already delivered is published to the same webhook again, e.g. from a schedule.
Updates arrive with "is_update": true. The title, content, images and meta fields reflect your latest edits, and published_url contains the URL your endpoint returned the first time (see "Responding to Junia").
How to store articles
- Keep Junia's
idnext to each article you save (for example in ajunia_idcolumn with a unique index). - On every delivery, look the article up by
id. Update it if it exists, create it if not. - Use
is_updatefor logging or notifications, not to decide whether to insert. That way a missed or repeated delivery can never create a duplicate.
In SQL, that's a single statement:
INSERT INTO articles (junia_id, slug, title, body_html, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (junia_id) DO UPDATE
SET title = EXCLUDED.title,
body_html = EXCLUDED.body_html,
updated_at = now();
The slug is deliberately left out of the update. It never changes after the first delivery, so links to the article keep working even if you rename it.
Payload reference
{
"id": "5f0c1c2e-8a4b-4d57-9b8e-2f1d7c3a9e10",
"title": "10 Ways to Repurpose a Blog Post",
"content_html": "<p>One good article can become a week of content...</p><h2>1. Turn it into a newsletter</h2>...",
"content_markdown": "One good article can become a week of content...\n\n## 1. Turn it into a newsletter\n...",
"slug": "10-ways-to-repurpose-a-blog-post",
"meta_title": "10 Ways to Repurpose a Blog Post (2026 Guide)",
"meta_description": "Get more from every article with these ten repurposing ideas.",
"status": "published",
"featured_image": "https://example.com/images/repurpose.png",
"featured_image_alt": "Blog post turned into social media posts",
"published_url": null,
"scheduled_date": null,
"published_at": "2026-09-23T10:30:00.000Z",
"is_update": false,
"test": false,
"language": "English",
"tags": ["content marketing", "repurposing"],
"faqs": [
{ "question": "How often should I repurpose content?", "answer": "Whenever an article is still getting traffic." }
]
}
| Field | Type | Notes |
|---|---|---|
id | string | Junia's article id. Never changes, use it as your key. |
title | string | The article's H1. |
content_html | string | Body as HTML, without the title and featured image (they have their own fields). Other images are included with their URLs. |
content_markdown | string | The same body as Markdown. |
slug | string | URL slug. Fixed after the first delivery to this webhook. |
meta_title, meta_description | string | SEO metadata. |
status | string | published, or draft if you picked Draft when publishing. |
featured_image, featured_image_alt | string or null | Featured image URL and its alt text. |
published_url | string or null | The article's public URL, if your endpoint returned one on an earlier delivery. |
scheduled_date | string or null | The date you scheduled the article for, if any. |
published_at | string | When this request was sent (ISO 8601). Rejecting old values protects against replayed requests. |
is_update | boolean | true if this article was delivered to this webhook before. |
test | boolean | true only for Send Test requests. |
language | string | Article language, e.g. English. |
tags | string[] | Tags on the article. |
faqs | object[] | { question, answer } pairs. |
Responding to Junia
- Reply with any
2xxstatus within 30 seconds to confirm delivery. Anything else counts as a failure. - Redirects are not followed, so point the webhook at the final URL.
- Optionally include the article's public address in a JSON response:
{ "url": "https://example.com/blog/10-ways-to-repurpose-a-blog-post" }. Junia opens it after publishing, can submit it to search engines for indexing, and sends it back aspublished_urlin later updates.
When something goes wrong
| What you see | Likely cause | What to do |
|---|---|---|
| Your endpoint returns 401 or 403 | The secret or security type doesn't match | Copy the Secret Key again and check the Security setting matches your code. With signed requests, make sure you sign the raw body. |
| 403 before your code even runs | A firewall, bot protection or security plugin blocked the request | Allow POST requests with the user agent Junia-Webhooks/1.0 to your webhook path. |
| "Webhook URL must be publicly accessible" | The URL points to localhost or a private network | Deploy your endpoint, or expose it with a tunnel such as ngrok while developing. |
| Status 3xx | Your URL redirects, e.g. http to https or a missing trailing slash | Use the final URL. |
| "No response within 30 seconds" | Your endpoint does slow work before replying | Reply first, then process the article in the background. |
| Duplicate articles on your site | Saving by title or creating a new row on every request | Upsert on id, see "Keeping articles in sync". |
When a scheduled or Autoblog delivery fails, Junia emails you so you can publish the article again.
Questions
Can one article go to several webhooks? Yes. Add as many webhooks as you like and publish to each one. Updates are tracked separately per webhook.
Can I send drafts?
Yes. Choose Draft when publishing and the payload arrives with "status": "draft". It's up to your endpoint to keep it unpublished.
Is my content encrypted?
Use an https:// URL and the request is encrypted in transit. Signed requests also make sure nobody changed the content along the way.
What if I regenerate the secret? Requests are signed with the new secret straight away, so update your server's configuration at the same time.
Does the payload include images?
Yes. The featured image has its own fields, and every other image stays in content_html (and content_markdown) with its original URL.