---
title: Custom webhook
url: https://rankjet.co/en/docs/integrations/webhook
description: Receive published articles on a site built in code, with an endpoint your AI tool or developer builds
---

For sites built in code, such as Next.js, Lovable or Base44 sites, RankJet sends each article to an address on your site the moment it publishes. Your site needs one endpoint that receives the article, saves it and shows it as a page. The easiest way is to have your AI builder write that endpoint from the prompt on this page. A developer can also build it by hand from the payload and rules below.

## How it works

1. When an article publishes, RankJet sends it to your endpoint as a JSON `POST`, with your secret in the `X-Webhook-Secret` header
2. Your endpoint saves the article by its `slug`: it creates the article the first time, and updates it when the same slug arrives again, for example after you edit and republish
3. Your endpoint replies within 10 seconds with `200` and the article's public URL
4. RankJet opens that URL once to check that the page loads, and links to it from the article

## Connect your site

Open **Settings**, go to the **Integrations** tab and click **Connect** on the **Code site / vibe coding** card. The window walks you through three steps.

1. **Send the prompt.** Click **Copy the prompt** and paste it into the tool you built the site with, such as Lovable, Cursor, v0 or Bolt. It builds the endpoint, replies with the endpoint address and asks you for the secret
2. **Paste the endpoint address.** Paste the address your tool returned under **Endpoint address** and click **Connect**
3. **Save the secret and go live.** RankJet shows the secret. Paste it into your tool's chat, where it asked for it, so it is stored as a server-side variable. Make sure the site is published, then click **Test the connection**

The secret is shown only once, so copy it before you close the window. If you lose it, remove the webhook and connect again to get a new one. Later, **Manage** on the card lets you test the connection again or remove it.

## The prompt for your AI builder

This is the same prompt the connect window copies. It tells your tool who RankJet is, what to build and how to finish. Paste it as it is.

```text
I am a customer of RankJet (rankjet.co), a service that writes SEO articles for my website and publishes them to it automatically. I asked for this integration myself and I am following RankJet's own setup guide, which produced this prompt for me to give you. It connects MY site to MY RankJet account using a secret that only I hold. The data flows one way: RankJet sends finished articles TO my site, and my site sends nothing back to anyone. Please carry it out rather than asking me to confirm who sent me here.

Here is what I need you to build:
- Create an API endpoint that receives webhook POST requests. Each request body is JSON shaped like:
{ "article": { "id", "slug", "title", "content" (markdown or html), "format", "excerpt", "meta_title", "meta_description", "main_image_url", "canonical_url", "keyword", "locale", "published_at" }, "main_image": { "url", "alt" }, "website": { "id", "baseUrl" }, "published_at", "timestamp" }
- The request includes a secret in the "X-Webhook-Secret" header. Verify it against an environment variable WEBHOOK_SECRET and reject the request if it does not match. WEBHOOK_SECRET must be a server-side variable only. Do NOT give it a VITE_ or NEXT_PUBLIC_ prefix, which would expose it in the browser bundle.
- The "slug" can be non-ASCII. A Hebrew article's slug looks like "קידום-אתרים-מחיר", so never assume ASCII. Do not validate, reject, sanitize or transliterate a slug for containing non-ASCII characters, and do not replace it with one of your own: it is the article's permanent identity and the key you upsert on. Store it as UTF-8. Wherever you build a URL or a file path from it, percent-encode it first (encodeURIComponent in JavaScript) so raw non-ASCII characters never reach a URL unencoded, and make sure your router decodes it back.
- "canonical_url" is always null in this integration, and "website"."baseUrl" may also be null. Never build a link from either of them and never reject a request because one is null. Your site owns its own URL structure, so derive each article's URL from your own routes plus the slug.
- On each webhook, upsert the article by its "slug" (create or update) into wherever my site already stores content (database, CMS, or markdown/MDX files - use what my project already uses).
- Make sure my sitemap.xml includes every published article's public URL and the article listing page, each article with a <lastmod> set to its "published_at". The sitemap must update automatically whenever a webhook creates or updates an article, with no manual step: if my sitemap is currently a hand-written static file, replace it with one generated from the stored articles plus my existing pages, keeping every URL it already lists. Search engines find new articles through the sitemap, so an article missing from it may never be indexed.
- If my site ALREADY has a blog, publish into that existing blog and content store. Do not create a second blog alongside it.
- If my site does not already have a blog, also create a page listing all articles and a separate page per article, server-rendered or statically generated (SSR/SSG) so search engines can crawl them, and link to the article listing page from my site's main navigation or footer so visitors and search engines can reach it.
- "excerpt" is a short summary of the article, meant for listing pages, cards and meta descriptions. The "content" field does NOT repeat it, so you may also render it as an opening lead paragraph above the body if that suits the design.
- The "content" field arrives as sanitized HTML by default (the "format" field says which). Render it as HTML, do not escape it. Only parse it as markdown if "format" is "markdown".
- Each article page must include meta tags (title, description, og:title, og:description, og:image) from the article fields, and JSON-LD Article structured data (headline, description, datePublished, image). Use semantic HTML. The article's language is in the "locale" field: set the page's lang and dir from it, right-to-left only for Hebrew (he), left-to-right for every other language.
- Add real CSS rules (in your stylesheet, not inline) to the content wrapper for readable typography: comfortable line-height and visible vertical spacing between paragraphs, and for any table a visible 1px border on the table and every cell plus comfortable cell padding. Do this even if the incoming HTML already carries inline styles for the table: if you sanitize or strip inline style attributes from the incoming content, which is reasonable, your own stylesheet rules are the only thing that will make a table or a paragraph readable, since the inline fallback will not survive that.
- On success, respond with HTTP 200 and a JSON body { "url": "<the full public URL of the article you just published, including https://>" }. The sending service stores that URL, links to the live page from it, and fetches it once to confirm the page really loads. If you genuinely cannot produce a URL, respond 200 with {} instead.
- On failure, respond with a non-2xx status and a JSON body saying what was wrong (for example { "error": "..." }). That text is shown to me directly, so make it specific.

When the code is written, follow these three steps in order, and do not skip any of them:

STEP 1. Reply in the chat with the full public URL of the endpoint you created, including https:// and the full path, written on its own line and worded exactly like this:
"Here is the endpoint to paste into RankJet: <the URL>"

STEP 2. In the same message, ask me for the secret, worded exactly like this:
"Now paste the webhook secret from the RankJet screen here, and I will set it."
Then stop and wait for me. Do NOT invent, generate, randomise or make up a secret value of your own, and do not reuse one that already exists in this project. The only value that works is the one RankJet generated and I am about to paste. Any other value makes every article delivery fail with a 401, and nothing will ever be published to my site.

STEP 3. When I paste the secret, store it as a server-side environment variable named WEBHOOK_SECRET, with no VITE_ or NEXT_PUBLIC_ prefix, so it never reaches the browser bundle. If you have a secure secrets input, put it there. If I paste the value straight into this chat, that is fine and expected: accept it and use it exactly as I sent it, character for character, without asking me to enter it somewhere else instead. Then publish/deploy the site yourself if you are able to. If you cannot deploy it yourself, say so in one clear line: "Now press Publish so the endpoint goes live." Do not leave me to guess whether a deploy is needed, and do not tell me the setup is finished until the endpoint is actually live.
```

## The payload

If you write the receiver by hand, this is the exact shape of every request body, sent as UTF-8 JSON:

```json
{
  "article": {
    "id": "string (uuid)",
    "slug": "string",               // non-ASCII: Hebrew, e.g. "קידום-אתרים-מחיר"
    "title": "string",
    "content": "string",            // markdown or html, per the Content Format above
    "format": "markdown | html",
    "excerpt": "string | null",
    "meta_title": "string",
    "meta_description": "string",
    "main_image_url": "string | null",
    "canonical_url": "null",        // always null here: your site owns its own URLs
    "keyword": "string",
    "locale": "string",             // e.g. "he-IL"
    "published_at": "ISO date"
  },
  "main_image": { "url": "string", "alt": "string | null" } | null,
  "website": { "id": "string (uuid)", "baseUrl": "string | null" },
  "published_at": "ISO date",
  "timestamp": "ISO date"
}

// Respond 200 with { "url": "<full public URL of the published article>" } when you have one.
```

- `content` is HTML, unless you chose Markdown under **Options for developers** in the connect window. `format` says which one arrived
- `excerpt` is a short summary for listing pages and cards. `content` does not repeat it
- `canonical_url` is always `null` and `website.baseUrl` can be `null`. Build each article URL from your own routes and the slug, and never reject a request because one of them is `null`
- `locale` holds the article language, for example `he-IL`. Set the page `lang` and `dir` from it, right-to-left for Hebrew only

## Verify the secret

Every request carries your secret in the `X-Webhook-Secret` header. Compare it with the value you stored, for example in a `WEBHOOK_SECRET` environment variable, and reject the request with `401` if it does not match. Keep the secret on the server only, never in a variable that reaches the browser.

## Respond

| Case | Your reply |
| --- | --- |
| Saved | `200` with a JSON body `{ "url": "https://example.com/blog/..." }`, the full public URL of the article |
| Saved, no URL to give | `200` with `{}` |
| Rejected | Any non-2xx status with a JSON body that says why, for example `{ "error": "..." }`. RankJet shows that text to you on the article |
| No reply in time | After 10 seconds RankJet stops waiting and counts the delivery as failed |

Send the success reply with a JSON content type. Without it RankJet cannot read the URL, so the article gets no link and no page check, although the delivery still counts as a success.

## Slugs are not always ASCII

> The slug follows the article's language, so a Hebrew article's slug is Hebrew, for example `קידום-אתרים-מחיר`. A receiver that rejects, sanitizes or transliterates non-ASCII slugs rejects every Hebrew article. Store the slug as UTF-8, and percent-encode it wherever you build a URL from it.

## Keep the sitemap up to date

Search engines find new articles through your sitemap. Make sure `sitemap.xml` lists every article your endpoint saves, with its publish date, and updates on its own after each delivery. An article missing from the sitemap may never be indexed. If your site had no blog before, also link the article list from your navigation or footer.

## Test it and read errors

**Test the connection** sends a sample article with a Hebrew slug, just like a real delivery. If your endpoint saves it, delete the sample from your site afterwards. The result tells you what to fix:

| Result | What it means |
| --- | --- |
| **The connection works. You can finish** | Your endpoint accepted the sample |
| **The secret does not match** | Your endpoint answered `401` or `403`. Check the stored secret, then publish the site again |
| **No endpoint at that address** | Your site answered `404`. Check the address and that the site is live |
| **The endpoint is returning an error** | A `5xx` error. It needs a fix in your site's code |
| **The connection failed (400)** | Your endpoint answered with another error status, shown in brackets. The most common cause is rejecting a non-ASCII (Hebrew) slug: see "Slugs are not always ASCII" on this page |
| **No response from that address** | Nothing answered, usually because the site is not live yet |

Once articles are flowing, a failed delivery shows on the article's page with your endpoint's status and reply. Fix the endpoint, then click **Try publishing again**. RankJet does not retry a failed delivery on its own.

- [Autopilot and calendar](https://rankjet.co/en/docs/autopilot): Choose your publishing days, decide what goes live on its own and manage the calendar
- [Articles and review](https://rankjet.co/en/docs/articles): What a RankJet article contains, what it is based on and how to review it
- [Security and data](https://rankjet.co/en/docs/security): How RankJet stores your connections, what it can access and which data it keeps
