HELP · CREATE AND SEND POSTS
Send posts from your tools with the API
Growfinch’s REST API lets your own tools, such as a render pipeline, a CMS or a script, send campaigns, upload their videos and images, fill queues and read results. Everything your tool sends follows the business’s rules: where the business requires approval, each campaign waits for a person in the console. The API can’t approve.
Updated 5 November 2026 · Growfinch
Before you start
- The base address is
https://growfinch.com/api/v1. Every path on this page sits under it. The list of endpoints is on the API’s feature page; this page shows how to use them. - An API key made for your businesses (next section).
- The accounts connected. Growfinch publishes only to accounts the workspace owner connected in the console: YouTube, Instagram and X.
- Prefer to ask in plain words? Claude can do all of this for you through the same key: see Use Growfinch with Claude.
Get an API key
The console’s Developer page says Not in this release yet: you can’t make a key yourself. During early access, Growfinch issues keys on request. Write to support at support@growfinch.com with the businesses the key is for and the scopes you need.
| Scope | What it lets the key do |
|---|---|
campaigns:read | Read campaigns, queues and their plans, post metrics, and your webhook endpoints. |
campaigns:write | Validate, create and cancel campaigns; create, change and delete queues and their items. |
media:write | Register, upload, complete and read media files. |
webhooks:write | Register, ping and remove webhook endpoints. Ask for it only if you’ll receive events. |
Any key can list its businesses, their connections and the items waiting for approval. No scope approves: the contract lists approvals:write, but Growfinch refuses approval to every key, whatever its scopes.
A key starts with mvk_. Growfinch stores only a hash of it, so keep it like a password: in a password manager or your system’s keychain, never in a file you share or commit. If a key leaks, write to support and we’ll revoke it.
Authenticate every request
Send the key in the Authorization header as Bearer, a space, then the key. The examples on this page read it from an environment variable. To set it without it showing on screen or landing in your shell history, run this, paste the key and press Enter:
read -rs GROWFINCH_API_KEY && export GROWFINCH_API_KEY
Then make your first call. It lists the businesses the key may act on:
curl -sS https://growfinch.com/api/v1/businesses \ -H "Authorization: Bearer $GROWFINCH_API_KEY" \ -H "x-maovora-contract: 1.2.0"
Each business comes back with its slug, name, timezone, default_mode (its publishing rule) and required_checks. The slug is the business_id you send in every other call. Growfinch never guesses the business.
| Header | What it does |
|---|---|
Authorization | Required on every request: Bearer and your key. |
x-maovora-contract | Optional. The contract version your tool was built against; today 1.2.0. Growfinch serves major version 1 and answers contract_version to a request that names another major. Every response carries this header with the version Growfinch runs. |
x-request-id | Optional. Your own id for the request; Growfinch makes one when you don’t. It comes back in the response header and in every error body. Quote it when you write to support. |
content-type | application/json on every request with a body. |
A minor version may add fields to a response. Ignore the ones you don’t know.
Send a campaign: validate, then create
A campaign is one piece of content for one business: one or more posts, each for one account at its own time. Send it in two steps. Validate is a dry run that writes nothing. Create stores the campaign, and where the business requires approval, files it on Approvals.
1. Write the body
Save this as campaign.json, with your own values in place of the parts in angle brackets:
{
"business_id": "<business id>",
"name": "<campaign name>",
"idempotency_key": "<a new key for this campaign, 8 to 128 characters>",
"posts": [
{
"platform": "youtube",
"format": "short",
"title": "<title, 100 characters at most>",
"caption": "<description>",
"link": "https://example.com/?utm_content=<your label>",
"scheduled_at": "2026-11-03T09:00:00Z",
"media": [{ "media_id": "<media id>", "kind": "video", "sha256": "<sha256>" }]
},
{
"platform": "x",
"format": "video",
"caption": "<post text, 280 characters at most>",
"first_reply": "<your link, posted as the first reply>",
"scheduled_at": "2026-11-03T09:00:00Z",
"media": [{ "media_id": "<media id>", "kind": "video", "sha256": "<sha256>" }]
}
]
}| Campaign field | What to put |
|---|---|
business_id | Required. The business’s slug from GET /businesses. |
name | Required, up to 120 characters. The name the approver sees. |
idempotency_key | Required, 8 to 128 characters. Sending the same key again for the same business returns the campaign made the first time, with replayed: true, instead of a second one. Growfinch doesn’t compare the bodies, so use a new key for each new campaign. |
posts | Required: 1 to 40 posts (below). |
policy | approval_required (the default), draft_only to keep it a draft, or automatic. Automatic goes out without approval only where a person allowed it for the business and its template_class; otherwise it still waits. |
checks | Your own checks’ results: name, passed and an optional detail. A business can require checks by name; one missing or failed stops the campaign from being scheduled. |
template_id, template_class | Your template’s id and kind, if you use templates. |
payload | Free-form data your templates use, stored with the campaign. |
source_ref, content_package_id | Your own reference (up to 200 characters), and an id that groups the platform versions of one piece of content. |
source_check | { url, version, field }. Before the posts go out, Growfinch reads url and compares its field (default version) with version. If they differ, the scheduled posts are cancelled and the owner is told on Approvals. |
| Post field | What to put |
|---|---|
platform, format | Required. See the formats below. |
caption | Required. X takes 280 characters, Instagram 2,200, YouTube 5,000 (the description). |
scheduled_at | Required. An ISO 8601 time with Z or an offset, such as 2026-11-03T09:00:00+05:30. |
media | The file the post carries: its media_id from the upload, its kind (video or image) and its sha256. Instead of a media_id you can give a url Growfinch can download. For an Instagram Reel, a second item with "role": "cover" and an image sets the cover. |
title | YouTube: required, up to 100 characters. |
first_reply | X: posted as a reply under the post right after it goes out. Put the link here. |
link | The click-through link. On YouTube the channel posts it as a comment under the Short, when the connection’s link comment setting is on. Its utm_content value comes back on the post as attribution_key, for your own analytics. |
tags | YouTube only: up to 30 video tags, 500 characters in all. |
meta | Your own labels for the results, such as a content pillar: snake_case names; text up to 120 characters, numbers or true/false. |
connection_id | Optional: a particular account. Without it, Growfinch uses the business’s connected account for the platform. |
| Platform | Formats Growfinch publishes today |
|---|---|
YouTube (youtube) | short: a vertical video. |
Instagram (instagram) | reel (a video) and single_image. |
X (x) | video and single_image. |
Every one of these formats carries one file, so each post needs its media.
2. Validate it
curl -sS https://growfinch.com/api/v1/campaigns/validate \ -H "Authorization: Bearer $GROWFINCH_API_KEY" \ -H "x-maovora-contract: 1.2.0" \ -H "content-type: application/json" \ --data @campaign.json
Nothing is stored. The answer has four parts:
ok: true when every line of the checklist passes.checklist: one line per check, each withname,okanddetail. It covers each post’s account (connected, or expired and needing a reconnect), its format, its caption length, YouTube’s title, the earliest time the content may go out, and each check the business requires.previews: one per post, with the characters used against the limit andwarnings, such as too many hashtags or a field the platform will drop.decision: what create would do:draft,pending_approvalorscheduled.
You can validate before you upload: in a dry run, a media item may carry only its kind and sha256.
3. Create it
When validation passes, send the same body to /campaigns:
curl -sS https://growfinch.com/api/v1/campaigns \ -H "Authorization: Bearer $GROWFINCH_API_KEY" \ -H "x-maovora-contract: 1.2.0" \ -H "content-type: application/json" \ --data @campaign.json
A new campaign answers 201 with campaign (its id, status and posts) and replayed: false. A retry with the same idempotency_key answers 200 with the same campaign and replayed: true, so retrying after a timeout is safe.
Upload a video or image
A file reaches Growfinch in three calls: register it, upload the bytes, then confirm. The same file registered twice for the same business is the same record, so a re-run never uploads twice.
- Register the file with its SHA-256 fingerprint and size. The name may use letters, digits, dots, dashes and underscores, and must end in
mp4,mov,png,jpg,jpegorwebp, matching itskind. Files can be up to 512 MB.
FILE=launch.mp4
SHA=$(shasum -a 256 "$FILE" | cut -d' ' -f1)
BYTES=$(wc -c < "$FILE" | tr -d ' ')
curl -sS https://growfinch.com/api/v1/media \
-H "Authorization: Bearer $GROWFINCH_API_KEY" \
-H "content-type: application/json" \
--data "{\"business_id\":\"<business id>\",\"kind\":\"video\",\"filename\":\"$FILE\",\"sha256\":\"$SHA\",\"bytes\":$BYTES}"The answer holds media (its id and status: pending) and upload: a signed url, the method PUT and the headers to send. You can add width, height and duration_ms to the body if you know them.
- Upload the bytes to that address, with the header it gave you. This request goes to storage, not to Growfinch, so it takes no API key.
curl -sS -X PUT "<upload url>" \ -H "content-type: video/mp4" \ --data-binary @"$FILE"
- Confirm it arrived. The file is now
ready.
curl -sS -X POST https://growfinch.com/api/v1/media/<media id>/complete \ -H "Authorization: Bearer $GROWFINCH_API_KEY"
Use the id, the kind and the sha256 in each post’s media. GET /media/<media id> shows a file’s status at any time. If the upload address has run out, register the same file again: you get a new one.
Before each post goes out, Growfinch checks that its file is ready and that the sha256 in the post matches the file registered. A post whose file doesn’t match fails rather than publishing something else.
Follow a campaign, or cancel it
curl -sS https://growfinch.com/api/v1/campaigns/<campaign id> \ -H "Authorization: Bearer $GROWFINCH_API_KEY"
The answer is the campaign with its posts, and events: its history, from creation through approval to each publish. Each post has its status, and once out its permalink and published_at; a failed one has error_code and error_message.
| Campaign status | What it means |
|---|---|
draft | Kept as a draft; nothing is scheduled. |
pending_approval | Waiting on Approvals for a person. |
queued | Waiting in a queue for its slot. |
scheduled | Approved (or allowed by the business’s rule); each post goes out at its time. |
processing | Posts are going out. |
completed, partially_completed | Every post went out, or only some did. |
cancelled | Cancelled or rejected. |
A post is scheduled, publishing, published, failed or cancelled. For what each failure means, see Fix a failed post.
To list a business’s campaigns, use GET /campaigns?business_id=<business id>, with optional status (several, separated by commas, such as scheduled,pending_approval), from, to and limit (50 unless you ask, up to 200).
To cancel, send DELETE with an optional reason:
curl -sS -X DELETE "https://growfinch.com/api/v1/campaigns/<campaign id>?reason=Changed%20plans" \ -H "Authorization: Bearer $GROWFINCH_API_KEY"
Posts still waiting to go out are cancelled; posts already published stay up. If the campaign was waiting for approval, its approval is closed, and if it was in a queue, it leaves the queue.
Add posts to a queue
A queue is a weekly timetable for one business, and each post you add takes the next free slot. Fill a queue of evergreen posts explains how queues behave; these are the calls.
POST /queues
{
"business_id": "<business id>",
"name": "<queue name>",
"slots": [{ "weekday": "tue", "time": "09:00" }, { "weekday": "thu", "time": "09:00" }],
"idempotency_key": "<a new key for this queue>"
}
POST /queues/<queue id>/items
{
"campaign": {
"name": "<post name>",
"idempotency_key": "<a new key for this post>",
"posts": [{ "platform": "instagram", "format": "reel", "caption": "<caption>",
"media": [{ "media_id": "<media id>", "kind": "video", "sha256": "<sha256>" }] }]
}
}Slot times are 24-hour, in the queue’s time zone: the business’s unless you send timezone. A new queue needs approval for each item (mode: approval_required), fills in order (fill: fifo) and posts at most once a day (max_per_day: 1); you can set each of these when you create it. An item’s campaign has no business_id and no scheduled_at: the queue gives both. The answer includes lands_at, the slot it will take if it’s approved now. Instead of a new body you can add an existing draft or pending campaign as { "campaign_id": "<campaign id>" }.
| Call | What it does |
|---|---|
GET /queues?business_id=… | The business’s queues. |
GET /queues/<id> | One queue with its items. |
GET /queues/<id>/plan?days=14 | Which item lands in which slot over the next days (up to 60), the first empty slot and how many days of content remain. |
PATCH /queues/<id> | Change slots, accounts, mode, fill order, recycling, the daily maximum or the expiry; { "paused": true } pauses it. |
PATCH /queues/<id>/items/<item id> | Move an item (position), take it out ("skip": true, which cancels its campaign) or hold it until a time (not_before). |
POST /queues/<id>/shuffle | Shuffle the items not yet scheduled. |
DELETE /queues/<id> | Cancel the queue’s waiting and scheduled items, then remove it. Published posts stay. |
Read results and approvals
curl -sS "https://growfinch.com/api/v1/posts/metrics?business_id=<business id>" \ -H "Authorization: Bearer $GROWFINCH_API_KEY"
The answer covers the last 30 days unless you send from and to (ISO times). For each published post, posts gives its permalink, meta and attribution_key, its latest and previous daily snapshots, the change between them, and every snapshot in the window. totals adds up each platform over the last 7 and 30 days. A snapshot has views, likes and comments, and shares, watch_time_seconds, average_view_seconds and impressions where the platform reports them. A counter the platform doesn’t give is null; Growfinch never estimates one. Read results explains how the numbers are collected.
GET /approvals lists the items waiting for a person in the businesses your key can see. It’s for reading only: a person approves or rejects in the console (Approve posts).
Report outcomes (1.5.0)
A view on a platform is not a result for your business. With the outcomes:write scope, POST /v1/outcomes takes what happened on your side, by your own event id and the utm_content the session arrived with; Growfinch joins each event to the post whose link carried that key within 7 days of publishing, the latest post winning, and keeps the others as candidates. A click is never an outcome.
curl -s -X POST https://growfinch.com/api/v1/outcomes \
-H "Authorization: Bearer $GROWFINCH_API_KEY" -H "Content-Type: application/json" \
-d '{"business_id":"<slug>","events":[{"type":"signup","event_id":"evt_1081","occurred_at":"2026-11-03T14:12:00Z","attribution_key":"2026-11-03-launch-instagram"}]}'- type:
visit·first_play·game_completed·signup·lead·purchase·custom:<snake_case>. event_id is yours and unique per type: the same id again is a duplicate, counted and not written. value is optional (amount_micro,currency); reverses names an event you sent before. - Up to 500 events a call; send hourly batches. Unknown fields and anything shaped like an answer are refused (422). Never send a user id, an email, a session cookie or an analytics key.
GET /v1/outcomes?business_id=<slug>&from=&to=lists what Growfinch holds, with the post each was joined to, the counts by type, and how many could not be joined.
How to set the key on your links and your site is on Label your posts and follow them to results.
Get events with webhooks
Instead of asking again and again, let Growfinch tell you when something happens. You need a key with webhooks:write.
- Register an https address and the events you want (up to 20, or
*for all). Addbusiness_idsto limit it to some of your businesses; without it, it covers every business the key may act on.
curl -sS https://growfinch.com/api/v1/webhooks \
-H "Authorization: Bearer $GROWFINCH_API_KEY" \
-H "content-type: application/json" \
--data '{"url":"https://example.com/hooks/growfinch","events":["post.published","post.failed","connection.expired"]}'- Save the
secretfrom the answer, beside theendpointand itsid. It’s shown this once and never again. Store it as you store the key. - Test it with
POST /webhooks/<id>/ping. Growfinch sends a signedwebhook.pingnow and answers withdeliveredand thestatus_codeyour address returned.
GET /webhooks lists your endpoints (never a secret) and DELETE /webhooks/<id> removes one.
What arrives
Each event is a POST with a JSON body: id, type, created_at, business_id and data. The headers x-maovora-event and x-maovora-delivery name the event type and the delivery.
| Event | When | In data |
|---|---|---|
campaign.approval_required | A campaign or queue item is waiting for a person. | campaign_id, name, posts, source |
campaign.scheduled | A person approved it, or a queue landed it in its slot. | campaign_id, name |
campaign.cancelled | It was cancelled. | campaign_id, cancelled (posts), reason |
post.published | A post went out. | campaign_id, post_id, platform, permalink |
post.failed | A post failed. | campaign_id, post_id, platform, error_code, error_message |
connection.expired | An account needs reconnecting. | connection_id, platform, handle |
exception.created | A failure that needs a person was filed on Approvals. | approval_id, summary, error_code |
queue.item_scheduled | A queue item took its slot. | queue_id, item_id, campaign_id, slot_at |
queue.slot_missed | A slot passed with nothing ready. | queue_id, slot_at, reason |
queue.running_low | Fewer days of content remain than the queue’s threshold. | queue_id, days_of_content, first_empty_at |
webhook.ping | You asked for a test. | endpoint_id |
Check the signature
Every delivery carries x-maovora-signature: t=<unix seconds>,v1=<hex>. The v1 value is an HMAC-SHA256, keyed with your endpoint’s secret, of the timestamp, a dot and the raw body exactly as it arrived. Check it before you trust the body, and refuse a timestamp more than five minutes from now. In Node.js:
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as it arrived, before any JSON parsing.
export function verifyGrowfinch(secret, rawBody, header) {
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec((header ?? "").trim());
if (!m) return false;
if (Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;
const expected = createHmac("sha256", secret).update(m[1] + "." + rawBody).digest();
const given = Buffer.from(m[2], "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}Answer with any 2xx status within 10 seconds. Anything else is tried again after about 1, 5, 30, 120 and 720 minutes. A delivery can arrive more than once, so skip an event whose id you’ve already handled.
What Growfinch refuses
- Unknown fields. Every object in a request is closed: a field Growfinch doesn’t know is refused with
validation_failed, naming it (Unrecognized key). Check the spelling against this page. Your free-form data goes inpayload, and labels for results inmeta. - Fields that could give away an answer. Growfinch never holds an answer, so these field names are refused wherever they appear, at any depth,
payloadandmetaincluded:answer,answers,solution,place,placename,lat,lng,lon,latitude,longitude,coordinatesandcoords. Each part of a name counts, split at underscores, dashes and capitals, soplace_name,userLatandcorrectAnswerare refused too. The error isspoiler_field; remove the field and send again. - A request without its business. Every call that touches a business names it with
business_id. A key can only act on the businesses it was made for. - Approval. No key approves, whatever its scopes. A person approves in the console.
If something goes wrong
Every error has the same shape. details says what to fix, when there’s something to say:
{
"error": {
"code": "validation_failed",
"message": "Validation failed.",
"request_id": "<request id>",
"details": [{ "path": "posts.0", "message": "Unrecognized key: \"hashtags\"" }]
}
}| Code (status) | What it means and what to do |
|---|---|
unauthorized (401) | Send an API key as Authorization: Bearer mvk_… means the header is missing or misspelt. Unknown or revoked API key. means the key is wrong or no longer valid: check the variable holds the whole key, or write to support. |
business_forbidden (403) | This key lacks the … scope.: ask support to add the scope. No access to business …: the key isn’t made for that business; check the slug with GET /businesses. |
business_required (400) | A list call without ?business_id=. |
not_found (404) | The business, campaign, file, queue or endpoint doesn’t exist, or belongs to a business your key can’t see. |
validation_failed (400 or 422) | 400: the body isn’t JSON. 422: a field is missing, too long, the wrong type or unknown; each problem is in details with its path. On create with automatic, the failed checks are listed instead. |
spoiler_field (422) | A field name that could give away an answer (see above). Remove it. |
policy_denied (422) | A rule refused the action; details.rule names it. This client may only create drafts. means your key makes drafts only: send "policy": "draft_only". |
conflict (409) | Queues: the campaign is already in a queue, or its idempotency_key belongs to a campaign outside the queue. Use a new key. |
contract_version (400) | Your x-maovora-contract names a major version Growfinch doesn’t serve; details.supported says which (1.x). |
internal (500) | Something went wrong at Growfinch. Retry with the same idempotency_key; if it repeats, write to support with the request_id. |
For posts that failed at the platform after they were scheduled, see Fix a failed post. Questions this page doesn’t answer: write to support, and we’ll add the answer.