Rankealo

Custom API (webhook)

Send articles to your own HTTP endpoint. Rankealo POSTs JSON events; your server validates a shared token, creates or updates posts, and returns the live URL.

When to use this

Choose the webhook integration when you run a custom stack, headless CMS, or internal publishing pipeline. You own the endpoint — Rankealo sends structured events; you map them into your database or CMS.

What you'll need#

  • A publicly reachable HTTPS endpoint that accepts POST requests.
  • A secure shared token stored in your environment (and pasted into Rankealo).
  • Logic to create, update, and optionally delete blog posts from the payload.

Step 1 — Set up your endpoint#

  1. 1
    In Rankealo (Integrations, Custom API) click Generate next to Access token and copy the token.
  2. 2
    Add it to your environment, e.g. RANKEALO_WEBHOOK_TOKEN=rk_wh_...(the bare token, no "Bearer").
  3. 3
    Create a POST handler that checks Authorization === `Bearer ${token}` before processing the body. Rankealo always sends the token as a Bearer token.
  4. 4
    Return 200 OK with JSON such as { "ok": true } when Rankealo sends an empty body {} (connection test).
  5. 5
    Handle the three event types below: publish_articles, update_articles, and delete_articles.

Copy for AI

Paste the prompt below into your coding agent to scaffold a Next.js or Express handler:
prompt
I need to create a webhook endpoint that receives blog articles from Rankealo and saves them to my site. Please create a POST route.

## Authentication
Rankealo sends the header `Authorization: Bearer <access token>`. Store the token in the env var `RANKEALO_WEBHOOK_TOKEN` and reject requests (401) unless `request.headers.get('authorization') === `Bearer ${process.env.RANKEALO_WEBHOOK_TOKEN}``.

## Connection test
Rankealo sends an empty JSON body `{}` when the user clicks "Test connection". Return 200 JSON `{ "ok": true }`.

## Events (POST, JSON)
All events look like `{ event_type, timestamp, data: { articles: [...] } }`.

1. `publish_articles` - create the post. Article fields: id, title, slug, content_markdown, content_html, meta_description, image_url, tags, created_at, article_schema, faq_schema, open_graph.
```json
{
  "event_type": "publish_articles",
  "timestamp": "2026-01-15T12:00:00.000Z",
  "data": {
    "articles": [{
      "id": "uuid",
      "title": "Article title",
      "slug": "article-title-1a2b3c",
      "content_markdown": "# Heading\n\nBody...",
      "content_html": "<div class=\"rankealo-content\">...</div>",
      "meta_description": "SEO description",
      "image_url": "https://.../featured.jpg",
      "tags": [],
      "created_at": "2026-01-15T11:58:00.000Z",
      "article_schema": { "@type": "Article" },
      "faq_schema": null,
      "open_graph": { "og:title": "..." }
    }]
  }
}
```
UPSERT keyed on the article `id` (never on slug alone; slug is a mutable attribute that may change between publish and update; enforce a UNIQUE constraint on id and handle slug collisions with a suffix). Respond 200 with `{ "ok": true, "id": "<article id>", "url": "<live article url>" }` (always return id so Rankealo can address later updates).

2. `update_articles` - same content fields as publish, plus `cms_post_id`. Treat it exactly like publish: UPSERT by article `id`. Respond 200 `{ "ok": true, "id": "<article id>" }`.
```json
{
  "event_type": "update_articles",
  "timestamp": "2026-01-15T12:00:00.000Z",
  "data": {
    "articles": [{
      "id": "uuid",
      "cms_post_id": "the id you returned on publish (or the article id)",
      "title": "Updated title",
      "content_markdown": "...",
      "content_html": "...",
      "meta_description": "...",
      "article_schema": null,
      "faq_schema": null,
      "open_graph": {}
    }]
  }
}
```

3. `delete_articles` - delete the post by article id; a no-op if it is missing. Respond 200 `{ "ok": true }`.
```json
{
  "event_type": "delete_articles",
  "timestamp": "2026-01-15T12:00:00.000Z",
  "data": { "articles": [{ "id": "uuid" }] }
}
```

Unknown event_type: return 400. Respond 200 JSON promptly and do heavy work after responding.

## Handling retries
Respond 2xx only after the article is durably saved. A non-2xx response or a timeout means Rankealo may send the same event again, so handlers must be idempotent (processing the same event twice yields the same state). Rankealo waits up to 2 minutes for publish/update responses and 30 seconds for delete and the connection test, so respond quickly and do heavy work after responding. If the publish response has no id, Rankealo uses the payload's article id as the post id. Each request also carries `X-Rankealo-Delivery-Id`, which stays the same when the same delivery is re-sent (optional dedupe).

Any non-2xx response is treated as a failure. Please detect my framework (Next.js, Express, etc.), create the route in the right place, add the env var to my .env, and save articles into my existing blog/CMS if possible. Prefer `content_markdown`; use `content_html` only if I render HTML directly.

Example handler (Next.js App Router)

ts
// app/api/rankealo/route.ts
// .env: RANKEALO_WEBHOOK_TOKEN=<the access token you generated in Rankealo>
//
// Storage rules (replace db.* with your own database calls):
//  - Key everything on article.id. Add a UNIQUE constraint on it, e.g.
//      CREATE TABLE posts (id text PRIMARY KEY, slug text, title text, ...);
//  - slug is a mutable attribute, never the key: it can change between
//    publish and update. TODO: keep slug unique by appending a suffix on conflict.
//  - Handlers must be idempotent: the same event twice leaves the same state.

async function upsertPost(article: any) {
  // publish_articles AND update_articles: insert-or-update WHERE id = article.id
  await db.posts.upsert({
    where: { id: article.id },
    data: {
      slug: article.slug, // may change between publish and update
      title: article.title,
      contentMarkdown: article.content_markdown,
      contentHtml: article.content_html,
      metaDescription: article.meta_description,
      imageUrl: article.image_url,
    },
  });
}

export async function POST(request: Request) {
  // Rankealo sends: Authorization: Bearer <your access token>
  const expected = `Bearer ${process.env.RANKEALO_WEBHOOK_TOKEN}`;
  if (request.headers.get('authorization') !== expected) {
    return Response.json({ ok: false, error: 'Unauthorized' }, { status: 401 });
  }

  // Connection test sends an empty JSON body {}
  const body = await request.json().catch(() => ({}));
  if (!body.event_type) return Response.json({ ok: true });

  // Optional: X-Rankealo-Delivery-Id is identical when Rankealo re-sends the
  // same delivery. Log/dedupe on it if you like; upserting by id is enough.
  const articles = body.data?.articles ?? [];

  // Respond promptly: do heavy work (revalidation, search indexing, image
  // processing) after the response, e.g. with after() or a queue.
  switch (body.event_type) {
    case 'publish_articles':
    case 'update_articles': {
      for (const article of articles) await upsertPost(article);
      const first = articles[0];
      // Always return the id so Rankealo can address later updates.
      return Response.json({
        ok: true,
        id: first?.id,
        url: first ? `https://your-site.com/blog/${first.slug}` : undefined,
      });
    }

    case 'delete_articles':
      // Delete by id; deleting something that is already gone is a no-op.
      for (const article of articles) await db.posts.deleteMany({ where: { id: article.id } });
      return Response.json({ ok: true });

    default:
      return Response.json({ ok: false, error: 'Unknown event_type' }, { status: 400 });
  }
}

Step 2 — Connect in Rankealo#

In Rankealo, open Integrations, choose Custom API, and fill in:

FieldRequiredWhere to find it
Webhook URLRequiredYour public HTTPS endpoint, e.g. https://your-api.com/webhook/rankealo.
Access tokenOptionalGenerate one in Rankealo (rk_wh_...). Sent as "Authorization: Bearer <token>". A bare token is saved as a Bearer token; use "Advanced: custom header value" to send any other header value (e.g. Basic ...) exactly as typed.

Click Test Connection. Rankealo sends {} to your URL — your endpoint must return 200 before Save is enabled.

Event types & payloads#

Connection test

Empty JSON body {}. Any 2xx response passes; for example:

json
{ "ok": true }

publish_articles

Sent when a new article is published. Respond 2xx; include the live URL (and your post id) so Rankealo can store them:

json
{
  "event_type": "publish_articles",
  "timestamp": "2026-01-15T12:00:00.000Z",
  "data": {
    "articles": [{
      "id": "uuid",
      "title": "Article title",
      "slug": "article-title-1a2b3c",
      "content_markdown": "# Heading\n\nBody...",
      "content_html": "<div class=\"rankealo-content\">...</div>",
      "meta_description": "SEO description",
      "image_url": "https://.../featured.jpg",
      "tags": [],
      "created_at": "2026-01-15T11:58:00.000Z",
      "article_schema": { "@type": "Article" },
      "faq_schema": null,
      "open_graph": { "og:title": "..." }
    }]
  }
}

Response: { "ok": true, "id": "<article id>", "url": "https://mysite.com/blog/article-slug" }. Upsert keyed on the article id (never slug alone; the slug may change before an update) and return the id. If your response has no id or post_id, Rankealo stores the article id from the payload as the post id, so refreshes still work.

update_articles

Sent when an article is edited or re-optimized. Includes cms_post_id — the ID your endpoint returned or stored from the original publish:

json
{
  "event_type": "update_articles",
  "timestamp": "2026-01-15T12:00:00.000Z",
  "data": {
    "articles": [{
      "id": "uuid",
      "cms_post_id": "the id you returned on publish (or the article id)",
      "title": "Updated title",
      "content_markdown": "...",
      "content_html": "...",
      "meta_description": "...",
      "article_schema": null,
      "faq_schema": null,
      "open_graph": {}
    }]
  }
}

Response: { "ok": true, "id": "<article id>" }. Handle it as an upsert by id, same as publish.

delete_articles

Sent when an article is removed from Rankealo and should be unpublished:

json
{
  "event_type": "delete_articles",
  "timestamp": "2026-01-15T12:00:00.000Z",
  "data": { "articles": [{ "id": "uuid" }] }
}

Response: { "ok": true }. Delete by id; deleting a missing article is a no-op.

What gets synced#

Each article in the payload includes:

  • TitleThe article title.
  • ContentFull article body as HTML (or markdown where the platform supports it).
  • Meta descriptionSEO meta description / excerpt, where the platform has a field for it.
  • content_markdownMarkdown source — use this if you convert to native blocks instead of rendering HTML.
  • content_htmlPre-rendered HTML — use this if you want to render the article as-is; style it with your own CSS.
  • slug, image_urlSent on publish only: slug generated from the title, and the featured image URL. Not repeated on update.
  • article_schema, faq_schema, open_graphStructured data, sent on publish and update.
  • tagsAlways an empty list on publish; Rankealo does not send tags.

Updates & re-publishing#

Store the cms_post_id you assign (or the Rankealo article id) when you first handle publish_articles. Rankealo sends that ID back on update_articles so you patch the correct record — no duplicate posts. If your publish response returns no id, Rankealo uses the article id as the post id, which is why upserting by the article id is the safest approach.

Handling retries#

Respond 2xx only after the article is durably saved. A non-2xx response or a timeout means Rankealo may send the same event again, so make handlers idempotent: processing the same event twice must leave the same state (upsert by id). Every request carries an X-Rankealo-Delivery-Id header that stays the same when the same delivery is re-sent, if you want to dedupe on it. Respond quickly and do heavy work after responding.

  • Rankealo waits up to 2 minutes for a publish_articles or update_articles response, and 30 seconds for delete_articles and the connection test. A slower response counts as a timeout.
  • Scheduled (autopilot) publishing retries a failed publish automatically, up to 6 attempts with growing delays. A manual publish is not retried; you can publish again from the article.
  • If your endpoint returns an error, Rankealo shows the first 2,000 characters of the response body in the failure message.

Troubleshooting#

Test connection fails+
Confirm your endpoint accepts POST, returns 200 for {}, and validates the Authorization header as `Bearer <token>` if you set an access token. A 401/403 means your endpoint rejected the token.
401 Unauthorized on publish+
Your server must compare the Authorization header to `Bearer <token>`. Rankealo sends the token with the Bearer prefix. If you saved a custom header value in advanced mode, compare against that exact value.
Updates create duplicate posts+
Upsert publish_articles and update_articles by the article id (never by slug alone) and store id as a UNIQUE key. Do not treat updates as new inserts.
Rankealo shows publish failed after 200+
Include a url field (and optionally id) in the publish_articles response so Rankealo can store the live article URL. Without it the publish still succeeds but no URL is recorded.
Refreshing an older article fails with "missing remote id"+
Articles published before this behavior by an endpoint that returned no id have no stored post id. New publishes now store the article id automatically. For an older article, contact support so we can link it to your existing post.
Publish fails or runs twice after a slow response+
Rankealo waits up to 2 minutes, then treats the delivery as failed and may resend it. Save the article, respond 2xx quickly, and do heavy work afterwards. Because handlers upsert by id, a resend updates the same post instead of creating a duplicate.

Still stuck? Contact support

Reading is step one. Measuring your AI visibility is step two.

Rankealo tracks how often your brand is mentioned and cited across the major AI engines, then helps you publish the pages that close the gaps.