Developer documentation · API v1
Your posts, your pipeline, over JSON.
Everything the dashboard does is available here: pull grounded, cited posts into any CMS, approve or reject them, steer the keyword map, schedule slots, read Search Console metrics and receive signed webhooks on every event.
Overview
The base URL is https://blog.fourdm.services/api/v1. Requests and responses are JSON; timestamps are ISO-8601; ids are integers. A key sees only the sites it belongs to: a site key sees one site, an account key sees every site on the account.
A typical integration does three things: pull status=published posts on a schedule or on a post.published webhook, render them, then call ack with the live URL so the canonical points at your page rather than the hosted copy.
Authentication
Send your key as a bearer token on every request. Keys are shown once when created and only a hash is stored, so keep them in a secret store, never in a browser.
Authorization: Bearer sk_site_9_…
| Key | Format | Reach | Where |
|---|---|---|---|
| Site key | sk_site_<id>_… | One site. Scopes: posts read and write, sites read, suggestions, metrics, webhooks. | Dashboard → site → Integrations → Create key |
| Account key | sk_acct_… | Every site on the account, with explicit scopes. | Issued on request for agency accounts |
| Publishable key | pk_<id>_… | The embed script only; safe in page HTML. | Site knowledge → Embed snippet |
Scopes: posts:read, posts:write, sites:read, sites:write, keywords:write, suggestions:write, webhooks:write, metrics:read. A key without a required scope gets 403.
Conventions
- Rate limit: 120 requests per minute per key. Every response carries
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset; over the limit you get429withRetry-After. - Idempotency: creating POSTs (
ack,generate,webhooks) accept anIdempotency-Keyheader. Repeating the same key within 24 hours returns the first response unchanged. - Pagination: the posts list is cursor based. Pass the returned
next_cursoruntilhas_moreis false. - Caching: the posts list returns a weak
ETag; send it back asIf-None-Matchto get304when nothing changed. - Long work: generation and re-indexing return
202with ajob_id; pollGET /jobs/{id}.
Posts
The post object
One shape everywhere: every posts endpoint and every post.* webhook payload returns it.
| Field | Type | Meaning |
|---|---|---|
| id, site_id | int | Post and site identifiers. |
| status | string | draft, pending_review, scheduled, published, failed. |
| slug, title, h1 | string | URL slug and headings. |
| meta_title, meta_description, excerpt | string | SEO meta, trimmed at word boundaries; excerpt is the first 170 characters of the description. |
| content_html, content_markdown | string | The body with table of contents, key-takeaways box, FAQ and internal links. Render the HTML, or the Markdown if your CMS prefers it. |
| word_count | int | Body length. |
| primary_keyword, intent | string | The search the post targets and its intent. |
| category_id, author_id | int | null | Your category and byline author, when set. |
| schema_jsonld | string | null | Article and FAQPage JSON-LD, ready to place in a <script type="application/ld+json">. |
| banner_url | string | null | The generated 1680×882 banner (a .webp sits beside every .jpg). |
| canonical_url | string | null | Set by ack; points at your live page. |
| scores, publishable | object, bool | The editor model's review: overall plus issues; publishable is the gate's verdict. |
| published_at, approved_at, refreshed_at, created_at, updated_at | datetime | null | ISO-8601, UTC. |
List posts
| Query | Type | Meaning |
|---|---|---|
| status | string | Filter by status, usually published. |
| updated_since | ISO-8601 | Only posts changed since this time; ideal for a sync job. |
| cursor | int | The next_cursor from the previous page. |
| limit | int | 1 to 100, default 50. |
curl "https://blog.fourdm.services/api/v1/sites/9/posts?status=published&limit=2" \
-H "Authorization: Bearer $SNOWGLOBE_KEY"
Get a post
The full post object. 404 when the post belongs to a site the key cannot see.
curl "https://blog.fourdm.services/api/v1/posts/23" \
-H "Authorization: Bearer $SNOWGLOBE_KEY"
Acknowledge delivery
Tell us the post is live on your site. The remote_url becomes the canonical URL (unless the post has been unpublished), and a post.published event is emitted with the updated post.
curl -X POST "https://blog.fourdm.services/api/v1/posts/23/ack" \
-H "Authorization: Bearer $SNOWGLOBE_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: deploy-2026-09-24-23" \
-d '{"remote_url": "https://seodada.com/blog/technical-seo-audit-checklist"}'
Approve, reject, refresh, unpublish
| Call | Body | Effect |
|---|---|---|
| POST /posts/{id}/approve | — | Approves a post waiting in approve-first mode; it publishes at its slot time. |
| POST /posts/{id}/reject | {"reason"} | Rejects it; the slot regenerates. 409 already_published if it already went live. |
| POST /posts/{id}/refresh | — | Queues a refresh: weak sections are rewritten and the post republishes at the same URL. |
| DELETE /posts/{id} | — | Unpublishes: status back to draft, the hosted copy hidden, the keyword released. Emits post.unpublished. |
curl -X POST "https://blog.fourdm.services/api/v1/posts/23/reject" \
-H "Authorization: Bearer $SNOWGLOBE_KEY" \
-H "Content-Type: application/json" \
-d '{"reason": "Wrong product name in section 3"}'
Automation
| Field | Type | Meaning |
|---|---|---|
| mode | string | full_auto, approve_first or draft_only. Full auto stays locked until the site's 30-day quality average clears the bar (trust_state). |
| timezone, publish_time | IANA, HH:MM | When posts publish, in the site's local time. |
| weekdays | int[] | 0 = Monday … 6 = Sunday. |
| targets | string[] | Any of pull, wordpress, webhook. |
| paused | bool | Stops generation without losing settings. |
| posts_per_week, auto_refresh, auto_approve_after_hours, cadence_target | read-only here | Set from the dashboard. |
curl -X PATCH "https://blog.fourdm.services/api/v1/sites/9/automation" \
-H "Authorization: Bearer $SNOWGLOBE_KEY" \
-H "Content-Type: application/json" \
-d '{"mode": "approve_first", "publish_time": "06:00", "weekdays": [0, 2, 4], "targets": ["wordpress", "webhook"]}'
Slots
| Call | Body or query | Effect |
|---|---|---|
| GET /sites/{id}/slots | ?from=&to= | The calendar: one slot per scheduled day with date, status, post_id, publish_at. |
| POST /sites/{id}/slots | {"date", "cluster_id?"} | Adds a slot on that date at the site's publish time; 409 slot_exists if the day is taken. |
| DELETE /slots/{slot_id} | — | Skips a scheduled slot. |
Generate now and jobs
Queues one post outside the schedule. Pass a keyword to choose the topic; leave it out and the keyword map picks the next one. Poll the job until status is done and read post_id.
curl -X POST "https://blog.fourdm.services/api/v1/sites/9/generate" \
-H "Authorization: Bearer $SNOWGLOBE_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: cwv-2026-09-24" \
-d '{"keyword": "core web vitals for content sites"}'
{ "id": "184", "kind": "generate", "status": "done", "site_id": 9, "post_id": 25, "error": null }
Sites and keywords
| Call | Body or query | Effect |
|---|---|---|
| GET /sites | — | The sites this key can see: id, name, url, language, description, house_style, active. |
| GET /sites/{id} | — | One site. |
| PATCH /sites/{id} | {"name", "description", "house_style", "language"} | Voice and language changes apply to the next post. |
| POST /sites/{id}/reindex | — | Re-crawls the site into the knowledge index. 202 with a job id. |
| GET /sites/{id}/clusters | ?status= | The keyword map: primary, secondaries, intent, format, priority, status, money_url. |
| POST /clusters/{id}/approve · /ban | — | Plan a cluster next, or keep it out. Scope keywords:write. |
| POST /sites/{id}/seeds | {"keywords": [...]} | Adds seed keywords the next backlog refresh expands. |
curl -X POST "https://blog.fourdm.services/api/v1/sites/9/seeds" \
-H "Authorization: Bearer $SNOWGLOBE_KEY" \
-H "Content-Type: application/json" \
-d '{"keywords": ["log file analysis", "hreflang for multi-language sites"]}'
Suggestions and metrics
| Call | Query | Effect |
|---|---|---|
| GET /sites/{id}/suggestions | ?status=open | The Brain's advice for the site: type, title, detail, impact, effort, priority, post_id. |
| POST /suggestions/{id}/accept · /dismiss | — | Accepting a refresh suggestion queues the refresh. Scope suggestions:write. |
| GET /sites/{id}/metrics | ?from=&to= | Search Console rows per post and period: clicks, impressions, ctr, position. Scope metrics:read. |
curl "https://blog.fourdm.services/api/v1/sites/9/metrics?from=2026-09-01&to=2026-09-30" \
-H "Authorization: Bearer $SNOWGLOBE_KEY"
Webhooks
Register a URL and we POST a signed JSON envelope for the events you choose. Failed deliveries retry at 1, 5, 30, 120, 360 and 1440 minutes; every attempt is visible in the delivery log and can be replayed from the dashboard or the API.
| Call | Body | Effect |
|---|---|---|
| GET /webhooks | — | Your webhooks. |
| POST /webhooks | {"url", "events?", "site_id?"} | 201 with the webhook and its secret, shown once. No events means all of them. |
| DELETE /webhooks/{id} | — | Removes it. |
| GET /webhooks/{id}/deliveries | — | Last 100 deliveries: event_type, status, attempts, response_code, last_error, next_attempt_at. |
| POST /webhooks/{id}/deliveries/{did}/replay | — | Sends that delivery again now. |
curl -X POST "https://blog.fourdm.services/api/v1/webhooks" \
-H "Authorization: Bearer $SNOWGLOBE_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: hook-seodada-1" \
-d '{"url": "https://hooks.seodada.com/snowglobe", "events": ["post.published", "post.updated"], "site_id": 9}'
Events: post.generated, post.pending_review, post.approved, post.published, post.updated, post.failed, post.unpublished, slot.skipped, site.indexed, suggestion.created, budget.threshold, connection.failed.
Envelope:
{
"id": "evt_9f2c1a7e4b3d",
"type": "post.published",
"created_at": "2026-09-24T00:30:12Z",
"data": { "post": { "id": 23, "site_id": 9, "status": "published", "…": "…" } }
}
Verifying signatures
Every delivery carries X-Snowglobe-Signature: t=<unix>,v1=<hex>, where v1 is HMAC-SHA256 of "{t}.{raw body}" with your webhook secret. Reject anything older than five minutes and compare in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(header, secret, rawBody) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
WordPress and WooCommerce plugin
If your site runs WordPress, you do not need to write against the API at all. The plugin receives each post the moment it is published through a signed webhook it registers itself, with a pull every 15 minutes as a safety net. It creates the post once and updates the same post on later edits, refreshes or a new banner, copies the banner and the infographics into your media library, sets the meta title and description for Yoast SEO and Rank Math, prints the Article and FAQPage JSON-LD, and acknowledges the live URL so the canonical points at your site. On a WooCommerce store it also sends your products to Snowglobe, so posts quote today's prices and link the right product.
Install and connect
- Plugins → Add New → Upload Plugin, choose the zip, install and activate.
- In the dashboard open the site → Integrations → WooCommerce and WordPress plugin → Create connection code. The code holds the API address, the site id and a new site key; it is shown once.
- In WordPress open Settings → Agile Snowglobe, paste the code and press Connect. The plugin registers its webhook, pulls every published post and, with WooCommerce, uploads the catalogue 100 products at a time.
- Keep the dashboard's WordPress connector (the application-password one) disconnected, otherwise the post is published twice. The Integrations page warns you when both are on.
The Integrations page shows the plugin's WordPress and WooCommerce versions, the products in the site's knowledge, the last webhook delivery and the last check-in, and offers an update when a newer plugin is out.
What it does with each post
| Post field | WordPress |
|---|---|
| title, slug, content_html_with_images | Post title, slug and content with the infographics placed after their sections; the leading H1 is dropped because your theme renders the title. |
| excerpt / meta_description | Post excerpt, plus _yoast_wpseo_title, _yoast_wpseo_metadesc, rank_math_title, rank_math_description. |
| banner_url_absolute, infographic_urls | Copied into the media library once with alt text; the banner becomes the featured image. |
| schema_jsonld | Stored on the post and printed in the head of its page. |
| published_at, updated_at | The post date on first import; unchanged posts are skipped on later pulls. |
| internal links | Links that resolve to WooCommerce products are remembered; the [snowglobe_products] shortcode renders them as a product grid (appended automatically when the option is on). |
| post.unpublished | The WordPress post goes back to draft. |
Product knowledge (WooCommerce)
Published, visible products are sent as structured data: name, price and sale price, the price range of variable products, currency, stock, SKU, categories, tags, visible attributes and descriptions. Snowglobe stores each as a trusted product page of the site; while the plugin is connected a crawl never overwrites it. Saving a product sends it a minute later in the background, and trashing or unpublishing it removes it. Nothing about customers or orders leaves the store. You can call the same endpoints from any other shop system with a site key.
| Call | Body and result |
|---|---|
| POST /sites/{id}/plugin/connect | {"webhook_url", "site_url", "plugin_version", "wp_version", "woocommerce_version"}. Registers the plugin's webhook for post.published, post.updated and post.unpublished, replacing an earlier one for the same URL, and returns webhook_secret once. Scope webhooks:write. |
| POST /sites/{id}/plugin/heartbeat | Versions plus synced, errors, last_result. Shown on the Integrations page. Scope sites:read. |
| POST /sites/{id}/products | {"products": [{"url", "name", "price", "max_price", "regular_price", "sale_price", "currency", "sku", "stock_status", "short_description", "description", "categories", "tags", "attributes", "image"}]}, up to 50 per call; the URL must be on the site's domain or a subdomain. Returns upserted, unchanged, skipped with reasons, and the product count. Scope knowledge:write. |
| POST /sites/{id}/products/remove | {"urls": [...]}. Removes those pages from the site's knowledge. Scope knowledge:write. |
Keys created before the knowledge:write scope existed get a 403 on the product calls; create a new connection code to replace the key. The plugin is plain PHP with no build step: the bootstrap, an API client, the connect flow, the post sync, the product sync, the webhook receiver and the settings page. Read it before you install it; it is yours to change.
Legacy pull API
The original read-only API stays available for sites that integrated before v1. It uses the site's older per-site key as an X-API-Key header (or ?api_key=) and serves published posts only.
| Call | Returns |
|---|---|
| GET /api/posts | Published posts, newest first, with limit and offset. |
| GET /api/posts/{slug} | One published post by slug. |
| GET /api/related | Posts related to a slug by embedding similarity. |
New integrations should use v1: it adds approval, scheduling, keywords, metrics and webhooks, and its post object carries schema, images and scores.
Errors and limits
Validation and state errors return an error object; authentication and rate-limit errors return a plain detail string.
{ "error": { "code": "slot_exists", "message": "A post is already scheduled for 2026-09-30.", "field": "date" } }
{ "detail": "missing scope: posts:write" }
| Status | Meaning | Typical cause |
|---|---|---|
| 400 | Bad request | bad_param, bad_date, bad_timezone, bad_url, bad_action. |
| 401 | Unauthorised | No bearer token, or a revoked or unknown key. |
| 403 | Forbidden | The key lacks the scope, or is scoped to another site. |
| 404 | Not found | The site, post, slot, cluster, suggestion or webhook is not visible to this key. |
| 409 | Conflict | slot_exists, already_published. |
| 429 | Too many requests | More than 120 calls in a minute; wait Retry-After seconds. |
OpenAPI
The machine-readable spec is at https://blog.fourdm.services/api/v1/openapi.json, and an interactive explorer at https://blog.fourdm.services/api/docs. Import the JSON into Postman or Insomnia, or generate a client with openapi-generator.