ViralHunt
ViralHunt (https://viralhunt.io) is a trending-content radar + cross-network scheduler.
This skill lets you (an agent) run the full loop for the user: find what's going viral →
curate it → schedule/publish it to their connected social accounts.
1. Get an API token (once)
Every call needs a personal token. The user creates one at
https://viralhunt.io → Account → API Access (owner/admin), then gives it to you. It
looks like vhk_.... If the user doesn't have one yet, send them there — a free 7-day
trial gives them a working key.
Send it on every request:
Authorization: Bearer vhk_the_users_token
Base URL: https://viralhunt.io/tool/api/v1/
Rate limit: 100 requests/hour per token (headers X-RateLimit-Remaining / -Reset; a 429
means wait until the reset). All responses are JSON: {"success":true,"data":{…}} or
{"success":false,"error":{"code","message"}}.
2. Find trending content
GET trending.php?source=<network>&sort=<sort>&time_range=<range>
source: tiktok | instagram | x | facebook | pinterest | bluesky | douyin |
reddit | mastodon | tumblr | hackernews | rss
sort: viral (default), engagement, newest, oldest, plus per network: most_liked,
most_viewed, most_commented, most_retweeted, most_reposted, most_saved,
most_upvoted (reddit), most_boosted (mastodon), most_noted (tumblr), most_points
(hackernews); for rss: trending, engagement, growth, bluesky, mentions, coverage,
hn, comments
time_range: 6h | 12h | 24h | 7d | 30d | 3m | all (on the post's own publish date).
Tumblr's corpus fills slowly, so 7d can be empty there: use 30d or all for tumblr.
- optional:
keyword=..., min_engagement=N, subreddit=name (reddit), page=N,
per_page=N (max 100)
Growth (growth_24h). Every post can carry growth_24h: how much it moved between our two
most distant readings, {from, to, delta, percent, hours, measured_at, samples}. Five rules:
null means we cannot say (only one reading, or a network with no snapshots: Facebook,
Pinterest, Tumblr). It does not mean zero. Never render it as 0%.
delta: 0 with samples >= 2 means measured and unchanged: two readings, same number. That
is a fact about the post (it is not moving), not missing data. Say "flat", not "no data".
hours is the real window between the two readings. The comparison point is the newest
reading at least 20 hours older than the latest one; when no reading is that old yet (a post
we found a few hours ago), the oldest reading is used and hours says how short the window
really is. It is often less than 24. Quote it ("+340% in 9h"), never assume 24. The field is
named for the target window, not a promise. full_window is the machine-readable form: true
only when the window reached 20 hours or more. Say "in 24h" only when it is true; otherwise
say "in Nh". RSS posts near the top of trending are mostly under a day old and are read at
about 1h, 2h, 5h, 11h and 23h after discovery, so a short window there is the normal case.
percent is null when from is under 100: from zero it is a division by zero, and from a
handful of interactions it is noise (70 to 188,247 is a real climb, "+268,824%" is not a
sentence anyone should print). delta always holds the absolute change and is the number to
report then.
samples is how many readings the window spans; 2 is the minimum that can say anything.
A large delta over few hours is what "going viral right now" looks like; prefer it over raw
totals when the user asks what is rising.
RSS signals. source=rss returns news articles from ~230 feeds plus articles discovered
through social links (feed name Discovered on social, source domain in author). Each carries
Facebook (facebook_shares/reactions/comments), Reddit (reddit_score/comments/submissions),
Pinterest, Bluesky (bluesky_likes/reposts/replies/accounts, bluesky_top_url), Hacker News
(hn_points/comments/url), comments on the article (comments_count/url), how many other outlets
ran the same story (coverage_count), and how many posts in our own corpus link it per network
(*_mentions, x_engagement, x_top_url). total_engagement and trend_score rank them.
curl -H "Authorization: Bearer $VH" \
"https://viralhunt.io/tool/api/v1/trending.php?source=tiktok&sort=viral&time_range=7d&per_page=10"
Returns ranked posts with engagement metrics, author, URL and thumbnail. Use this to tell
the user what's gaining velocity, or to pick something to curate and repost.
3. When and where to post (best time, hashtags, sounds, communities)
Four endpoints answer the questions that come right after "what is trending". Every one of them
returns the sample size and the time window its numbers rest on. Quote them: "over 12,400
posts of the last year" is an answer, a bare hour is a guess.
Best time to post. GET best-time.php?network=tiktok&timezone=America/Mexico_City[&keyword=fitness]
network: tiktok | instagram | x | facebook | pinterest | bluesky | mastodon |
douyin | tumblr | reddit. timezone is an IANA name (default UTC).
- Returns
best_slots[] (weekday + hour, ranked by average engagement, each with posts,
avg_engagement and hit_rate_pct = share of that slot's posts that reached the network's top
10%), worst_slot, today.best_hours, by_hour, by_weekday, proof_posts (the most viral
posts published in the best slot, with links), and sample {posts, window: "365d", from, to}.
- Read
confidence first: high (500+ posts), medium (200+), low. Do not name an hour on low.
- It measures when the posts that went viral were published, not when the audience is awake.
Say so when it matters. Weekday aggregates stay in UTC; hours are rotated to the zone.
- With
keyword=, the slots are recomputed on posts whose caption contains it. If that sample is
under 200 posts you get fallback: true, keyword_sample: N and the network-wide slots.
Top hashtags. GET hashtags.php[?network=instagram][&q=fitness][&sort=engagement|posts|per_post]
- Per network or across all (omit
network). Rows: hashtag, posts, total_engagement,
per_post. per_post is the one to compare: a tag used less but hitting harder.
GET hashtags.php?hashtag=fyp breaks one tag down by network with its top posts.
window.type is all_time with updated_at. There is no per-day hashtag history; if the user
asks for "this week", say the totals are over the whole corpus.
Trending sounds. GET sounds.php[?network=tiktok|instagram|douyin|all][&q=espresso][&cross_only=1]
- Ranked by the engagement of the posts that used the sound, cross-network sounds first. A sound
is listed only when several different accounts used it (one account's audio is a voiceover).
- Rows carry
networks{tiktok|instagram|douyin: posts, creators, eng, per_post}, cross, and
stronger (tiktok | instagram | even) when both sides are measured.
GET sounds.php?slug=<slug> returns one sound with the posts that used it (links included).
Best communities. GET communities.php?network=reddit[&q=running][&sort=upside|members|peak|posts][&max_members=200000]
- Reddit rows are ranked by
peak_per_1k: the best score we hold per 1,000 members, i.e. upside
relative to size. No average score is published (a swept community's sample is its greatest
hits). Gates: 25 posts held, 5,000 members, one post over 100 points, adult excluded.
GET communities.php?network=reddit&subreddit=running adds timing (best UTC hours and day
the climbing posts were posted, with sample), pace, flairs, type_mix, top_posts and
similar communities. Use max_members to find smaller rooms that are easier to climb.
GET communities.php?network=bluesky[&q=science] lists Bluesky custom feeds (a feed
surfaces your post to its readers without a follow) with posts, authors, avg_likes;
&feed=<slug> adds top posts, authors and hashtags.
curl -H "Authorization: Bearer $VH" \
"https://viralhunt.io/tool/api/v1/best-time.php?network=instagram&timezone=Europe/Madrid"
curl -H "Authorization: Bearer $VH" \
"https://viralhunt.io/tool/api/v1/communities.php?network=reddit&q=running&max_members=300000"
A good answer to "when should I post this on Instagram?" combines them: the best slot in the
user's zone with its hit rate and sample, two or three hashtags with high per_post, and, for a
Reel, a sound that is cross or stronger: instagram.
4. See where you can publish
GET schedule.php?action=targets
Returns the user's projects (brands/workspaces) and the connected accounts you can
post to in each:
{ "project": {"id":1,"name":"My Brand","timezone":"America/Mexico_City"},
"projects": [ {"id":1,"name":"My Brand","networks":["instagram","facebook"],"account_count":4} ],
"accounts": [ {"account_id":12,"network":"instagram","name":"@mybrand"} ] }
If a project has 0 accounts, the user is on a plan without connected accounts —
publishing won't work until they connect accounts in the app (Agency plans).
5. Publish now or schedule
POST schedule.php?action=create with a JSON body:
{
"project": "My Brand", // or "project_id": 1 — REQUIRED if the org has >1 project
"body": "Your caption text",
"media": ["https://.../image_or_video.mp4"], // public URLs; optional if body is set
"target_account_ids": [12, 15], // omit = ALL accounts in the project
"networks": ["instagram","facebook"],// alternative to target_account_ids
"scheduled_at": "2026-08-01T15:30:00Z", // ISO-8601 UTC; omit = publish immediately
"first_comment": "Link in comments 👇" // optional; posted as the first comment
}
curl -X POST -H "Authorization: Bearer $VH" -H "Content-Type: application/json" \
-d '{"project":"My Brand","body":"Hello world","networks":["instagram"]}' \
"https://viralhunt.io/tool/api/v1/schedule.php?action=create"
Returns { id, status, scheduled_at, targets, results, warnings }. status is
processing (publishing now), scheduled (queued), partial (some targets failed — see
warnings), or failed. It fans out one post per target account.
Rules to follow so you don't create bad posts:
- If the org has more than one project, you must pass
project or project_id — the
API refuses to guess (so a post never lands on the wrong brand).
- Only pass
scheduled_at in the future, in UTC.
- Verify the content before publishing: don't repost fake news, copyrighted media, or spam
— that gets the user's accounts banned. When unsure, show the user and ask.
6. Upload media (optional)
If you have a local file instead of a URL:
POST schedule.php?action=upload — multipart form field file (jpg/png/gif/webp/mp4/mov,
≤50MB). Returns { "url": "https://..." }. Pass that URL in media on create.
7. Edit or cancel a scheduled post
Posts can be edited ONLY while their status is scheduled (not yet publishing), and PostProxy
won't allow an edit less than ~5 minutes before publish time.
- Cancel:
POST schedule.php?action=cancel — {"id": 123}. Cancels the not-yet-published
targets. If everything already published you get 409 already_published.
- Update:
POST schedule.php?action=update — {"id": 123, "body": "...", "media": ["..."], "networks": ["facebook"], "scheduled_at": "2026-08-01T15:30:00Z"}. Only the fields you send
change. The project cannot be changed.
Agent rules:
- Before editing,
GET schedule.php?action=get&id=123 and confirm status is scheduled.
- On
409 (already publishing/published or too close to publish time), re-fetch and tell the
user — never retry an update blindly.
- After any edit,
GET again and confirm the change landed before reporting done.
8. Check status
GET schedule.php?action=get&id=<post_id> → the post's current status and per-network
results. Call POST schedule.php?action=sync first to refresh from the networks.
9. Content templates (make the image, don't just write the caption)
ViralHunt ships layout templates — HTML + CSS + a variable manifest — so the graphics you
produce are on-brand and pixel-exact. ViralHunt does not render them: you do.
GET templates.php → the library (lean: no html/css, so it doesn't flood your context).
GET templates.php?slug=vh-image-card → that one template's full spec, including html,
css, variables, formats, palette, fonts and render_tech.
Filters: category, media_type=image|video, network, q, and
assigned=1&project_id=N (only the templates that project is allowed to use).
The render loop:
GET templates.php?assigned=1&project_id=N → pick a template for what you're posting.
GET templates.php?slug=… → read variables, html, css, formats, render_tech.
- Generate a value for every variable, obeying its
description and its rules
(max_chars, no_em_dash, must_appear_in, enum…). rules are hard constraints — if a
value breaks one, fix it before rendering, and use the variable's fallback if generation
fails. Never invent values for a variable bound to a content_source (a curated bank);
read them from that bank in order and stop when it's exhausted.
- Replace every
{{key}} in html with your value, include the css, pick a size from
formats (each entry carries w, h and the networks it suits).
- Render as
render_tech says — for image templates that's headless Chrome at the format's
w×h: load the html+css, wait for [data-vh-ready="1"] (the template sets it once its
fonts and images have loaded and the headline has auto-shrunk), then screenshot the
.vh-card element. Screenshotting earlier gives you a blank or badly-typeset card.
- Upload the PNG (
POST schedule.php?action=upload) and pass the returned URL in media on
schedule.php?action=create.
The fonts array carries woff2 URLs and the css already @font-faces them, so the render is
identical everywhere — don't substitute local fonts. Colors are tokens, not hex: a variable
of type: "token" takes a key from palette (e.g. "cyan"), never #1edbee.
Authoring your own templates (owner/admin): POST templates.php with
{"action":"create"|"update", "slug", "name", "category", "html", "css", "variables", …}.
Editing a curated/global template clones it into the org's own copy — the global is
never modified, and on update you may send only the fields that change.
Two rules the API enforces, so design for them:
- Every
{{placeholder}} in html must be declared in variables, or the call is
rejected. A template with an undeclared hole would render blank for whoever fetches it.
html and css are capped at 256KB each. Reference images and fonts by URL — the
fonts array is how a font travels with the template.
Write the manifest as carefully as the layout: description is the instruction another
agent (or you, later) will generate from, and rules are the hard constraints it must
satisfy. A template whose manifest just says "text" produces bad cards.
To change which templates a project may use (owner/admin only):
POST template-assignments.php — {"project_id": 1, "template_id": 7, "action": "add"|"remove"}.
Humans do the same from Templates in the app.
Editorial board (optional)
You can also organize work on the user's kanban board instead of publishing directly. The
board is how a team curates before anything goes out.
GET context.php — organization, members (people and agents, with the ids you assign to),
columns (with is_default / is_done) and categories, in one call. Start here.
POST cards.php — create a card. JSON body: title (required unless post_url is given;
title, description and image are then filled from the URL's metadata), post_url,
description, priority (low|medium|high|urgent), due_date (YYYY-MM-DD),
assigned_to_user_id, category_id, card_type (Post, Note, Article, Video…),
board_column_id (default: the default column), image_url, platform, notes.
Assigning a card notifies the person (push + email).
POST cards.php with {"action":"move","card_id":N,"board_column_id":M} — move it. Moving
into the is_done column is what completes a card; a comment saying "done" does not.
GET my-cards.php[?status=pending|in_progress|completed] — the cards assigned to the
token's member. When the token belongs to an agent member, this is your workload.
GET comments.php?card_id=N / POST comments.php {"card_id":N,"comment":"…"} — read or
add comments; new comments notify the assignee and mirror to the team chat's #board channel.
GET columns.php, GET categories.php, GET card-types.php, GET members.php,
GET pending.php (pending cards grouped by member) — the pieces of context.php on their own.
The agent loop on a board: read my-cards.php → validate the post (no fake news, ToS
violations, copyright or spam; if in doubt comment and move it to a review column) → move to an
in-progress column → comment progress → move to the is_done column. Full docs at
https://viralhunt.io/api.
Error handling
| HTTP |
code |
what to do |
| 401 |
invalid_key |
token missing/revoked — ask the user for a fresh one |
| 403 |
no_accounts |
project has no connected accounts — user must connect them in-app |
| 422 |
project_required |
org has multiple projects — pass project/project_id |
| 422 |
validation_error |
fix the parameter named in the message |
| 429 |
rate_limited |
wait until X-RateLimit-Reset, then retry |
| 503 |
scheduler_unavailable / publishing_unavailable |
scheduling not enabled on this site |
Always surface the error.message to the user verbatim — it explains exactly what to fix.
1---
2name: viralhunt
3description: >-
4 Discover what's trending/going viral across TikTok, Instagram, X, Facebook,
5 Pinterest, Bluesky, Douyin, Reddit, Mastodon, Tumblr, Hacker News and news RSS,
6 learn the best time to post on each network from measured viral posts, find the
7 top hashtags, trending sounds and best communities, and schedule or publish posts
8 to the user's own connected social accounts — powered by the ViralHunt.io API.
9 Use this whenever the user wants to find viral or trending content in a niche,
10 research what's performing on social right now, ask when or where to post, or
11 draft/schedule/publish social posts across networks.
12license: MIT
13metadata:
14 author: viralhunt-io
15---
16
17# ViralHunt
18
19ViralHunt (https://viralhunt.io) is a trending-content radar + cross-network scheduler.
20This skill lets you (an agent) run the full loop for the user: **find what's going viral →
21curate it → schedule/publish it** to their connected social accounts.
22
23## 1. Get an API token (once)
24
25Every call needs a personal token. The user creates one at
26**https://viralhunt.io → Account → API Access** (owner/admin), then gives it to you. It
27looks like `vhk_...`. If the user doesn't have one yet, send them there — a free 7-day
28trial gives them a working key.
29
30Send it on every request:
31
32```
33Authorization: Bearer vhk_the_users_token
34```
35
36Base URL: `https://viralhunt.io/tool/api/v1/`
37Rate limit: 100 requests/hour per token (headers `X-RateLimit-Remaining` / `-Reset`; a 429
38means wait until the reset). All responses are JSON: `{"success":true,"data":{…}}` or
39`{"success":false,"error":{"code","message"}}`.
40
41## 2. Find trending content
42
43`GET trending.php?source=<network>&sort=<sort>&time_range=<range>`
44
45- `source`: `tiktok` | `instagram` | `x` | `facebook` | `pinterest` | `bluesky` | `douyin` |
46 `reddit` | `mastodon` | `tumblr` | `hackernews` | `rss`
47- `sort`: `viral` (default), `engagement`, `newest`, `oldest`, plus per network: `most_liked`,
48 `most_viewed`, `most_commented`, `most_retweeted`, `most_reposted`, `most_saved`,
49 `most_upvoted` (reddit), `most_boosted` (mastodon), `most_noted` (tumblr), `most_points`
50 (hackernews); for `rss`: `trending`, `engagement`, `growth`, `bluesky`, `mentions`, `coverage`,
51 `hn`, `comments`
52- `time_range`: `6h` | `12h` | `24h` | `7d` | `30d` | `3m` | `all` (on the post's own publish date).
53 Tumblr's corpus fills slowly, so `7d` can be empty there: use `30d` or `all` for `tumblr`.
54- optional: `keyword=...`, `min_engagement=N`, `subreddit=name` (reddit), `page=N`,
55 `per_page=N` (max 100)
56
57**Growth (`growth_24h`).** Every post can carry `growth_24h`: how much it moved between our two
58most distant readings, `{from, to, delta, percent, hours, measured_at, samples}`. Five rules:
59- `null` means **we cannot say** (only one reading, or a network with no snapshots: Facebook,
60 Pinterest, Tumblr). It does **not** mean zero. Never render it as 0%.
61- `delta: 0` with `samples >= 2` means **measured and unchanged**: two readings, same number. That
62 is a fact about the post (it is not moving), not missing data. Say "flat", not "no data".
63- `hours` is the **real** window between the two readings. The comparison point is the newest
64 reading at least 20 hours older than the latest one; when no reading is that old yet (a post
65 we found a few hours ago), the oldest reading is used and `hours` says how short the window
66 really is. It is often less than 24. Quote it ("+340% in 9h"), never assume 24. The field is
67 named for the target window, not a promise. `full_window` is the machine-readable form: `true`
68 only when the window reached 20 hours or more. Say "in 24h" only when it is `true`; otherwise
69 say "in Nh". RSS posts near the top of `trending` are mostly under a day old and are read at
70 about 1h, 2h, 5h, 11h and 23h after discovery, so a short window there is the normal case.
71- `percent` is `null` when `from` is under 100: from zero it is a division by zero, and from a
72 handful of interactions it is noise (70 to 188,247 is a real climb, "+268,824%" is not a
73 sentence anyone should print). `delta` always holds the absolute change and is the number to
74 report then.
75- `samples` is how many readings the window spans; 2 is the minimum that can say anything.
76A large `delta` over few `hours` is what "going viral right now" looks like; prefer it over raw
77totals when the user asks what is *rising*.
78
79**RSS signals.** `source=rss` returns news articles from ~230 feeds **plus articles discovered
80through social links** (feed name `Discovered on social`, source domain in `author`). Each carries
81Facebook (`facebook_shares/reactions/comments`), Reddit (`reddit_score/comments/submissions`),
82Pinterest, Bluesky (`bluesky_likes/reposts/replies/accounts`, `bluesky_top_url`), Hacker News
83(`hn_points/comments/url`), comments on the article (`comments_count/url`), how many other outlets
84ran the same story (`coverage_count`), and how many posts in our own corpus link it per network
85(`*_mentions`, `x_engagement`, `x_top_url`). `total_engagement` and `trend_score` rank them.
86
87```bash
88curl -H "Authorization: Bearer $VH" \
89 "https://viralhunt.io/tool/api/v1/trending.php?source=tiktok&sort=viral&time_range=7d&per_page=10"
90```
91
92Returns ranked posts with engagement metrics, author, URL and thumbnail. Use this to tell
93the user what's gaining velocity, or to pick something to curate and repost.
94
95## 3. When and where to post (best time, hashtags, sounds, communities)
96
97Four endpoints answer the questions that come right after "what is trending". Every one of them
98returns the **sample size** and the **time window** its numbers rest on. Quote them: "over 12,400
99posts of the last year" is an answer, a bare hour is a guess.
100
101**Best time to post.** `GET best-time.php?network=tiktok&timezone=America/Mexico_City[&keyword=fitness]`
102- `network`: `tiktok` | `instagram` | `x` | `facebook` | `pinterest` | `bluesky` | `mastodon` |
103 `douyin` | `tumblr` | `reddit`. `timezone` is an IANA name (default UTC).
104- Returns `best_slots[]` (weekday + hour, ranked by average engagement, each with `posts`,
105 `avg_engagement` and `hit_rate_pct` = share of that slot's posts that reached the network's top
106 10%), `worst_slot`, `today.best_hours`, `by_hour`, `by_weekday`, `proof_posts` (the most viral
107 posts published in the best slot, with links), and `sample {posts, window: "365d", from, to}`.
108- Read `confidence` first: `high` (500+ posts), `medium` (200+), `low`. Do not name an hour on `low`.
109- It measures when the posts that went viral were **published**, not when the audience is awake.
110 Say so when it matters. Weekday aggregates stay in UTC; hours are rotated to the zone.
111- With `keyword=`, the slots are recomputed on posts whose caption contains it. If that sample is
112 under 200 posts you get `fallback: true`, `keyword_sample: N` and the network-wide slots.
113
114**Top hashtags.** `GET hashtags.php[?network=instagram][&q=fitness][&sort=engagement|posts|per_post]`
115- Per network or across all (omit `network`). Rows: `hashtag`, `posts`, `total_engagement`,
116 `per_post`. `per_post` is the one to compare: a tag used less but hitting harder.
117- `GET hashtags.php?hashtag=fyp` breaks one tag down by network with its top posts.
118- `window.type` is `all_time` with `updated_at`. There is no per-day hashtag history; if the user
119 asks for "this week", say the totals are over the whole corpus.
120
121**Trending sounds.** `GET sounds.php[?network=tiktok|instagram|douyin|all][&q=espresso][&cross_only=1]`
122- Ranked by the engagement of the posts that used the sound, cross-network sounds first. A sound
123 is listed only when several different accounts used it (one account's audio is a voiceover).
124- Rows carry `networks{tiktok|instagram|douyin: posts, creators, eng, per_post}`, `cross`, and
125 `stronger` (`tiktok` | `instagram` | `even`) when both sides are measured.
126- `GET sounds.php?slug=<slug>` returns one sound with the posts that used it (links included).
127
128**Best communities.** `GET communities.php?network=reddit[&q=running][&sort=upside|members|peak|posts][&max_members=200000]`
129- Reddit rows are ranked by `peak_per_1k`: the best score we hold per 1,000 members, i.e. upside
130 relative to size. No average score is published (a swept community's sample is its greatest
131 hits). Gates: 25 posts held, 5,000 members, one post over 100 points, adult excluded.
132- `GET communities.php?network=reddit&subreddit=running` adds `timing` (best UTC hours and day
133 the climbing posts were posted, with sample), `pace`, `flairs`, `type_mix`, `top_posts` and
134 `similar` communities. Use `max_members` to find smaller rooms that are easier to climb.
135- `GET communities.php?network=bluesky[&q=science]` lists Bluesky **custom feeds** (a feed
136 surfaces your post to its readers without a follow) with `posts`, `authors`, `avg_likes`;
137 `&feed=<slug>` adds top posts, authors and hashtags.
138
139```bash
140curl -H "Authorization: Bearer $VH" \
141 "https://viralhunt.io/tool/api/v1/best-time.php?network=instagram&timezone=Europe/Madrid"
142curl -H "Authorization: Bearer $VH" \
143 "https://viralhunt.io/tool/api/v1/communities.php?network=reddit&q=running&max_members=300000"
144```
145
146A good answer to "when should I post this on Instagram?" combines them: the best slot in the
147user's zone with its hit rate and sample, two or three hashtags with high `per_post`, and, for a
148Reel, a sound that is `cross` or `stronger: instagram`.
149
150## 4. See where you can publish
151
152`GET schedule.php?action=targets`
153
154Returns the user's **projects** (brands/workspaces) and the **connected accounts** you can
155post to in each:
156
157```json
158{ "project": {"id":1,"name":"My Brand","timezone":"America/Mexico_City"},
159 "projects": [ {"id":1,"name":"My Brand","networks":["instagram","facebook"],"account_count":4} ],
160 "accounts": [ {"account_id":12,"network":"instagram","name":"@mybrand"} ] }
161```
162
163If a project has **0 accounts**, the user is on a plan without connected accounts —
164publishing won't work until they connect accounts in the app (Agency plans).
165
166## 5. Publish now or schedule
167
168`POST schedule.php?action=create` with a JSON body:
169
170```json
171{
172 "project": "My Brand", // or "project_id": 1 — REQUIRED if the org has >1 project
173 "body": "Your caption text",
174 "media": ["https://.../image_or_video.mp4"], // public URLs; optional if body is set
175 "target_account_ids": [12, 15], // omit = ALL accounts in the project
176 "networks": ["instagram","facebook"],// alternative to target_account_ids
177 "scheduled_at": "2026-08-01T15:30:00Z", // ISO-8601 UTC; omit = publish immediately
178 "first_comment": "Link in comments 👇" // optional; posted as the first comment
179}
180```
181
182```bash
183curl -X POST -H "Authorization: Bearer $VH" -H "Content-Type: application/json" \
184 -d '{"project":"My Brand","body":"Hello world","networks":["instagram"]}' \
185 "https://viralhunt.io/tool/api/v1/schedule.php?action=create"
186```
187
188Returns `{ id, status, scheduled_at, targets, results, warnings }`. `status` is
189`processing` (publishing now), `scheduled` (queued), `partial` (some targets failed — see
190`warnings`), or `failed`. It fans out one post per target account.
191
192**Rules to follow so you don't create bad posts:**
193- If the org has more than one project, you **must** pass `project` or `project_id` — the
194 API refuses to guess (so a post never lands on the wrong brand).
195- Only pass `scheduled_at` in the future, in UTC.
196- Verify the content before publishing: don't repost fake news, copyrighted media, or spam
197 — that gets the user's accounts banned. When unsure, show the user and ask.
198
199## 6. Upload media (optional)
200
201If you have a local file instead of a URL:
202
203`POST schedule.php?action=upload` — multipart form field `file` (jpg/png/gif/webp/mp4/mov,
204≤50MB). Returns `{ "url": "https://..." }`. Pass that URL in `media` on create.
205
206## 7. Edit or cancel a scheduled post
207
208Posts can be edited ONLY while their status is `scheduled` (not yet publishing), and PostProxy
209won't allow an edit less than ~5 minutes before publish time.
210
211- **Cancel:** `POST schedule.php?action=cancel` — `{"id": 123}`. Cancels the not-yet-published
212 targets. If everything already published you get `409 already_published`.
213- **Update:** `POST schedule.php?action=update` — `{"id": 123, "body": "...", "media": ["..."],
214 "networks": ["facebook"], "scheduled_at": "2026-08-01T15:30:00Z"}`. Only the fields you send
215 change. The project cannot be changed.
216
217Agent rules:
218- Before editing, `GET schedule.php?action=get&id=123` and confirm status is `scheduled`.
219- On `409` (already publishing/published or too close to publish time), re-fetch and tell the
220 user — never retry an update blindly.
221- After any edit, `GET` again and confirm the change landed before reporting done.
222
223## 8. Check status
224
225`GET schedule.php?action=get&id=<post_id>` → the post's current status and per-network
226results. Call `POST schedule.php?action=sync` first to refresh from the networks.
227
228## 9. Content templates (make the image, don't just write the caption)
229
230ViralHunt ships **layout templates** — HTML + CSS + a variable manifest — so the graphics you
231produce are on-brand and pixel-exact. ViralHunt does **not** render them: you do.
232
233`GET templates.php` → the library (lean: no html/css, so it doesn't flood your context).
234`GET templates.php?slug=vh-image-card` → **that one template's full spec**, including `html`,
235`css`, `variables`, `formats`, `palette`, `fonts` and `render_tech`.
236
237Filters: `category`, `media_type=image|video`, `network`, `q`, and
238`assigned=1&project_id=N` (only the templates that project is allowed to use).
239
240**The render loop:**
241
2421. `GET templates.php?assigned=1&project_id=N` → pick a template for what you're posting.
2432. `GET templates.php?slug=…` → read `variables`, `html`, `css`, `formats`, `render_tech`.
2443. Generate a value for **every** variable, obeying its `description` and its `rules`
245 (`max_chars`, `no_em_dash`, `must_appear_in`, `enum`…). `rules` are hard constraints — if a
246 value breaks one, fix it before rendering, and use the variable's `fallback` if generation
247 fails. Never invent values for a variable bound to a `content_source` (a curated bank);
248 read them from that bank in order and stop when it's exhausted.
2494. Replace every `{{key}}` in `html` with your value, include the `css`, pick a size from
250 `formats` (each entry carries `w`, `h` and the `networks` it suits).
2515. Render as `render_tech` says — for image templates that's headless Chrome at the format's
252 `w`×`h`: load the html+css, **wait for `[data-vh-ready="1"]`** (the template sets it once its
253 fonts and images have loaded and the headline has auto-shrunk), then screenshot the
254 `.vh-card` element. Screenshotting earlier gives you a blank or badly-typeset card.
2556. Upload the PNG (`POST schedule.php?action=upload`) and pass the returned URL in `media` on
256 `schedule.php?action=create`.
257
258The `fonts` array carries woff2 URLs and the `css` already `@font-face`s them, so the render is
259identical everywhere — don't substitute local fonts. Colors are **tokens**, not hex: a variable
260of `type: "token"` takes a key from `palette` (e.g. `"cyan"`), never `#1edbee`.
261
262**Authoring your own templates** (owner/admin): `POST templates.php` with
263`{"action":"create"|"update", "slug", "name", "category", "html", "css", "variables", …}`.
264Editing a curated/global template **clones it into the org's own copy** — the global is
265never modified, and on `update` you may send only the fields that change.
266
267Two rules the API enforces, so design for them:
268- Every `{{placeholder}}` in `html` must be declared in `variables`, or the call is
269 rejected. A template with an undeclared hole would render blank for whoever fetches it.
270- `html` and `css` are capped at 256KB each. Reference images and fonts by URL — the
271 `fonts` array is how a font travels with the template.
272
273Write the manifest as carefully as the layout: `description` is the instruction another
274agent (or you, later) will generate from, and `rules` are the hard constraints it must
275satisfy. A template whose manifest just says "text" produces bad cards.
276
277To change which templates a project may use (owner/admin only):
278`POST template-assignments.php` — `{"project_id": 1, "template_id": 7, "action": "add"|"remove"}`.
279Humans do the same from **Templates** in the app.
280
281## Editorial board (optional)
282
283You can also organize work on the user's kanban board instead of publishing directly. The
284board is how a team curates before anything goes out.
285
286- `GET context.php` — organization, members (people and agents, with the ids you assign to),
287 columns (with `is_default` / `is_done`) and categories, in one call. Start here.
288- `POST cards.php` — create a card. JSON body: `title` (required unless `post_url` is given;
289 title, description and image are then filled from the URL's metadata), `post_url`,
290 `description`, `priority` (`low|medium|high|urgent`), `due_date` (YYYY-MM-DD),
291 `assigned_to_user_id`, `category_id`, `card_type` (Post, Note, Article, Video…),
292 `board_column_id` (default: the default column), `image_url`, `platform`, `notes`.
293 Assigning a card notifies the person (push + email).
294- `POST cards.php` with `{"action":"move","card_id":N,"board_column_id":M}` — move it. Moving
295 into the `is_done` column is what completes a card; a comment saying "done" does not.
296- `GET my-cards.php[?status=pending|in_progress|completed]` — the cards assigned to the
297 token's member. When the token belongs to an agent member, this is your workload.
298- `GET comments.php?card_id=N` / `POST comments.php` `{"card_id":N,"comment":"…"}` — read or
299 add comments; new comments notify the assignee and mirror to the team chat's #board channel.
300- `GET columns.php`, `GET categories.php`, `GET card-types.php`, `GET members.php`,
301 `GET pending.php` (pending cards grouped by member) — the pieces of `context.php` on their own.
302
303The agent loop on a board: read `my-cards.php` → validate the post (no fake news, ToS
304violations, copyright or spam; if in doubt comment and move it to a review column) → move to an
305in-progress column → comment progress → move to the `is_done` column. Full docs at
306**https://viralhunt.io/api**.
307
308## Error handling
309
310| HTTP | code | what to do |
311|------|------|-----------|
312| 401 | `invalid_key` | token missing/revoked — ask the user for a fresh one |
313| 403 | `no_accounts` | project has no connected accounts — user must connect them in-app |
314| 422 | `project_required` | org has multiple projects — pass `project`/`project_id` |
315| 422 | `validation_error` | fix the parameter named in the message |
316| 429 | `rate_limited` | wait until `X-RateLimit-Reset`, then retry |
317| 503 | `scheduler_unavailable` / `publishing_unavailable` | scheduling not enabled on this site |
318
319Always surface the `error.message` to the user verbatim — it explains exactly what to fix.