Push Endpoint for Custom Sites
Implement one signed HTTPS route on your custom-coded site and RankNest pushes approved changes to it automatically, like the WordPress plugin.
A push endpoint gives a custom-coded site the same one-click Push to Site experience as WordPress. You implement one HTTPS route on the site, register its URL on the client's Custom-Coded Site connector, and RankNest delivers each approved change to that route within about five minutes. Your code decides how the change is applied: update a database row, edit a CMS entry, or anything else that changes the live page.
Without a push endpoint, a custom connector still works. Approved changes wait in the connector's queue for you (or an AI coding assistant) to apply through the CLI or MCP. The endpoint is the upgrade from "queued for pickup" to "applied automatically".
Where to find it
Open the client's Settings, go to the Integrations tab, and expand the Custom-Coded Site connector. The Automatic Push Endpoint section sits below the CLI instructions.
How to register your endpoint
- Deploy a route on the client's site that implements the contract below.
- Paste its full HTTPS URL into the Automatic Push Endpoint field and click Register.
- Copy the signing secret that appears. RankNest shows it once and stores it
encrypted. Set it on the site as an environment variable, for example
RANKNEST_PUSH_SECRET. - Click Send test ping. When the route answers correctly, the section shows Verified and queued changes start flowing.
Warning: Registering again replaces the secret. The site must be updated with the new value or every push will be rejected.
The contract
RankNest sends POST requests to your route with a JSON body and two
headers:
| Header | Value |
|---|---|
X-RankNest-Timestamp |
Unix time in milliseconds |
X-RankNest-Signature |
sha256=<hex HMAC-SHA256 of "timestamp.body"> |
Verify every request before acting on it:
- Reject when the timestamp is more than 5 minutes from your clock.
- Compute
HMAC-SHA256(secret, timestamp + "." + rawBody)and compare it to the signature header with a timing-safe compare.
import crypto from "node:crypto";
function verify(secret: string, timestamp: string, signature: string, rawBody: string): boolean {
if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60_000) return false;
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Answer unauthenticated requests with 401 and an empty body.
Request types
Your route receives three body shapes, distinguished by type. Ignore any
fields you do not recognize; new optional fields may appear over time.
ping, sent by the Send test ping button:
{ "type": "ping" }
Reply 200 with { "ok": true }.
add_link, sent for an approved internal link recommendation:
{
"type": "add_link",
"item_id": "9be0…",
"source_url": "https://www.example.com/blog/some-post/",
"target_url": "https://www.example.com/services/thing/",
"anchor_text": "our thing service",
"placement": {
"original_paragraph": "<p>…the paragraph as crawled…</p>",
"edited_paragraph": "<p>…the same paragraph with the new link…</p>"
}
}
Find the page that serves source_url, locate original_paragraph in its
content (match loosely: strip tags, decode entities, collapse whitespace),
and add the link. Wrapping the first occurrence of anchor_text in the
matched paragraph preserves your stored markup better than replacing the
whole paragraph.
update_meta, sent when an SEO test variant goes live:
{
"type": "update_meta",
"item_id": "41c2…",
"source_url": "https://www.example.com/blog/some-post/",
"title": "New page title",
"meta_description": "New meta description.",
"h1": { "current": "Old heading", "new": "New heading" }
}
All three change fields are optional; apply the ones present. h1.current is
the locator: change the heading only when it matches the live one.
Response shape
Reply 200 with JSON in one of two shapes.
Applied:
{
"applied": true,
"revision_id": "optional string you can use to find the change later",
"previous": { "title": "Old title", "meta_description": "Old description" },
"warning": "optional note, e.g. already_linked"
}
previous matters for update_meta: RankNest stores it so a test's original
values can be restored later. Return the values that were live before your
write.
Declined:
{
"applied": false,
"code": "unmanaged_url",
"message": "That page is built from code, not managed content."
}
A decline is treated as final. The item is marked failed with your message,
no retries, and the change stays available for manual pickup through the CLI
or MCP. Useful codes: unmanaged_url (the URL is not content your route can
edit), not_found (no page serves that URL), paragraph_not_found,
anchor_not_found.
Retries
Non-2xx responses are retried up to 3 times when they look transient (429
or 5xx). Other statuses fail the item immediately. Make your route
idempotent: applying the same item_id twice must be safe, since a timeout
after a successful write leads to a retry.
Tips
- Revalidate or purge any page cache after a write, or the worker's live-page verification (which fetches the public URL a few minutes later) reports the change as missing.
- Keep the route fast. RankNest times out after 15 seconds.
- Return
unmanaged_urlfor pages whose copy lives in source code. Those changes stay in the queue view, and an AI coding assistant working in the repo can apply them through the MCP instead.
Troubleshooting
The test ping fails with a signature error. The site holds a different secret than RankNest. Register again to mint a fresh secret and update the site's environment variable.
Pushes complete but verification reports a mismatch. The write landed in your database, but the public page still serves cached HTML. Revalidate the page in your framework after every write.
Every push fails with unmanaged_url. The queued changes target pages
your route does not manage. Apply those through the
CLI or MCP and mark them completed.
Related
Last updated 2026-08-14