Receive ActiveGeo content on your own site
Point an ActiveGeo schedule at an endpoint on your website. When a post is ready we POST it to your URL, signed with an HMAC so you can verify it's really from us. Your code then inserts it into your CMS — with the featured image, category and SEO meta.
How it works
- You add a webhook Connection in ActiveGeo (Settings → Connections) with your endpoint URL and a signing secret.
- You create a schedule that publishes to that connection.
- Each run, ActiveGeo generates a unique, SEO‑optimized post and POSTs it to your URL, signed.
- Your endpoint verifies the signature and inserts the post.
The payload
We send a JSON body with a content.completed event. Headers include X-Webhook-Event: content.completed and the signature:
X-Signature: sha256=<hmac_sha256(raw_request_body, your_secret)>{
"event": "content.completed",
"data": {
"id": 123,
"title": "New AI Tools For Customer Support",
"html": "<h2>...</h2><p>...</p>",
"markdown": "## ...",
"excerpt": "A short summary used as the meta description.",
"category": "AI Automation",
"heroImage": "https://.../featured.jpg",
"meta": { "title": "...", "description": "..." },
"word_count": 1180,
"faq": [
{ "q": "What is …?", "a": "A concise answer." },
{ "q": "How do I …?", "a": "Another concise answer." }
]
},
"timestamp": 1730000000000
}Use markdown if your CMS stores Markdown, or html if it stores HTML. Sideload heroImage into your media library for the featured image. When the article has a FAQ, a ready‑made faq array of { q, a } pairs is included — use it for FAQ rich results (below).
1. Verify the signature
Always verify against the raw request body (before JSON parsing), using a constant‑time compare.
Node / Next.js
import crypto from 'node:crypto'
const raw = await req.text()
const expected = 'sha256=' + crypto.createHmac('sha256', process.env.ACTIVEGEO_WEBHOOK_SECRET).update(raw).digest('hex')
const got = req.headers.get('x-signature') || ''
const ok = got.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))
if (!ok) return Response.json({ error: 'bad_signature' }, { status: 401 })PHP / Laravel
$raw = $request->getContent();
$sig = 'sha256=' . hash_hmac('sha256', $raw, env('ACTIVEGEO_WEBHOOK_SECRET'));
if (!hash_equals($sig, (string) $request->header('X-Signature'))) abort(401);Java / Spring
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = "sha256=" + Hex.encodeHexString(mac.doFinal(rawBody.getBytes(StandardCharsets.UTF_8)));
if (!MessageDigest.isEqual(expected.getBytes(), signatureHeader.getBytes())) {
return ResponseEntity.status(401).build();
}2. Insert the post
Node / Next.js + Payload CMS
This is exactly how softpact.com receives content. Place it at src/app/api/activegeo-webhook/route.ts.
import { getPayload } from 'payload'
import config from '@payload-config'
import crypto from 'node:crypto'
export const runtime = 'nodejs'
export async function POST(req) {
const raw = await req.text()
const expected = 'sha256=' + crypto.createHmac('sha256', process.env.ACTIVEGEO_WEBHOOK_SECRET).update(raw).digest('hex')
if ((req.headers.get('x-signature') || '') !== expected) {
return Response.json({ error: 'bad_signature' }, { status: 401 })
}
const { data } = JSON.parse(raw)
const payload = await getPayload({ config })
const post = await payload.create({
collection: 'posts',
overrideAccess: true,
data: {
title: data.title,
body: data.markdown, // Payload textarea/markdown
excerpt: data.excerpt,
category: data.category, // map onto your allowed values
_status: 'draft',
},
})
return Response.json({ ok: true, id: post.id })
}To set a featured image, fetch data.heroImage, create a media doc from the buffer, and reference its id (see the softpact receiver for the full version with image sideload + category mapping + unique slug).
PHP / Laravel
$post = $request->json('data');
BlogPost::create([
'title' => $post['title'],
'body' => $post['markdown'],
'excerpt' => $post['excerpt'],
'category'=> $post['category'],
'status' => 'draft',
]);WordPress (in your own theme/plugin)
add_action('rest_api_init', function () {
register_rest_route('mysite/v1', '/activegeo', [
'methods' => 'POST',
'permission_callback' => '__return_true',
'callback' => function (WP_REST_Request $r) {
$raw = $r->get_body();
$sig = 'sha256=' . hash_hmac('sha256', $raw, ACTIVEGEO_SECRET);
if (!hash_equals($sig, (string) $r->get_header('x-signature'))) {
return new WP_REST_Response(['error' => 'bad_signature'], 401);
}
$d = json_decode($raw, true)['data'];
$id = wp_insert_post([
'post_title' => sanitize_text_field($d['title']),
'post_content' => wp_kses_post($d['html']),
'post_status' => 'draft',
]);
return ['ok' => true, 'id' => $id];
},
]);
});Prefer no code? Install the ActiveGeo WordPress plugin — it does all of this for you.
Java / Spring Boot
@PostMapping("/activegeo")
public ResponseEntity<?> receive(@RequestBody String rawBody,
@RequestHeader("X-Signature") String signature) {
// ...verify HMAC as above...
JsonNode data = mapper.readTree(rawBody).get("data");
Post post = new Post();
post.setTitle(data.get("title").asText());
post.setBody(data.get("markdown").asText());
post.setStatus("draft");
repo.save(post);
return ResponseEntity.ok(Map.of("ok", true, "id", post.getId()));
}3. Emit FAQ rich results (recommended)
Blog posts include a ## FAQ section (with ### question + answer pairs) and a matching data.faq array. Emitting FAQPage JSON‑LD on the post page makes it eligible for Google's expandable FAQ rich results — a real ranking and click‑through win, and one of the few things Yoast/RankMath users pay for. You do not need to parse the content: use thefaq array we send.
Store the pairs, then render the schema
Save data.faq on the post (a JSON field), then on the post page output one script tag:
// on your post page (server component)
function FaqSchema({ faq }) {
if (!Array.isArray(faq) || !faq.length) return null
const data = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faq.map((f) => ({
'@type': 'Question',
name: f.q,
acceptedAnswer: { '@type': 'Answer', text: f.a },
})),
}
return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} />
}No stored field? Derive the pairs from the Markdown at render time — the FAQ is always a ## FAQheading followed by ### question lines, each with a short answer paragraph. Keep that structure intact when you store the post and extraction stays trivial. Validate any post URL with Google's Rich Results Test.
Security checklist
- Serve the endpoint over HTTPS.
- Verify the signature against the raw body with a constant‑time compare — never trust an unsigned request.
- Be idempotent: de‑duplicate on
data.idso a retry doesn't create a duplicate post. - Sanitize HTML before storing/rendering.
- Create posts as drafts first while you're testing.
Prefer to pull instead of receive? Generate on demand with the /v1 API, or go back to all integrations.