Day to day
Publishing to your own site (webhook)
For a site that isn’t on WordPress, Ghost, Webflow or Shopify. Your site exposes one HTTPS endpoint; Vidrys sends each on-site page to it as signed JSON, and your site decides how to store and show it.
By Garvit Sharma, Co-founder · Last reviewed 21 Oct 2026
Settings → Publishing → Webhook
Set it up
Build an endpoint on your site that accepts
POSTwith a JSON body, for examplehttps://example.com/api/vidrys/webhook. Use the final address: Vidrys never follows a redirect, sowwwagainst the bare domain, and a trailing slash, both matter.Generate a secret, for example with
openssl rand -hex 32, and give it to your server.In
Settings→Publishing, pickWebhook, paste the address and the secret, and pressConnect Webhook.Vidrys sends a signed
connection.testevent straight away and keeps the connection only if your endpoint answers2xx— so have it running first.Testsends another one at any time.
What Vidrys sends
| Event | Sent by | What your site does |
|---|---|---|
connection.test | Connect and Test | Checks the signature and answers 2xx. Nothing to store. |
content.publish | Publish live or Send as draft on a new-page ticket | Creates or updates the page. status says whether it goes live. |
content.page_fix | Send change request on a ticket that fixes an existing page | Stores the edit list for target_url, for a person to apply. Never publish it as a page. |
content.unpublish | Unpublish on a ticket whose page went live through this webhook | Takes the page off your site — keeping it as a draft is fine — and answers {"unpublished": true}, also when it wasn’t live. |
seo.fix.apply | Approve on a fix in the SEO Fixes queue | Changes one thing on an existing page — its title, meta description, canonical, noindex or image alt text, or a redirect from a dead address — and answers {"applied": true}. See SEO fixes. |
seo.fix.revert | Roll back on a published fix | Puts the field's previous value back, and answers {"applied": true}. |
aeo.fix.apply | Publish live on an AEO audit fix's ticket | Puts the fix on your site — a page's revised text, a robots.txt group, an llms.txt, or JSON-LD for the homepage — and answers {"applied": true}. See AEO audit fixes. |
New event types may be added. Answer any event you don’t recognise with 2xx and ignore it.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Vidrys-Webhooks/1 |
X-Vidrys-Signature | t=<unix seconds>,v1=<hex> — verify it as below. |
X-Vidrys-Event, X-Vidrys-Delivery | The event name and the delivery id, for your logs. They are not signed, so read the event from the body. |
X-GEO-Signature | Legacy: a hex HMAC-SHA256 of the body alone, with no timestamp. Prefer X-Vidrys-Signature. |
Verify the signature
v1 is an HMAC-SHA256, keyed with your secret, of the timestamp, a dot, and the raw request body: <t>.<body>. Compute it over the bytes exactly as they arrived, before parsing — re-serialised JSON won’t match. Reject a request whose t is more than 300 seconds from your clock, so a captured request can’t be replayed.
// Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(header: string | null, rawBody: Buffer, secret: string): boolean {
if (!header) return false;
const parts = Object.fromEntries(header.split(",").map((part) => part.trim().split("=", 2)));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
if (!/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
return timingSafeEqual(Buffer.from(parts.v1, "hex"), expected);
}# Python
import hashlib, hmac, time
def verify(header: str | None, raw_body: bytes, secret: str) -> bool:
if not header:
return False
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Both use only the standard library — Node’s crypto module and Python’s hmac — and compare in constant time (timingSafeEqual, compare_digest), so the check gives nothing away about the expected value through how long it takes. HMAC itself is specified in RFC 2104.
The body
| Field | Sent with | Meaning |
|---|---|---|
event | Every event | connection.test, content.publish, content.page_fix or content.unpublish. |
spec_version | Every event | 2. |
delivery_id | Every event | Unique per delivery. Log it. |
sent_at | Every event | When it was sent, ISO 8601 in UTC. |
content_id | Content events, content.unpublish | The stable key: one ticket’s page, one record. Upsert on it. |
status | Content events | publish (put it live) or draft (store it, don’t show it). Always draft for a page fix. |
title | Content events | The page headline. The body never repeats it as a leading # heading. |
slug | content.publish | A suggested URL segment. Keep the first one you use, so the address never changes. |
excerpt | content.publish | The page’s opening answer as plain text, at most 160 characters. Fits a meta description. |
content_markdown | Content events | The body as Markdown. |
content_html | Content events | The same body as HTML. Raw HTML in the draft is escaped, not passed through. |
asset_version | Content events | The draft’s version. Ignore a delivery older than the one you have. Can be null; treat that as 0. |
placeholders | Content events | Bracketed gaps the draft still has, such as [customer count]. A draft with any is never sent live. |
target_url | content.page_fix | The existing page to change. null for a new page. |
content_type | Content events | The kind of page — faq, how-to, comparison, definition, … Can be null. |
mode | Content events | create for a new page; refine_unseen or refine_rejected for a fix to an existing one. |
url | content.unpublish | The live address your site answered with when it published the page. Can be null. |
asset_id, recommendation_id, project_id | Content events, content.unpublish | Vidrys ids, for your logs. asset_id is null on older tickets. |
What to reply
Any 2xx means delivered. Anything else, a redirect included, is a failure: the ticket shows it with Retry publish. Vidrys waits up to 90 seconds, which covers a host that sleeps when idle. Optionally, reply with JSON:
{ "id": "<your record id>", "url": "https://example.com/blog/<slug>", "status": "published" }urlis the page’s live address. Send it only when the page is live.statusispublishedordraft. Leave it out and Vidrys assumes the one it asked for.- A live reply with a
urlmoves the ticket to measuring on its own, using that address. A draft reply doesn’t.
content.unpublish is the exception: answer {"unpublished": true} once the page is no longer live. Anything else — a plain 2xx included, which is what a receiver that ignores the event sends — counts as a refusal: the page stays recorded as live and Unpublish shows your site’s answer.
Storing it
Your site needs somewhere durable to keep what arrives — usually a table next to the rest of its content. For example, in Postgres:
CREATE TABLE vidrys_content (
content_id text PRIMARY KEY, -- upsert key
kind text NOT NULL, -- 'new_page' or 'page_fix'
status text NOT NULL, -- 'draft', 'published', 'pending', 'applied'
title text NOT NULL,
slug text UNIQUE, -- new pages only; never changes once set
excerpt text,
target_url text, -- page fixes: the page to change
body_markdown text NOT NULL,
asset_version integer NOT NULL,
published_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now()
);- One record per `content_id`. Sending the same ticket twice, or from two tickets of the same page, must update one record, not create two.
- Newest version wins. Skip a delivery whose
asset_versionis lower than the one stored. - Stable address. Keep the slug from the first delivery.
- A draft never takes a live page down. Keep a draft that arrives for a live page aside until a live version follows.
- Unpublish keeps the record. Turn it back into a draft so its address stops serving. Publishing the same
content_idagain brings it back at the same slug. - Page fixes are for people. Store
content.page_fixas a pending change; once the edit is live on your site, pressMark publishedon the ticket.
SEO fixes
The SEO Fixes queue proposes one change to one page — a title, meta description or canonical tag; removing a noindex; alt text for its images; its Open Graph tags; the links in its text; or a redirect, from a dead address or pointed straight at the end of a chain — and a person approves it, edited if need be. Approved, it reaches your endpoint as seo.fix.apply, signed like everything else, and only over https with a secret set. Your site makes the change. Roll back on a published fix sends seo.fix.revert with the value to put back.
{
"event": "seo.fix.apply",
"fix_id": "5f1c…", "idempotency_key": "5f1c…:apply:1",
"sent_at": "2026-09-28T10:15:00+00:00",
"url": "https://example.com/pricing",
"changes": [
{ "field": "meta_description", "value": "Plans from ₹999 a month…", "previous_value": null }
]
}| Field | Meaning |
|---|---|
url | The page to change, as it was crawled. |
changes[].field | title, meta_description, canonical, robots, redirect, image_alt, open_graph or links — or a page edit (below): headings, subheadings, more_text, link_text, add_links or related_links. |
changes[].value | What to set. On a revert, the value Vidrys wrote. robots is index (take the noindex off the page) or, on a revert, noindex. redirect is the address url should answer with a single permanent (301) redirect to — url either no longer answers, or redirects through a chain that this replaces — and null on a revert (remove the redirect). image_alt is an object, each image’s src exactly as the page’s HTML writes it → its alt text. open_graph is an object, og:title and/or og:description → the tag’s content. links is an object, each link’s address as the page links it → the address to link instead; on a revert it is the swap back. |
changes[].previous_value | What the field held before. On a revert, what to put back; null clears it. |
idempotency_key | The same on every retry of one approval; a new approval gets a new key. |
sent_at | When it was sent, ISO 8601 in UTC. Reject an event more than a few minutes old. |
Reply { "applied": true } once the change is stored. A plain 2xx is not enough — a receiver built only for pages can never apply a fix by accident. Refuse with { "applied": false, "reason": "…" }; the reason is shown on the fix. Vidrys then reads the live page itself, and the fix reads Verified only when the page carries the value.
- Same key, same answer. A retry (Vidrys never heard back) carries the same
idempotency_key; answer it with the outcome you recorded, without applying the change again. - Never over an edit. Apply only while the field is empty or still holds
previous_value; revert only while it still holdsvalue. Refuse otherwise, and say so inreason. - Apply all or none. A delivery with several changes is one change to the page.
Page edits
A page edit changes the page's own text, so its value is a list of edits, in order, each naming what it changes by the page's own words — { "edits": [...], "revert": false }. Make each on your page's content; compare words, not markup. On a revert, the same edits arrive with "revert": true: undo them. Refuse (applied: false) when an edit's text isn't on the page as named, or is there twice.
| `op` | The edit |
|---|---|
heading | The heading at level (1-6) whose words are text becomes an H<to_level> with the words to_text (the same words: only its level changes). An empty text is an empty heading. |
insert | blocks go before or after (where) the paragraph whose text starts with the words anchor. Each block is a heading or paragraph (tag h1-h6 or p, with text) or a list of links (tag ul, with links: [{ href, text }]). |
link_text | The link to href whose words are text gets the words to_text. |
link_label | The link to href that shows no words gets aria-label to_label. |
link_phrase | The first mention of phrase in the page's paragraphs, not already in a link, becomes a link to href. |
{ "field": "headings", "previous_value": null,
"value": { "revert": false, "edits": [
{ "op": "heading", "level": 4, "text": "Pricing", "to_level": 3, "to_text": "Pricing" }
] } }vidrys.com is itself a receiver: pages pushed to it can have their title and meta description fixed this way, an AEO fix rewrites a pushed page, adds a robots.txt group, serves an llms.txt or merges JSON-LD into its home page, and a fix to one of its hand-written pages is refused with the reason. Its source is the reference for the rules above.
AEO audit fixes
A fix from the AEO audit is a ticket in the Content queue. Publish live on it sends aeo.fix.apply — signed, and only over https with a secret set, like an SEO fix. Your site applies it: kind says what it is. Anything else — Send change request, or a CMS — leaves applying it to a person, who then presses Mark published.
{
"event": "aeo.fix.apply", "spec_version": 2,
"delivery_id": "…", "sent_at": "2026-10-09T10:15:00+00:00",
"content_id": "…", "asset_version": 2,
"checks": ["structured_data"],
"kind": "markup",
"target_url": "https://example.com/",
"filename": null,
"media_type": "application/ld+json",
"title": "Structured data depth — JSON-LD markup",
"content": "{ "@context": "https://schema.org", … }",
"instructions_markdown": "Merge each property into the node of that @type…"
}| `kind` | What your site does |
|---|---|
page | Replaces the body of the page at target_url with content — the whole revised page, its # headline included (title repeats it). content_html is the same as HTML; changes_markdown lists what changed, for your review. |
file | A file at your site’s root, named in filename: a robots.txt group to add to /robots.txt, or an llms.txt to serve at /llms.txt. |
markup | JSON-LD for the homepage: merge each property into the node of that @type it already declares rather than adding a second one. |
Reply { "applied": true } once it is live, exactly as for an SEO fix. Refuse with { "applied": false, "reason": "…" } — a page your site can't rewrite, say — and the reason is shown on the ticket, which stays unpublished; Send change request then hands the same fix to a person. Any other answer leaves the ticket unpublished too, with a note that your receiver may not handle the event yet. Vidrys never sends a fix with blanks still in it — a [placeholder] in a page, or a <placeholder> in a file or markup. Unpublish cannot undo it on your site: it only stops counting it, so revert it there.
Vidrys retries a page only when someone presses Retry publish; a fix whose answer never came is retried on its own, with the same key. To change the secret, disconnect and connect again with the new one.