Webhook API Reference
SEOGrove delivers published articles to your site via signed HTTP POST. This reference covers everything you need to receive, verify, and process the payload.
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 → Integrations → Add 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.
Request format
Every request includes these headers:
Content-Type: application/json
X-SEOGrove-Event: content.published
X-SEOGrove-Signature: sha256=<hmac-hex>
User-Agent: SEOGrove/1.0
| Header | Description |
|---|---|
| X-SEOGrove-Event | content.published for articles, ping for test connections |
| X-SEOGrove-Signature | HMAC-SHA256 of the raw request body. See Signature verification. |
Payload reference
Full example payload for a content.published event:
{
"event": "content.published",
"timestamp": "2026-05-01T10:00:00Z",
"site": {
"host": "yoursite.com",
"url": "https://yoursite.com"
},
"content": {
"id": 42,
"title": "How Much Are Fines for Parking Without a Moving Permit?",
"slug": "parking-fines-without-moving-permit",
"html": "<h1>How Much Are Fines...</h1><p>...</p>",
"markdown": "# How Much Are Fines...\n\n...",
"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+...",
"tags": [],
"metadata": { /* full metadata object — see below */ }
}
}
site
| Field | Type | Description |
|---|---|---|
| host | string | Bare domain, e.g. yoursite.com |
| url | string | Full origin, e.g. https://yoursite.com |
content
| Field | Type | Description |
|---|---|---|
| id | integer | SEOGrove internal ID |
| title | string | Article title as written by SEOGrove |
| slug | string | URL-safe identifier. Use as upsert key to avoid duplicates on re-publish. |
| html | string | Article body as HTML. The H1 title is not included — your template should render it. Internal links are rewritten to absolute URLs. |
| markdown | string | Original Markdown source with absolute internal links. H1 is included here for standalone use. |
| excerpt | string | First prose paragraph, max 300 chars. Suitable for list/card previews and meta description fallback. |
| featured_image_url | string | null | Permanent 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_alt | string | null | SEO-optimised alt text: image subject + primary keyword. Use this as the alt attribute. |
| content_type | string | article, mini_tool, or programmatic_page |
| published_at | ISO 8601 | Timestamp of this publish event |
| word_count | integer | null | Approximate word count of the article body |
| primary_keyword | string | null | The target keyword this article was written for |
| seo_title | string | null | AI-optimised title variant. Null if snippet optimisation hasn't run. |
| meta_description | string | null | AI-generated meta description, typically 150–160 chars |
| tags | array | Tag strings. Empty array if none assigned. |
| metadata | object | Full metadata JSONB. Contains all of the above plus raw AI outputs. Treat as additive — new keys may appear without notice. |
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.
{
"event": "ping",
"timestamp": "2026-05-01T10:00:00Z"
}
Signature verification
Every content.published 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.
# 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.
Post model with title, body, slug, published_at")
so it generates migrations and routes that fit your schema.
I use SEOGrove (seogrove.io) to auto-generate and publish SEO articles to my site via webhook. Please implement a complete webhook receiver so my site can receive and display these articles.
## 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
User-Agent: SEOGrove/1.0
Payload structure:
{
"event": "content.published",
"timestamp": "2026-05-01T10:00:00Z",
"site": { "host": "yoursite.com", "url": "https://yoursite.com" },
"content": {
"id": 42,
"title": "Article Title",
"slug": "article-slug", <-- use as upsert key to avoid duplicates on re-publish
"html": "<p>Body HTML...</p>", <-- no H1 tag; your template renders the title as H1
"markdown": "Body markdown...",
"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",
"tags": []
}
}
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. Fields to save
title, slug, html body, excerpt, seo_title, meta_description,
featured_image_url, featured_image_alt, published_at
4. 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.
5. Return 200 OK for all successfully processed events.
## 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.
# 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"]
)
end
render json: { received: true }
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
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.
v1 — Initial release
Fields: word_count, primary_keyword,
seo_title, meta_description, tags.