# RankOrg - Publishing Integrations

Configure where generated SEO articles go live: RankOrg-hosted domains, your own domain, WordPress, or signed webhooks.

## Publishing destinations

Every RankOrg blog is hosted by RankOrg and always has its subdomain. The canonical URL points at the blog's main address so the same article does not compete with itself across addresses.

- **Hosted subdomain** (e.g. `yourname.rankgt.com`): The fastest path: a RankOrg-hosted blog on a *.rankgt.com subdomain with automatic TLS. Every blog has one from the start, live in seconds, no DNS changes.
- **Custom domain** (e.g. `blog.yourdomain.com`): Point your own address at the blog with one DNS record. RankOrg issues the certificate, and once the address is live it becomes the canonical one, so the same article never competes with itself across addresses. Your plan must include a custom domain.

### Connecting your own domain

1. **Add the address.** In the app, open Settings, Addresses, and add the address you want, for example blog.yourdomain.com. Owners and admins can do this.
2. **Add the record at your domain provider.** Settings shows the record to add. For a subdomain it is one CNAME record pointing at a target that belongs to your blog alone; a domain without a subdomain in front uses the A records and the TXT record Settings lists instead. Nobody else can use your blog's target to claim an address.
3. **Wait for the check.** RankOrg re-checks the record every ten minutes, or press Check again. DNS can take up to an hour to show up. When it points at your blog, the certificate is issued automatically (a few minutes) and the address goes live.
4. **It becomes the canonical address.** The first live custom address is your blog's main one: canonical links, the sitemap and feeds use it, and the subdomain keeps working.

Manage addresses at https://rankit.blog/app/settings/addresses.

## WordPress

Publish each new article into a WordPress site through its REST API, in addition to the hosted blog. Settings, Publish elsewhere, WordPress. It needs the Pro plan.

Connect fields:
- Site address: The WordPress address, including the folder of a sub-directory install (https://acme.com/blog). It must use HTTPS.
- Username: A WordPress user who may create posts: an Editor or Author publishes; a Contributor can only save drafts.
- Application password: A WordPress application password (Users, Profile, Application Passwords; WordPress 5.6 or later). Not your login password.
- Status of new posts: publish (the default) or draft.

- Auth: HTTP Basic (username and application password) to /wp-json/wp/v2/posts.
- Credentials are checked at connect time (and with Check the connection) by reading the password's own user, and stored encrypted.
- The first delivery creates the post; a later delivery of the same article updates it, and leaves its status alone.
- Only the moment an article goes live is sent. An article you edit afterwards on RankOrg is not pushed again: edit the WordPress copy too.
- WordPress needs pretty permalinks (anything but Plain) for /wp-json/ to answer, and a security plugin that blocks the REST API or application passwords will make the connection fail with 401 or 403.
- The hero image is not uploaded as the post's featured image.

Connect it at https://rankit.blog/app/integrations/wordpress.

## Webhooks

RankOrg POSTs a signed JSON event to your URL every time an article goes live, so you can sync content into any CMS, cache or automation. Settings, Publish elsewhere, Webhook. It needs the Pro plan; the address must be https on port 443 and resolve to a public host, and redirects are not followed.

Event: `article.published`. Every request is a POST with `Content-Type: application/json` and these headers:

- `X-RankOrg-Event`: The event type: article.published (test for the test event).
- `X-RankOrg-Event-Id`: evt_…, the same on every retry of one delivery (also id in the body).
- `X-RankOrg-Attempt`: 1, 2, … for each try.
- `X-RankOrg-Timestamp`: Unix seconds when this attempt was signed.
- `X-RankOrg-Signature`: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
- `User-Agent`: RankOrg-Webhooks/1 (+https://rankorg.com)

### Verifying the signature

Take t and v1 from X-RankOrg-Signature, refuse a t more than five minutes from now, recompute HMAC-SHA256 of "<t>.<raw body>" with your webhook secret (the one Settings shows when you connect), and compare in constant time. Use the raw body: the exact bytes received, before any JSON parsing.

Node (no dependencies):

```js
// Verify a RankOrg webhook in plain Node (no dependencies): an Express route, an n8n Code node,
// a Code by Zapier step. Pass the RAW request body (the exact bytes received, before any JSON
// parsing) as a string or a Buffer.
const crypto = require("node:crypto");

/** true when `header` (X-RankOrg-Signature) signs `rawBody` with `secret` and is fresh. */
function verifyRankOrg(secret, header, rawBody, toleranceSeconds = 300) {
  let t;
  const sigs = [];
  for (const part of String(header || "").split(",")) {
    const [k, v = ""] = part.trim().split("=");
    if (k === "t" && /^\d{1,12}$/.test(v)) t = Number(v);
    if (k === "v1" && /^[0-9a-f]{64}$/.test(v)) sigs.push(Buffer.from(v, "hex"));
  }
  if (t === undefined || sigs.length === 0) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
  return sigs.some((s) => crypto.timingSafeEqual(s, expected));
}

module.exports = { verifyRankOrg };
```

Python (standard library only):

```python
# Verify a RankOrg webhook in Python (standard library only): a Flask or Django view, any
# receiver. Pass the RAW request body (the exact bytes received, before any JSON parsing).
import hashlib
import hmac
import re
import time

_T = re.compile(r"[0-9]{1,12}")
_V1 = re.compile(r"[0-9a-f]{64}")


def verify_rankorg(secret: str, header, raw_body: bytes, tolerance: int = 300) -> bool:
    """True when `header` (X-RankOrg-Signature) signs `raw_body` with `secret` and is fresh."""
    t = None
    sigs = []
    for part in str(header or "").split(","):
        key, _, value = part.strip().partition("=")
        if key == "t" and _T.fullmatch(value):
            t = int(value)
        elif key == "v1" and _V1.fullmatch(value):
            sigs.append(value)
    if t is None or not sigs or abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), b"%d." % t + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in sigs)
```

- **Zapier:** Use Webhooks by Zapier, Catch Raw Hook (it keeps the raw body and the headers), then a Code by Zapier step with the Node function above.
- **n8n:** Turn on Raw Body in the Webhook node, then use the function in a Code node. Self-hosted n8n needs NODE_FUNCTION_ALLOW_BUILTIN=crypto.
- **Make:** A custom webhook with Get request headers on. Compare v1 with the HMAC-SHA256 of t, a dot and the raw body, keyed with your secret.

### Delivery and retries

Delivery is at least once: answer 2xx quickly and drop an event id you have already processed. A timeout, a network error, or a 408, 425, 429 or 5xx answer is retried, up to 7 attempts over one to two hours, each with the same event id. Any other answer ends the delivery as failed. The Settings page lists every attempt.

### The test event

Send a test event from Settings, Publish elsewhere: same headers and body shape with "type": "test" and a sample article, signed like a real delivery and never retried.

### Example `article.published` body

```json
{
  "id": "evt_3f1c9a4be0d24b7d8a51c2e9f0b7a6d4",
  "type": "article.published",
  "api_version": 1,
  "created_at": "2026-10-11T09:30:00.000Z",
  "site": { "id": "0199c6c5-…", "name": "Ferncrest Dental", "url": "https://ferncrest.rankgt.com/" },
  "data": {
    "article": {
      "id": "0199c6c5-…",
      "title": "Is a chipped tooth an emergency?",
      "slug": "is-a-chipped-tooth-an-emergency",
      "url": "https://ferncrest.rankgt.com/blog/is-a-chipped-tooth-an-emergency/",
      "markdown_url": "https://ferncrest.rankgt.com/blog/is-a-chipped-tooth-an-emergency.md",
      "published_at": "2026-10-11T09:30:00.000Z",
      "updated_at": null,
      "excerpt": "Usually not, but call the same day if it hurts.",
      "meta_description": "Usually not, but call the same day if it hurts.",
      "html": "<p>Keep it clean…</p>",
      "markdown": "Keep it clean…",
      "hero_image_url": "https://media.rankgt.com/…/hero.webp",
      "author": "Dr. Lena Ortiz",
      "tags": ["Dental emergencies", "Chipped teeth"]
    }
  }
}
```

- url is the canonical address on the blog's main host.
- html is the sanitised article body without the page around it, with the blog's internal links written as absolute addresses; markdown is the same text without links.
- api_version changes only when a field is removed or changes meaning.

### Coming from the earlier webhook format

A step mapped to the earlier fields must be remapped:

- `X-RankOrg-Signature: sha256=<hex>` is now t=<unix>,v1=<hex>: the same HMAC input, the timestamp now inside the header.
- `X-RankOrg-Delivery` is now X-RankOrg-Event-Id (the old name is still sent for a short while).
- `version: "2026-06-09"` is now api_version: 1.
- `event, createdAt` is now type, created_at (the publish time).
- `article.*` is now data.article.* (one level down).
- `article.contentHtml, article.heroImage` is now data.article.html, data.article.hero_image_url.
- `article.metaDescription, article.publishedAt` is now data.article.meta_description (null when none), data.article.published_at.
- `site.slug, site.canonicalHost, site.blogUrl, article.canonicalUrl, article.keywords, article.status, article.publishAt` is now removed (site.url is the site root; article.url is canonical; tags replace keywords).
- `three attempts in the request` is now up to seven attempts over one to two hours.

## Public web endpoints

These files need no authentication. On rankorg.com:

- `/llms.txt`: AI discovery file: what RankOrg is, its pages, tools and this API.
- `/robots.txt`: Crawler directives, including AI crawler access.
- `/sitemap.xml`: Full URL index for search engines.
- `/index.md`: Markdown twin of the homepage for AI answer engines.

On every blog:

- `/sitemap.xml`: Every published post.
- `/llms.txt`: The blog's index for AI assistants (and /llms-full.txt with the text).
- `/rss.xml`: The RSS feed (and /feed.json).
- `/robots.txt`: Crawler directives, naming the sitemap.
- `/blog/<slug>.md`: A post as Markdown, beside /blog/<slug>/.

## Related docs

- Developer docs: https://rankit.blog/docs
- MCP for AI agents: https://rankit.blog/docs/mcp
