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