Documentation / Quickstart

VoidGram REST API — Quickstart

Automate VoidGram the same way the app does. Same endpoints, same payloads, same Prisma-backed database — just authenticated with a Bearer key instead of the session cookie the embedded UI uses.

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

  1. Create a key. Open VoidGram → Settings → API KeysNew key. Give it a name you'll recognise later (for example, batch-scheduler or claude-desktop). You'll see the full vg_live_... value exactly once — copy it now.
  2. 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"
  3. 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[].username

    You should see your connected Instagram username(s). Grab the id field for one account — you'll pass it as accountId in 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/publish returns 202 even though the Instagram upload hasn't finished yet. Do not tell the user "it's published" until you've polled /posts/:id and seen status: "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 retryAfter field (seconds) and a Retry-After header — 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 with 401 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 localhost and 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). limit maxes at 100, defaults to 20. hasMore in the response tells you if there's another page.
  • Dates: always ISO 8601. Server parses any valid offset (2026-04-20T14:00:00-07:00 is fine); server responses are always UTC (...Z).
  • IDs: UUIDv4 strings.
  • Uploads: multipart/form-data with a single file field. Max size 100 MB (configurable via MAX_FILE_SIZE_MB). Accepted types: JPEG, PNG, WebP, MP4, QuickTime, MKV, WebM, AVI.
  • Body format: JSON unless you're uploading. Content-Type: application/json on 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/v2 alongside v1.
  • Deprecations are announced in release notes with minimum three months notice before removal, and deprecated endpoints return Deprecation and Sunset headers 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 localhost only. 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/publish or /schedule after 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.