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." }
],
"schemaVersion": 2,
"schema": [ { "@context": "https://schema.org", "@type": "Article", "…": "…" } ],
"sources": [ { "title": "SE Ranking study", "url": "https://…" } ],
"entities": ["OAI-SearchBot", "Perplexity", "Schema.org"],
"about": [ { "name": "Generative engine optimization", "url": "https://www.wikidata.org/wiki/…" } ],
"canonicalUrl": "https://original.com/posts/slug",
"isOriginal": false,
"datePublished": "2026-08-01T09:00:00.000Z",
"dateModified": "2026-08-03T11:20:00.000Z",
"primaryKeyword": "generative engine optimization",
"tldr": "A short standalone answer.",
"geoScore": 82,
"seoScore": 91,
"authorIdentity": { "name": "…", "jobTitle": "…", "sameAs": ["…"] },
"publisherIdentity": { "name": "…", "url": "…", "sameAs": ["…"] }
},
"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).
What schemaVersion: 2 adds
Everything above the schemaVersion line is v1 and is unchanged — a receiver written against the original spec keeps working untouched. The v2 fields are things we already computed while writing the article and used to throw away, which meant every receiver either re‑derived them badly or shipped without them.
| Field | What to do with it |
|---|---|
schema | Ready‑to‑emit JSON‑LD. Store the array and output one script tag per object in the page <head>. Do not rebuild it — it already carries the author as a Person with sameAs, the cited sources as citation[], the entity URIs as about[], and isBasedOn when the post is a copy. |
canonicalUrl / isOriginal | When isOriginal is false, another URL owns this content — emit <link rel="canonical"> pointing at canonicalUrl. Skipping this leaves two near‑identical URLs competing for the same passages, and a retrieval engine picks one arbitrarily. |
dateModified | Set the post's modified date from this, not from the time you received the webhook. It only ever changes on a real content change. Bumping a date without changing the content is a freshness signal that is not true. |
datePublished | Use it instead of new Date(). Overwriting it re‑dates the article every time it is re‑sent. |
sources / entities / about | Already inside schema; sent separately so you can reuse them for tagging or internal linking without re‑parsing the JSON‑LD. Only about entries that carry a URI are emitted — a bare name is a worklist item, not a claim. |
geoScore / seoScore | Informational. Both may be null, which means unscored — not zero, and not bad. |
Emitting the schema
// store it verbatim on the post…
await db.post.create({ data: { …fields, schema: data.schema, canonicalUrl: data.canonicalUrl } })
// …then on the post page, one tag per object
{(post.schema ?? []).map((block, i) => (
<script
key={i}
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(block).replace(/</g, '\u003c') }}
/>
))}The replace matters: a title containing </script> would otherwise break out of the tag. Our own jsonLdScripts() does the same thing for the same reason.
One caveat, stated plainly. Schedules delivered through Automations → Webhooks send schemaVersion: 1 — no schema, no canonicalUrl, no identities — because that path does not have the connection context needed to compute them. Connect the destination under Settings → Connections to receive v2.
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.