← Dashboard / Docs / Webhooks

Webhook API Reference

SEOGrove delivers published articles and free tools to your site via signed HTTP POST. This reference covers everything you need to receive, verify, and process the payload.

Current version: v1 Last updated: 20 May 2026 Tool rendering contract ->

Overview

When SEOGrove publishes an article — either manually approved or autonomously — it makes a single HTTP POST to the webhook URL you configure. The request body is JSON and is signed with an HMAC-SHA256 signature so you can verify it actually came from SEOGrove.

Method

POST

Content-Type

application/json

Timeout

30 seconds · 3 attempts

Setup

Go to your site dashboard → IntegrationsAdd integration → select Webhook / Custom API. Enter your endpoint URL and optionally generate a secret key for signature verification. Hit Test connection — SEOGrove sends a ping and checks for a 2xx response.

Tip: use the AI coding prompt in the integration form to generate a complete, verified receiver endpoint for your stack in seconds.

Request format

Every request includes these headers:

HTTP Headers
Content-Type: application/json
X-SEOGrove-Event: content.published, tool.published, content.deleted, tool.deleted, or ping
X-SEOGrove-Signature: sha256=<hmac-hex>
User-Agent: SEOGrove/1.0
Header Description
X-SEOGrove-Eventcontent.published for article upserts, tool.published for free tool upserts, content.deleted/tool.deleted for deletion, ping for test connections
X-SEOGrove-SignatureHMAC-SHA256 of the raw request body. See Signature verification.

Payload reference

Full example payload for a content.published event. Article HTML is semantic and intentionally unstyled for broad CMS compatibility; render the title, featured image, and JSON-LD schema separately in your template. Free tools use the same envelope with event: "tool.published", content_type: "mini_tool", and a populated content.tool object.

JSON Payload
{
  "event": "content.published",
  "timestamp": "2026-05-01T10:00:00Z",
  "site": {
    "host": "yoursite.com",
    "url": "https://yoursite.com",
    "locale": "en"
  },
  "content": {
    "id": 42,
    "title": "How Much Are Fines for Parking Without a Moving Permit?",
    "slug": "parking-fines-without-moving-permit",
    "locale": "en",
    "canonical_path": "/parking-fines-without-moving-permit",
    "html": "<p>Parking without a moving permit...</p><h2>Fine ranges</h2><p>...</p>",
    "markdown": "# How Much Are Fines...\n\n...",
    "schema_json": { /* BlogPosting JSON-LD object */ },
    "excerpt": "Parking without a moving permit can result in fines...",
    "featured_image_url": "https://cdn.seogrove.io/abc123.png",
    "featured_image_alt": "Moving truck parked on a city street — parking fines moving permit",
    "content_type": "article",
    "published_at": "2026-05-01T10:00:00Z",
    "word_count": 1686,
    "primary_keyword": "parking fines moving permit",
    "seo_title": "Moving Permit Parking Fines: What You'll Pay in 2026",
    "meta_description": "Parking without a moving permit risks fines of $65–$250+...",
    "category": "Permits",
    "tags": [],
    "metadata": { /* full metadata object — see below */ },
    "tool": null
  }
}

site

FieldTypeDescription
hoststringBare domain, e.g. yoursite.com
urlstringFull origin, e.g. https://yoursite.com
localestringDefault site locale used for generated content, e.g. en, es, fr, or pt-BR

content

FieldTypeDescription
idintegerSEOGrove internal ID
titlestringArticle title as written by SEOGrove
slugstringURL-safe identifier. Use as upsert key to avoid duplicates on re-publish.
localestringLocale for this content item. May differ from site.locale when a generation job uses a language override.
canonical_pathstringPreferred path on your site. Free tools use /tools/:slug.
htmlstringSemantic, unstyled article body HTML. The H1 title, featured image, and JSON-LD schema are not included — your template should render them separately. Internal links are rewritten to absolute URLs.
markdownstringOriginal Markdown source with absolute internal links. H1 is included here for standalone use.
schema_jsonobject | nullGenerated JSON-LD schema for this article, including the content locale in inLanguage. Render it in a script[type=\"application/ld+json\"] tag in your page head/body; do not print it as visible article content.
excerptstringFirst prose paragraph, max 300 chars. Suitable for list/card previews and meta description fallback.
featured_image_urlstring | nullPermanent CDN URL for the hero image. Null if no image was generated. The HTML body does not embed this — you must render it explicitly.
featured_image_altstring | nullSEO-optimised alt text: image subject + primary keyword. Use this as the alt attribute.
content_typestringarticle, mini_tool, or programmatic_page
published_atISO 8601Timestamp of this publish event
word_countinteger | nullApproximate word count of the article body
primary_keywordstring | nullThe target keyword this article was written for
seo_titlestringCurrent title to use for SEO tags. Matches title after a snippet variant is applied.
meta_descriptionstring | nullAI-generated meta description, typically 150–160 chars
categorystring | nullPrimary category name when present in metadata. Falls back to the first category in metadata.categories.
tagsarrayTag strings. Empty array if none assigned.
metadataobjectFull metadata JSONB. Contains all of the above plus raw AI outputs. Treat as additive — new keys may appear without notice.
toolobject | nullPresent for mini_tool payloads. Includes the native tool manifest, native HTML, renderer version, schema, copy, CTA, JSON-LD, and an iframe fallback for locked-down CMSs.
Tool payload fragment
{
  "event": "tool.published",
  "content": {
    "content_type": "mini_tool",
    "slug": "moving-cost-calculator",
    "canonical_path": "/tools/moving-cost-calculator",
    "html": "<p>...</p><section data-sg-tool>...</section>",
    "tool": {
      "type": "cost_estimator",
      "archetype": "lead_gen",
      "renderer_version": 3,
      "manifest": { "version": 3 },
      "native_html": "<section data-sg-tool>...</section>",
      "iframe_fallback": "<iframe src=\"https://seogrove.io/embed/tools/123\" ...></iframe>"
    }
  }
}

Ping event

When you click Test connection in the integration settings, SEOGrove sends a lightweight ping. Return 200 OK immediately — no signature verification required for pings.

Ping body
{
  "event": "ping",
  "timestamp": "2026-05-01T10:00:00Z"
}

Signature verification

Every non-ping webhook request is signed. The X-SEOGrove-Signature header contains sha256=<hex> where the hex is HMAC-SHA256(secret, raw_request_body). Always use a timing-safe comparison to prevent timing attacks.

Important: read the raw body before parsing JSON. The signature is computed over the raw bytes — parsing first may alter whitespace and break verification.
# Rails / Rack
def valid_signature?(request)
  secret   = ENV["SEOGROVE_WEBHOOK_SECRET"]
  raw_body = request.body.read
  request.body.rewind

  expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
  received = request.headers["X-SEOGrove-Signature"].to_s

  ActiveSupport::SecurityUtils.secure_compare(expected, received)
end

AI setup prompt

Copy this prompt and paste it into Claude Code, Cursor, or Windsurf in your own project. It gives your AI assistant everything it needs to implement a complete, production-ready webhook receiver — including image display, SEO meta tags, and upsert logic — in one shot. Fill in your tech stack at the bottom before sending.

Tip: also tell the AI where your posts/articles are stored (e.g. "I have a Post model with title, body, slug, published_at") so it generates migrations and routes that fit your schema.
AI prompt — copy & paste into Claude Code / Cursor
I use SEOGrove (seogrove.io) to auto-generate, publish, and prune SEO articles and free tools on my site via webhook. Please implement a complete webhook receiver so my site can receive, display, update, and delete articles and tools.

## What SEOGrove sends

POST to my webhook URL with these headers:
  Content-Type: application/json
  X-SEOGrove-Signature: sha256=<hmac-hex>   (HMAC-SHA256 of the raw request body)
  X-SEOGrove-Event: content.published, tool.published, content.deleted, tool.deleted, or ping
  User-Agent: SEOGrove/1.0

Publish payload structure:
{
  "event": "content.published",
  "timestamp": "2026-05-01T10:00:00Z",
  "site": { "host": "yoursite.com", "url": "https://yoursite.com", "locale": "en" },
  "content": {
    "id": 42,
    "title": "Article Title",
    "slug": "article-slug",           <-- use as upsert key to avoid duplicates on re-publish
    "locale": "en",
    "canonical_path": "/article-slug", <-- tools use /tools/:slug
    "external_id": null,
    "external_url": null,
    "html": "<p>Body HTML...</p>",    <-- semantic unstyled HTML; no H1, image, or schema
    "markdown": "Body markdown...",
    "schema_json": { "@context": "https://schema.org", "@type": "BlogPosting", "inLanguage": "en" },
    "excerpt": "First paragraph, max 300 chars",
    "featured_image_url": "https://cdn.seogrove.io/image.png",  <-- null if no image; NOT in html body
    "featured_image_alt": "Descriptive alt text — primary keyword",
    "content_type": "article",
    "published_at": "2026-05-01T10:00:00Z",
    "word_count": 1500,
    "primary_keyword": "target keyword",
    "seo_title": "SEO-optimised title variant",   <-- use as <title> tag if present
    "meta_description": "150-160 char description",
    "category": "Permits",
    "tags": []
  }
}

For "tool.published", content.content_type is "mini_tool", content.canonical_path should be used as the page path, and content.html contains the native tool page body. Store tools separately from blog posts if your app has a separate Tool/Page model; otherwise create a page at /tools/:slug. Receivers should store the signed native tool artifact from content.tool.native_html and render it as trusted first-party HTML, or render content.tool.manifest with their own component. Use content.tool.iframe_fallback only for locked-down CMSs that cannot run native tool assets.

Delete payload structure:
{
  "event": "content.deleted",
  "timestamp": "2026-05-01T10:00:00Z",
  "site": { "host": "yoursite.com", "url": "https://yoursite.com", "locale": "en" },
  "content": {
    "id": 42,
    "title": "Article Title",
    "slug": "article-slug",
    "locale": "en",
    "external_id": null,
    "external_url": "https://example.com/blog/article-slug"
  }
}

SEOGrove also sends a lightweight ping to verify the connection:
{ "event": "ping", "timestamp": "..." }
Respond 200 immediately — no signature check needed for pings.

## Requirements

1. Signature verification
   - Read the raw request body BEFORE parsing JSON (parsing can alter whitespace and break the sig)
   - Compute: expected = "sha256=" + HMAC-SHA256(SEOGROVE_WEBHOOK_SECRET env var, raw_body)
   - Use a timing-safe comparison to prevent timing attacks
   - Return 401 if invalid

2. Upsert logic
   - Use slug as the unique key: find_or_initialize_by(slug: ...) so re-publishing updates the existing record

3. Delete logic
   - If event == "content.deleted", find the article by slug and delete it or unpublish it
   - If your system stores external IDs or canonical URLs, also support lookup by external_id or external_url
   - Deletes must be idempotent: if the article is already missing, still return 200 OK

4. Fields to save
   title, slug, html body, schema_json, excerpt, seo_title, meta_description,
   featured_image_url, featured_image_alt, published_at

5. Page template requirements
   - Render title as the <h1> — the html body does NOT include one
   - <title> tag: use seo_title if present, otherwise title
   - <meta name="description">: use meta_description
   - Display featured image ABOVE the article body:
       <img src="{{ featured_image_url }}" alt="{{ featured_image_alt }}">
     Only render the image block if featured_image_url is present.
   - Render schema_json separately as JSON-LD:
       <script type="application/ld+json">{{ schema_json_as_json }}</script>
   - Apply your own article CSS for headings, paragraphs, lists, tables, links, images, and blockquotes. SEOGrove does not send presentation classes in article HTML so it works across Rails, WordPress, Webflow, static sites, and custom CMS receivers.

6. Return 200 OK for all successfully processed events.
   - Include the final published URL when available:
     { "received": true, "url": "https://example.com/blog/article-slug" }
   - For deletes, return:
     { "received": true, "deleted": true }

## My tech stack
[DESCRIBE YOUR STACK HERE — e.g. "Ruby on Rails 8 with PostgreSQL", "Next.js 14 with Prisma", "Laravel 11", "Django 5 with SQLite", etc.]

Complete endpoint example

A minimal but production-ready Rails endpoint. Copy and adapt to your stack.

Ruby on Rails
# app/controllers/hooks_controller.rb
class HooksController < ActionController::Base
  skip_before_action :verify_authenticity_token

  def seogrove
    raw_body = request.body.read
    payload  = JSON.parse(raw_body)
    event    = payload["event"]

    # Always respond 200 to pings immediately
    if event == "ping"
      render json: { received: true } and return
    end

    unless valid_signature?(raw_body)
      render json: { error: "Invalid signature" }, status: :unauthorized and return
    end

    if event == "content.published"
      content = payload["content"]
      post = Post.find_or_initialize_by(slug: content["slug"])
      post.update!(
        title:              content["title"],
        body:               content["html"],
        excerpt:            content["excerpt"],
        seo_title:          content["seo_title"],
        meta_description:   content["meta_description"],
        featured_image_url: content["featured_image_url"],
        featured_image_alt: content["featured_image_alt"],
        published_at:       content["published_at"]
      )
      render json: { received: true, url: post_url(post) } and return
    elsif event == "tool.published"
      content = payload["content"]
      tool = ToolPage.find_or_initialize_by(slug: content["slug"])
      tool.update!(
        title: content["title"],
        path: content["canonical_path"],
        body: content["html"],
        tool_payload: content["tool"],
        published_at: content["published_at"]
      )
      render json: { received: true, url: tool_url(tool) } and return
    elsif event == "content.deleted" || event == "tool.deleted"
      content = payload["content"]
      post = Post.find_by(slug: content["slug"])
      deleted = post.present?
      post&.destroy!
      render json: { received: true, deleted: deleted } and return
    end

    render json: { error: "Unsupported event" }, status: :unprocessable_entity
  rescue JSON::ParserError
    render json: { error: "Invalid JSON" }, status: :unprocessable_entity
  end

  private

  def valid_signature?(raw_body)
    secret   = Rails.application.credentials.dig(:seogrove, :webhook_secret)
    expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
    ActiveSupport::SecurityUtils.secure_compare(expected,
      request.headers["X-SEOGrove-Signature"].to_s)
  end
end

Retries & errors

SEOGrove retries failed deliveries up to 3 times, with a 30-second wait between each attempt. A delivery is considered failed if your endpoint:

  • × Returns a non-2xx HTTP status
  • × Does not respond within 30 seconds
  • × Is unreachable (DNS failure, connection refused)

After 3 failures the article is marked Failed in SEOGrove. You can view the raw error, get an AI diagnosis, and re-publish at any time from the article page.

Changelog

2026-05-16

Fields: schema_json added

Webhook article HTML is now schema-free, unstyled semantic markup. Render content.schema_json, content.title, and featured image fields separately in your template.

2026-05-12

Fields: category added

Webhook receivers can now read the primary category directly from content.category instead of parsing metadata.

2026-05-06

Free tools: tool.published and tool.deleted

Free tools now publish as mini_tool content with canonical_path, a native manifest, native HTML, and an iframe fallback for locked-down CMSs.

2026-05-02

Fields: featured_image_alt added

SEO-optimised alt text now included alongside featured_image_url. The html field no longer includes the H1 — your template should render the title.

2026-05-01

v1 — Initial release

Fields: word_count, primary_keyword, seo_title, meta_description, tags.