TL;DR
The VoidGram server binds to http://localhost:3001 whenever the desktop
app is running. It exposes a stable, versioned REST surface at /api/v1/*
that mirrors the internal /api/* routes the UI talks to. Authenticate with
Authorization: Bearer vg_live_... — generate keys under
Settings → API Keys in the app.
- Base URL:
http://localhost:3001/api/v1 - Auth:
Authorization: Bearer $VOIDGRAM_API_KEY - OpenAPI spec:
GET /api/v1/openapi.json(consume it from your codegen tool of choice) - MCP server:
@voidgram/mcp-server— native Claude Code / Claude Desktop integration - Interactive docs:
http://localhost:3001/api/docs(Swagger UI, while the app is running)
List connected Instagram accounts as a sanity check:
curl -H "Authorization: Bearer $VOIDGRAM_API_KEY" \
http://localhost:3001/api/v1/auth/me Response:
{
"user": { "id": "a1b2c3d4-..." },
"accounts": [
{
"id": "5e6f7a8b-...",
"igUserId": "17841400000000000",
"username": "your_account",
"accountType": "BUSINESS",
"loginMethod": "instagram",
"tokenExpiresAt": "2026-06-12T10:00:00.000Z"
}
]
} Getting started
- Create a key. Open VoidGram → Settings → API Keys →
New key. Give it a name you'll recognise later (for example,
batch-schedulerorclaude-desktop). You'll see the fullvg_live_...value exactly once — copy it now. - Store it. Export it as an environment variable so it doesn't end up
in shell history or source control:
export VOIDGRAM_API_KEY="vg_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - Verify it works. The app must be open — if you hit a connection
refused, launch VoidGram first.
curl -s -H "Authorization: Bearer $VOIDGRAM_API_KEY" \ http://localhost:3001/api/v1/auth/me | jq .accounts[].usernameYou should see your connected Instagram username(s). Grab the
idfield for one account — you'll pass it asaccountIdin every publishing call below.
LLM tip: the server only runs while the desktop app is open. If the user has quit VoidGram, every call fails with
ECONNREFUSED. Detect that and tell the user to launch the app rather than retrying with backoff.
Common workflows
1. Publish a single image post
Three steps: upload media, create a draft (optional but recommended), publish.
# 1. Upload
MEDIA_ID=$(curl -s -X POST \
-H "Authorization: Bearer $VOIDGRAM_API_KEY" \
-F "file=@./sunset.jpg" \
http://localhost:3001/api/v1/media/upload | jq -r .id)
# 2. Publish immediately
curl -s -X POST \
-H "Authorization: Bearer $VOIDGRAM_API_KEY" \
-H "Content-Type: application/json" \
-d "{
"accountId": "$ACCOUNT_ID",
"mediaFileId": "$MEDIA_ID",
"mediaType": "IMAGE",
"caption": "Sunset over Seattle #pnw"
}" \
http://localhost:3001/api/v1/posts/publish POST /posts/publish returns 202 with
{ postId, status: "QUEUED" } — Instagram's container model is async.
Poll GET /posts/:id until status === "PUBLISHED" (see
workflow 4) to get the igPermalink.
LLM tip:
/posts/publishreturns 202 even though the Instagram upload hasn't finished yet. Do not tell the user "it's published" until you've polled/posts/:idand seenstatus: "PUBLISHED". A 202 only means "VoidGram accepted the job".
2. Schedule a post
Same upload step, then POST /schedule instead of
/posts/publish. scheduledFor is ISO 8601 — the server accepts
any valid offset and stores UTC.
// TypeScript (fetch)
const res = await fetch("http://localhost:3001/api/v1/schedule", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.VOIDGRAM_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
accountId,
mediaFileId,
mediaType: "IMAGE",
caption: "Scheduled for tomorrow morning",
scheduledFor: "2026-04-20T14:00:00.000Z",
timezone: "America/Los_Angeles",
}),
});
const job = await res.json();
// { postId, jobId, cloudflareJobId?, scheduledFor, isCloudflare } isCloudflare: true means the job lives in the user's Cloudflare Worker and
will fire even if the desktop app is closed. false means the SQLite job
queue — the app must be running at scheduledFor for the post to go out.
3. Bulk schedule 20 posts
Instagram caps publishing at 25 posts per 24-hour rolling window per account (some accounts get 50). Spread your batch out and fan out across multiple accounts if you have them.
# Python
import os, time, requests
from datetime import datetime, timedelta, timezone
API = "http://localhost:3001/api/v1"
HDR = {"Authorization": f"Bearer {os.environ['VOIDGRAM_API_KEY']}"}
account_ids = [a["id"] for a in requests.get(f"{API}/auth/me", headers=HDR).json()["accounts"]]
media_paths = [f"./queue/{i}.jpg" for i in range(20)]
captions = [f"Batch post #{i}" for i in range(20)]
start = datetime.now(timezone.utc) + timedelta(minutes=10)
STAGGER = timedelta(minutes=20) # 3 posts/hour per account is comfortable
for i, path in enumerate(media_paths):
with open(path, "rb") as f:
media = requests.post(f"{API}/media/upload", headers=HDR, files={"file": f}).json()
account = account_ids[i % len(account_ids)] # round-robin
when = start + STAGGER * (i // len(account_ids))
requests.post(f"{API}/schedule", headers={**HDR, "Content-Type": "application/json"}, json={
"accountId": account,
"mediaFileId": media["id"],
"mediaType": "IMAGE",
"caption": captions[i],
"scheduledFor": when.isoformat(),
"timezone": "UTC",
}).raise_for_status()
time.sleep(0.2) # stay well under 100 req/min LLM tip: do not retry 429 responses with exponential backoff. The response carries a
retryAfterfield (seconds) and aRetry-Afterheader — sleep for exactly that duration, then try once more. Retrying sooner wastes the user's rate budget.
4. Poll post status
GET /posts/:id returns the current status and (once published) the
igPermalink. Poll every 3-5 seconds up to about 3 minutes for videos, less
for images.
for i in $(seq 1 30); do
STATUS=$(curl -s -H "Authorization: Bearer $VOIDGRAM_API_KEY" \
http://localhost:3001/api/v1/posts/$POST_ID | jq -r .status)
echo "attempt $i: $STATUS"
[ "$STATUS" = "PUBLISHED" ] || [ "$STATUS" = "FAILED" ] && break
sleep 3
done
Status values: DRAFT | SCHEDULED | QUEUED | PROCESSING | PUBLISHING | PUBLISHED
| FAILED. On FAILED, inspect errorCode and
errorMessage on the post record.
Alternative for fire-and-forget flows: GET /posts/recent-completions?since=<ISO>
returns posts that entered PUBLISHED or FAILED after the
given timestamp. Good for "tell me what finished in the last minute" loops.
5. Listen for repost events (polling)
There are no webhooks in v3.3.0. Poll GET /story-repost/history?accountId=...
and diff by id.
let seen = new Set<string>();
setInterval(async () => {
const res = await fetch(
`http://localhost:3001/api/v1/story-repost/history?accountId=${accountId}`,
{ headers: { Authorization: `Bearer ${process.env.VOIDGRAM_API_KEY}` } }
);
const { reposts } = await res.json();
for (const r of reposts) {
if (!seen.has(r.id) && r.status === "PUBLISHED") {
console.log("new repost", r.id, r.originalUsername);
seen.add(r.id);
}
}
}, 60_000); // once a minute is plenty LLM tip: don't poll faster than 60 seconds for background feeds — you'll burn rate budget without gaining anything (the detector cron runs on a similar cadence). For interactive publishing status, 3-5 seconds is appropriate; for history/repost feeds, 60+ seconds.
Using from Claude Code / Desktop
VoidGram ships an MCP server that wraps the REST API as typed tools:
claude mcp add voidgram --scope user -- npx -y @voidgram/mcp-server Configure environment variables in your MCP config:
{
"mcpServers": {
"voidgram": {
"command": "npx",
"args": ["-y", "@voidgram/mcp-server"],
"env": {
"VOIDGRAM_API_KEY": "vg_live_...",
"VOIDGRAM_BASE_URL": "http://localhost:3001/api/v1"
}
}
}
} Example prompts that map to single tool calls:
- "Schedule these 20 images from ~/Downloads/queue one every 20 minutes starting tomorrow 9am PST."
- "List all posts that failed in the last 7 days and show me the error codes."
- "How many story reposts did @your_account publish this week?"
See the MCP server docs for the full tool catalog.
Using from ChatGPT / function calling
Point your tool at the OpenAPI spec at GET /api/v1/openapi.json. Any
generator that consumes OpenAPI 3.1 will produce valid function definitions —
openapi-to-function-calls
or
swagger-typescript-api
both work out of the box.
Fetch the spec once, store it in your repo, regenerate when VoidGram ships new fields —
the /api/v1 surface is stable, so you'll only need to regenerate for
additions.
Auth and key management
- Header format:
Authorization: Bearer vg_live_<43-char-token>. Anything else is rejected with401 AUTH_INVALID_KEY. - Create / revoke: only from the app's Settings UI — API keys
authenticated via Bearer cannot create or revoke other keys
(
403 COOKIE_AUTH_REQUIRED). This is deliberate; a compromised key cannot mint backups for itself. - Rotation: create a new key, update every consumer, then revoke the
old one. Revocation is immediate — the next request with the old key returns
401 AUTH_INVALID_KEY. There is no undo. - Storage: VoidGram stores only the SHA-256 hash of your key plus the
first 12 characters (
vg_live_xxxx) for lookup. If you lose the key, you cannot recover it — create a new one and revoke the lost one. - Scope: same-machine only. The server binds to
localhostand does not accept connections from the LAN or public internet in v3.3.0.
Errors and rate limits
Every error response uses the same shape:
{
"error": "Human-readable message",
"code": "MACHINE_READABLE_CODE",
"status": 404,
"details": { "...": "only present in dev mode" }
} For the full error catalog grouped by category (authentication, validation, media pipeline, Private API, Cloudflare, rate limiting), see the Errors reference.
Rate limits
Per API key: 100 requests per minute and 1000 requests per
hour (the stricter bucket wins). Override via RATE_LIMIT_RPM /
RATE_LIMIT_RPH env vars if you need to.
Every response carries RateLimit-Limit,
RateLimit-Remaining, and RateLimit-Reset headers
(IETF draft-7).
On 429 the response body includes retryAfter in seconds and the same value
as a Retry-After header.
Session-cookie requests (the embedded UI) are not rate-limited — the limiter only applies to Bearer-authenticated traffic.
Pagination and conventions
- Paging: endpoints that return lists accept
?limit=N&page=N(for/posts,/schedule) or?limit=N&offset=N(for/media).limitmaxes at 100, defaults to 20.hasMorein the response tells you if there's another page. - Dates: always ISO 8601. Server parses any valid offset
(
2026-04-20T14:00:00-07:00is fine); server responses are always UTC (...Z). - IDs: UUIDv4 strings.
- Uploads:
multipart/form-datawith a singlefilefield. Max size 100 MB (configurable viaMAX_FILE_SIZE_MB). Accepted types: JPEG, PNG, WebP, MP4, QuickTime, MKV, WebM, AVI. - Body format: JSON unless you're uploading.
Content-Type: application/jsonon writes.
Stability guarantees
/api/v1/*is frozen. No breaking changes to existing endpoints inside the v3.x line.- New fields on responses are additive. New endpoints may appear. Your parser should ignore unknown fields.
- Breaking changes require a new major version and will be mounted at
/api/v2alongside v1. - Deprecations are announced in release notes with minimum three months notice before removal, and deprecated endpoints return
DeprecationandSunsetheaders during the window. /api/*(without/v1) is the internal contract used by the embedded UI and will move with the UI. Do not build integrations against/api/*— use/api/v1/*.
What's NOT in v3.3.0
Honest list of missing capabilities so you don't waste time looking for them:
- No LAN or public access. The server binds to
localhostonly. If you need a remote agent to trigger posts, run it on the same machine or add a reverse proxy yourself. Remote access is planned for a later release. - No webhooks. All event-driven patterns are polling today. Use
/posts/recent-completions?since=...and/story-repost/history?accountId=...with a diff-by-id loop. - No per-key scopes. Every key is full-access. Treat each key like a root credential and rotate aggressively. Scoped keys are on the roadmap.
- No built-in idempotency keys. If you retry
/posts/publishor/scheduleafter a network blip, you'll get two posts. Track your own request IDs and deduplicate before calling. - No official SDK. TypeScript and Python SDKs are planned for v3.4. Until then, the OpenAPI spec + your codegen tool of choice is the recommended path.
- No multi-user isolation. VoidGram is a single-user desktop app — every key shares the same user account. Don't hand a key to a collaborator expecting separation.
- Facebook cross-post not supported in Cloud-scheduled posts. Local-scheduled posts cross-post fine; Cloud-scheduled ones ignore
crossPostToFacebook: true.
Further reading
- Reference — every endpoint, every schema, every field.
- Errors — all error codes with recovery hints.
- MCP server — Claude Code / Claude Desktop integration.
- REMvisual/VoidGram — source and issues on GitHub.