Skip to content
ActiveGeo

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

  1. You add a webhook Connection in ActiveGeo (Settings → Connections) with your endpoint URL and a signing secret.
  2. You create a schedule that publishes to that connection.
  3. Each run, ActiveGeo generates a unique, SEO‑optimized post and POSTs it to your URL, signed.
  4. 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.id so 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.

Webhook & API cookbook — receive ActiveGeo content