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

  1. Build an endpoint on your site that accepts POST with a JSON body, for example https://example.com/api/vidrys/webhook. Use the final address: Vidrys never follows a redirect, so www against the bare domain, and a trailing slash, both matter.

  2. Generate a secret, for example with openssl rand -hex 32, and give it to your server.

  3. In Settings → Publishing, pick Webhook, paste the address and the secret, and press Connect Webhook.

  4. Vidrys sends a signed connection.test event straight away and keeps the connection only if your endpoint answers 2xx — so have it running first. Test sends another one at any time.

What Vidrys sends

EventSent byWhat your site does
connection.testConnect and TestChecks the signature and answers 2xx. Nothing to store.
content.publishPublish live or Send as draft on a new-page ticketCreates or updates the page. status says whether it goes live.
content.page_fixSend change request on a ticket that fixes an existing pageStores the edit list for target_url, for a person to apply. Never publish it as a page.
content.unpublishUnpublish on a ticket whose page went live through this webhookTakes the page off your site — keeping it as a draft is fine — and answers {"unpublished": true}, also when it wasn’t live.
seo.fix.applyApprove on a fix in the SEO Fixes queueChanges 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.revertRoll back on a published fixPuts the field's previous value back, and answers {"applied": true}.
aeo.fix.applyPublish live on an AEO audit fix's ticketPuts 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

HeaderValue
Content-Typeapplication/json
User-AgentVidrys-Webhooks/1
X-Vidrys-Signaturet=<unix seconds>,v1=<hex> — verify it as below.
X-Vidrys-Event, X-Vidrys-DeliveryThe event name and the delivery id, for your logs. They are not signed, so read the event from the body.
X-GEO-SignatureLegacy: 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

FieldSent withMeaning
eventEvery eventconnection.test, content.publish, content.page_fix or content.unpublish.
spec_versionEvery event2.
delivery_idEvery eventUnique per delivery. Log it.
sent_atEvery eventWhen it was sent, ISO 8601 in UTC.
content_idContent events, content.unpublishThe stable key: one ticket’s page, one record. Upsert on it.
statusContent eventspublish (put it live) or draft (store it, don’t show it). Always draft for a page fix.
titleContent eventsThe page headline. The body never repeats it as a leading # heading.
slugcontent.publishA suggested URL segment. Keep the first one you use, so the address never changes.
excerptcontent.publishThe page’s opening answer as plain text, at most 160 characters. Fits a meta description.
content_markdownContent eventsThe body as Markdown.
content_htmlContent eventsThe same body as HTML. Raw HTML in the draft is escaped, not passed through.
asset_versionContent eventsThe draft’s version. Ignore a delivery older than the one you have. Can be null; treat that as 0.
placeholdersContent eventsBracketed gaps the draft still has, such as [customer count]. A draft with any is never sent live.
target_urlcontent.page_fixThe existing page to change. null for a new page.
content_typeContent eventsThe kind of page — faq, how-to, comparison, definition, … Can be null.
modeContent eventscreate for a new page; refine_unseen or refine_rejected for a fix to an existing one.
urlcontent.unpublishThe live address your site answered with when it published the page. Can be null.
asset_id, recommendation_id, project_idContent events, content.unpublishVidrys 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" }
  • url is the page’s live address. Send it only when the page is live.
  • status is published or draft. Leave it out and Vidrys assumes the one it asked for.
  • A live reply with a url moves 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_version is 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_id again brings it back at the same slug.
  • Page fixes are for people. Store content.page_fix as a pending change; once the edit is live on your site, press Mark published on 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 }
  ]
}
FieldMeaning
urlThe page to change, as it was crawled.
changes[].fieldtitle, 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[].valueWhat 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_valueWhat the field held before. On a revert, what to put back; null clears it.
idempotency_keyThe same on every retry of one approval; a new approval gets a new key.
sent_atWhen 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 holds value. Refuse otherwise, and say so in reason.
  • 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
headingThe 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.
insertblocks 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_textThe link to href whose words are text gets the words to_text.
link_labelThe link to href that shows no words gets aria-label to_label.
link_phraseThe 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
pageReplaces 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.
fileA 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.
markupJSON-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.