Setup and onboarding
How the Vistrify workflow works
This guide is for buyers who want to inspect exactly how the workflow runs from setup through publishing.
Start with this if you are evaluating the product
- The free trial includes 1 site, and each paid site has its own subscription.
- The workflow is site -> keywords -> drafts -> calendar -> publishing.
- The cron generates drafts up to 2 days before publish day, then focuses mainly on publishing on the scheduled date.
1. Quick start (10 minutes)
- Create your account and complete plan selection for the first site.
- Add your site in Sites and set your target market (country + language).
- Generate keywords, create drafts, and review the content.
- Connect publishing (WordPress or API), then schedule dates in Calendar.
2. WordPress integration (easiest)
Use this option if your blog is running on WordPress.
- In WordPress go to Users -> Profile -> Application Passwords and create a new app password.
- In Vistrify: Settings -> Publish connections -> WordPress.
- Fill fields: wp_url (site URL), wp_username (WP user), wp_app_password (application password).
- Click Save WordPress, then publish one test draft from Drafts -> Publish to WordPress.
If successful, the article will appear immediately as a WordPress post.
3. Custom API / Webhook integration
Use this when you are not on WordPress or you publish to a custom CMS.
- Set webhook_url. This is the main publish endpoint that receives POST requests from Vistrify.
- Optionally add api_key. Vistrify sends it as Authorization: Bearer <token>.
- If you want Vistrify to update/delete already published posts, set update_webhook_url and delete_webhook_url. You can use {{postId}}, {postId}, or :id placeholders.
- Choose update_method and delete_method. By default, updates use PUT and deletes use DELETE.
- Click Save API, then Test connection. The test sends a safe payload and does not publish content.
What each field means
- webhook_url: required publish endpoint.
- api_key: optional bearer token for authorization.
- update_webhook_url: optional update endpoint. If omitted, Vistrify will try the main webhook_url.
- delete_webhook_url: optional delete endpoint. If omitted, Vistrify will try the main webhook_url or a known public post URL.
- update_method / delete_method: HTTP methods used for update/delete. If your platform only supports POST, set POST and branch on action in the payload.
Minimum viable webhook (publish only)
The simplest version only needs webhook_url. Your endpoint accepts POST, stores the article in your CMS or database, and returns any 2xx response.
If you do not need later updates or deletes yet, even { ok: true } is enough.
Publish payload sent by Vistrify
This is the default JSON sent by Custom API / Webhook. Some fields are intentionally duplicated under multiple names for compatibility with different CMSs.
POST /posts
Content-Type: application/json
Authorization: Bearer <api_key> // only if api_key is set
{
"action": "publish",
"id": null,
"postId": null,
"externalPostId": null,
"slug": "article-slug",
"postUrlHint": "https://example.com/blog/article-slug",
"title": "Article title",
"body": "<h1>Article title</h1><p>Rendered HTML content</p>",
"content": "<h1>Article title</h1><p>Rendered HTML content</p>",
"html": "<h1>Article title</h1><p>Rendered HTML content</p>",
"content_html": "<h1>Article title</h1><p>Rendered HTML content</p>",
"markdown": "# Markdown content",
"content_markdown": "# Markdown content",
"body_markdown": "# Markdown content",
"articleTitle": "Article title",
"articleContent": "<h1>Article title</h1><p>Rendered HTML content</p>",
"excerpt": "Short meta description",
"metaDescription": "Short meta description",
"seoDescription": "Short meta description",
"imageUrl": "https://cdn.vistrify.com/covers/generated/draft-123/article-title-v3.png",
"image_url": "https://cdn.vistrify.com/covers/generated/draft-123/article-title-v3.png",
"coverImageUrl": "https://cdn.vistrify.com/covers/generated/draft-123/article-title-v3.png",
"cover_image_url": "https://cdn.vistrify.com/covers/generated/draft-123/article-title-v3.png",
"featuredImage": "https://cdn.vistrify.com/covers/generated/draft-123/article-title-v3.png",
"featured_image": "https://cdn.vistrify.com/covers/generated/draft-123/article-title-v3.png",
"format": "html",
"sourceFormat": "markdown"
}Response Vistrify expects after publish
Any 2xx status is enough for publish itself. But if you want update/delete later, return a stable post ID and ideally the public post URL.
- Accepted ID keys: id, post_id, postId, article_id, articleId, resource_id.
- Accepted URL keys: url, link, post_url, postUrl, permalink, article_url, articleUrl.
HTTP/1.1 201 Created
Content-Type: application/json
{
"ok": true,
"id": "post_123",
"url": "https://example.com/blog/article-slug"
}Full CRUD webhook (publish + update + delete)
This is the better production setup. Return ID/URL on publish and configure dedicated update/delete endpoints, or use one endpoint that handles multiple actions.
If you use templated endpoints, Vistrify supports {{postId}}, {postId}, and :id. If there is no placeholder and Vistrify knows the post ID, it automatically appends /postId to the URL.
Update contract
Vistrify sends the same payload as publish, but with action = update and the ID/URL of the already published post.
By default, updates use PUT. If your platform prefers POST or the first attempt fails, Vistrify can retry as POST.
PUT /posts/{{postId}}
Content-Type: application/json
Authorization: Bearer <api_key> // only if api_key is set
{
"...publishFields": "same fields as publish payload",
"action": "update",
"id": "post_123",
"postId": "post_123",
"externalPostId": "post_123",
"url": "https://example.com/blog/article-slug",
"postUrl": "https://example.com/blog/article-slug",
"externalPostUrl": "https://example.com/blog/article-slug"
}Delete contract
By default, Vistrify calls the delete endpoint with DELETE. If you set delete_method = POST, the delete payload is sent as JSON.
If a DELETE endpoint responds with 405 or 501, Vistrify automatically retries with POST and action = delete.
DELETE /posts/{{postId}}
Authorization: Bearer <api_key> // only if api_key is set
// If delete_method is POST instead of DELETE, Vistrify sends JSON like:
{
"action": "delete",
"id": "post_123",
"postId": "post_123",
"externalPostId": "post_123",
"slug": "article-slug",
"url": "https://example.com/blog/article-slug",
"postUrl": "https://example.com/blog/article-slug",
"externalPostUrl": "https://example.com/blog/article-slug",
"title": "Article title",
"articleTitle": "Article title",
"body": "# Markdown content",
"content": "# Markdown content",
"markdown": "# Markdown content",
"articleContent": "# Markdown content",
"format": "markdown"
}How Test connection works
Test connection always sends a safe request to webhook_url. It does not publish content and can be treated as an auth-aware health check.
POST /posts
Content-Type: application/json
X-Vistrify-Test: 1
Authorization: Bearer <api_key> // only if api_key is set
{
"action": "test_connection"
}Working Node / Express example
This example shows the full publish + update + delete flow. Replace the in-memory Map with your CMS, database, or API calls.
import express from "express";
import crypto from "node:crypto";
const app = express();
app.use(express.json());
const posts = new Map();
function publicUrl(slug) {
return `https://your-site.com/blog/${slug}`;
}
app.post("/posts", (req, res) => {
if (req.header("X-Vistrify-Test") === "1" || req.body?.action === "test_connection") {
return res.json({ ok: true });
}
const id = crypto.randomUUID();
const slug = req.body.slug || id;
posts.set(id, {
id,
slug,
title: req.body.title,
body: req.body.content_html || req.body.html || req.body.body || "",
excerpt: req.body.metaDescription || req.body.excerpt || "",
imageUrl: req.body.imageUrl || req.body.coverImageUrl || "",
});
return res.status(201).json({
ok: true,
id,
url: publicUrl(slug),
});
});
app.put("/posts/:id", (req, res) => {
const existing = posts.get(req.params.id);
if (!existing) return res.status(404).json({ ok: false, error: "Not found" });
const slug = req.body.slug || existing.slug;
const nextPost = {
...existing,
slug,
title: req.body.title,
body: req.body.content_html || req.body.html || req.body.body || existing.body,
excerpt: req.body.metaDescription || req.body.excerpt || existing.excerpt,
imageUrl: req.body.imageUrl || req.body.coverImageUrl || existing.imageUrl,
};
posts.set(req.params.id, nextPost);
return res.json({
ok: true,
id: req.params.id,
url: publicUrl(slug),
});
});
app.delete("/posts/:id", (req, res) => {
if (!posts.has(req.params.id)) {
return res.status(404).json({ ok: false, error: "Not found" });
}
posts.delete(req.params.id);
return res.json({ ok: true, deleted: true, id: req.params.id });
});
app.listen(3000, () => {
console.log("Webhook listening on http://localhost:3000");
});Single-endpoint POST-only example
Use this variant if your CMS or backend only supports POST. In Settings, point webhook_url, update_webhook_url, and delete_webhook_url to the same endpoint, then set update_method and delete_method to POST.
In this model you branch on the action field: publish, update, delete, or test_connection.
import express from "express";
import crypto from "node:crypto";
const app = express();
app.use(express.json());
const posts = new Map();
function publicUrl(slug) {
return `https://your-site.com/blog/${slug}`;
}
app.post("/posts", (req, res) => {
const action = req.body?.action;
if (req.header("X-Vistrify-Test") === "1" || action === "test_connection") {
return res.json({ ok: true });
}
if (action === "publish") {
const id = crypto.randomUUID();
const slug = req.body.slug || id;
posts.set(id, {
id,
slug,
title: req.body.title,
body: req.body.content_html || req.body.html || req.body.body || "",
excerpt: req.body.metaDescription || req.body.excerpt || "",
imageUrl: req.body.imageUrl || req.body.coverImageUrl || "",
});
return res.status(201).json({
ok: true,
id,
url: publicUrl(slug),
});
}
if (action === "update") {
const id = req.body.postId || req.body.id || req.body.externalPostId;
const existing = id ? posts.get(id) : null;
if (!id || !existing) {
return res.status(404).json({ ok: false, error: "Not found" });
}
const slug = req.body.slug || existing.slug;
const nextPost = {
...existing,
slug,
title: req.body.title,
body: req.body.content_html || req.body.html || req.body.body || existing.body,
excerpt: req.body.metaDescription || req.body.excerpt || existing.excerpt,
imageUrl: req.body.imageUrl || req.body.coverImageUrl || existing.imageUrl,
};
posts.set(id, nextPost);
return res.json({
ok: true,
id,
url: publicUrl(slug),
});
}
if (action === "delete") {
const id = req.body.postId || req.body.id || req.body.externalPostId;
if (!id || !posts.has(id)) {
return res.status(404).json({ ok: false, error: "Not found" });
}
posts.delete(id);
return res.json({ ok: true, deleted: true, id });
}
return res.status(400).json({ ok: false, error: "Unsupported action" });
});
app.listen(3000, () => {
console.log("POST-only webhook listening on http://localhost:3000/posts");
});Most important rules
- If you want update/delete, return a stable id after publish.
- Also return the public post URL when possible, because it helps later operations and verification.
- If your system only supports POST, set update/delete to POST and branch on the action field.
- Your endpoint must actually create, update, or delete the post. Returning 200 OK without changing the CMS is not enough.
4. How auto-publishing works
- In Calendar, drag keywords to dates or use Auto-schedule.
- A daily cron generates drafts up to 2 days in advance, then uses the ready draft for publishing on the scheduled day.
- If publishing fails, the draft is still generated and waits for manual Publish.
5. Common errors and quick fixes
- 401 / 403: invalid credentials (WP app password or API token).
- 404: wrong webhook URL or missing endpoint.
- 502: Vistrify cannot reach your endpoint or your endpoint returned an error.
- "No connection configured": no publish connection saved for the active site.
- Post not visible: your endpoint accepts data but does not store/publish it in your CMS.
- Update/delete not working: the publish endpoint does not return a stable id/url, or the update/delete endpoint ignores postId.
- Test connection fails: check whether your firewall/WAF blocks POST requests or the X-Vistrify-Test header.
6. Support
If you want us to verify your setup step-by-step, email info@vistrify.com with your domain and integration type (WordPress or API).
Next step
If the setup looks right, launch one site
Use the docs first, then launch one workflow and judge the product on execution, not promises.
14-day free trial. No credit card. 1 site. Limited trial usage.