Documentation / Reference

VoidGram REST API v1 — Complete Reference

Companion to the Quickstart. The quickstart is a walkthrough; this document is the exhaustive contract. Every endpoint, every schema, every error code in v3.3.0 is listed here, pinned to the OpenAPI spec at GET /api/v1/openapi.json.

1. Overview

This reference documents every endpoint in the frozen /api/v1 contract of VoidGram v3.3.0. The Quickstart covers how to authenticate and walks through the five most common workflows (publish, schedule, bulk schedule, poll status, poll reposts). This document is the per-endpoint catalogue you reach for when building an integration, a code generator, or a tool definition for an LLM agent.

How to read this: each endpoint entry is self-contained and lists (a) the HTTP method and path, (b) the auth modes accepted, (c) the full request shape, (d) the success shape with status code, (e) the error codes specific to that endpoint, and (f) gotchas you won't see from the schema alone. Schemas are cross-referenced — if a response returns Post, see Schemas → Post.

Authoritative sources:

  • Machine-readable spec: GET http://localhost:3001/api/v1/openapi.json (OpenAPI 3.1).
  • Interactive docs: http://localhost:3001/api/docs (Swagger UI, while the app is running).
  • MCP server: MCP docs.
  • Quickstart: Quickstart.

v3.3.0 known limitations:

  • Localhost only. Server binds to 127.0.0.1:3001. No LAN, no public exposure.
  • No webhooks. All event-driven patterns are polling — see section 8.
  • No per-key scopes. Every key is full-access. The scopes field on ApiKey exists but is always empty.
  • No official SDK. Use the OpenAPI spec with your codegen tool (both TS and Python SDKs planned for v3.4).
  • No built-in idempotency. Retrying POST /posts/publish or POST /schedule after a network hiccup creates a duplicate. Track your own request IDs.
  • Single-user. Every API key authorises as the same desktop user.

2. Conventions

Base URL and versioning

  • Base URL: http://localhost:3001/api/v1
  • /api/v1/* is the frozen public contract. Additive-only inside v3.x.
  • /api/* (no /v1) is the internal UI contract and will move without notice. Do not integrate against it.
  • Breaking changes ship at /api/v2 alongside v1 — both served from the same process during the deprecation window.

Request format

  • JSON body on all writes unless uploading: Content-Type: application/json.
  • Multipart on POST /media/upload only: Content-Type: multipart/form-data, single field file.
  • Unknown properties in request bodies are silently ignored — forwards-compatible for clients.

Response envelope

There isn't one. Resources are returned at the top level, directly:

GET /api/v1/posts/abc...
200 OK
{ "id": "abc...", "mediaType": "IMAGE", "status": "PUBLISHED", ... }

Only errors are wrapped, always in the ErrorResponse shape:

{
  "error": "Scheduled time must be in the future",
  "code": "INVALID_SCHEDULE",
  "status": 400,
  "details": { "_errors": [], "scheduledFor": { "_errors": ["in the past"] } },
  "retryAfter": 7
}
  • error — human-readable, for logs and UI. Do not pattern-match on this.
  • code — stable machine enum. Match on this. See Errors reference.
  • status — mirrors the HTTP status code for convenience.
  • details — present in dev mode for VALIDATION_ERROR; the raw Zod error tree. Absent in production builds.
  • retryAfter — only present on RATE_LIMITED; seconds until the window resets.

Dates

  • Wire format: ISO 8601.
  • Responses: always UTC with the Z suffix, e.g. "2026-04-20T14:00:00.000Z".
  • Requests: any valid offset accepted. "2026-04-20T07:00:00-07:00" and "2026-04-20T14:00:00Z" mean the same moment and are both stored as UTC internally.

IDs

All resource IDs are UUID v4 strings. Instagram's own numeric IDs (igUserId, igMediaId, facebookPostId) are passed through as strings to avoid JS number-precision issues.

Pagination

Two patterns in the wild — the inconsistency is historical and will be tidied in v2.

EndpointUsesDefaultsMax
GET /posts?page + ?limitpage 1, limit 20limit 100
GET /schedule?page + ?limitpage 1, limit 20limit 100
GET /media?offset + ?limitoffset 0, limit 20limit 100

All list responses include hasMore: boolean — use that rather than computing total > offset + limit yourself (counts can shift under concurrent writes).

Note: /media is the one endpoint that takes offset instead of page. Every other paginator uses page. This is a known inconsistency.

Auth header format

Authorization: Bearer vg_live_<43-char-token>

The prefix vg_live_ is always present. Anything else is rejected with 401 AUTH_INVALID_KEY.

Rate-limit headers

Every response on a Bearer-authenticated request includes the IETF draft-7 headers:

RateLimit-Limit: 100
RateLimit-Remaining: 87
RateLimit-Reset: 42
RateLimit-Policy: 100;w=60, 1000;w=3600

RateLimit-Reset is seconds until the current window rolls over. On 429 responses a standard Retry-After: <seconds> header accompanies the body retryAfter field.

3. Authentication

Two auth modes are accepted on every /api/v1 route unless stated otherwise:

ModeHeader / transportUsed byRate-limited?
BearerAuthorization: Bearer vg_live_...External integrations, CLI, MCP, agentsYes
Session cookieCookie: session=<JWT>The embedded Electron UINo

Key lifecycle

  1. Createcookie-only. POST /settings/api-keys. The full vg_live_... value is returned exactly once in fullKey. Store it immediately.
  2. Use — pass Authorization: Bearer vg_live_... on every request.
  3. Revokecookie-only. DELETE /settings/api-keys/{id}. Revocation is immediate: the next request with the old key returns 401 AUTH_INVALID_KEY.

The cookie-only restriction on create/revoke is a privilege-escalation guard: a compromised API key cannot mint backups for itself or cover its tracks by revoking the others. Attempting either with a Bearer key returns 403 COOKIE_AUTH_REQUIRED.

4. Endpoints

Every entry below may additionally return the generic error codes AUTH_REQUIRED (401), AUTH_INVALID_KEY (401), RATE_LIMITED (429), VALIDATION_ERROR (400), and INTERNAL_ERROR (500). Only endpoint-specific codes are called out per-entry.

4.1 Health

GET /api/v1/health

  • Description: Liveness + database probe.
  • Auth: None. This endpoint is intentionally open — it's the probe external tooling uses to decide whether the desktop app is running.
  • Request: none.

200 Response:

{
  "status": "healthy",
  "timestamp": "2026-04-19T10:00:00.000Z",
  "services": { "database": "connected" },
  "uptime": 12345.6
}
  • status enum: healthy | degraded | unhealthy.
  • uptime — seconds since process start.
  • 503: database unreachable; returns ErrorResponse.
  • Gotcha: because this is the one unauthenticated endpoint, it's also the endpoint a caller uses to distinguish "VoidGram is closed" (ECONNREFUSED) from "VoidGram is open but the DB is wedged" (503). Tell the user to relaunch the app in the first case, and to file a bug in the second.

GET /api/v1/health/ig-version

  • Description: Returns the Private API app version VoidGram is using and whether it's stale.
  • Auth: Bearer or cookie.

200 Response:

{
  "currentVersion": "389.2.0.33.85",
  "latestVersion": "391.0.0.46.90",
  "isOutdated": true,
  "lastCheckedAt": "2026-04-18T22:00:00.000Z"
}

Edge cases: latestVersion is null if the remote check has never succeeded. isOutdated is false when latestVersion is null (can't compare).

4.2 Auth

GET /api/v1/auth/check

  • Description: Lightweight "is anyone logged in?" probe used by the setup wizard.
  • Auth: None.
  • 200 Response: { "authenticated": boolean, "username"?: string }username is only included when authenticated: true.

GET /api/v1/auth/me

  • Description: Current user plus the array of connected Instagram accounts.
  • Auth: Bearer or cookie.

200 Response:

{
  "user": { "id": "a1b2c3d4-..." },
  "accounts": [ /* InstagramAccount[] */ ]
}

Gotcha: accounts[].accessToken is never returned — tokens stay server-side. Use accounts[].id as the accountId input on publishing calls.

POST /api/v1/auth/logout

  • Description: Clears the session cookie. No-op for Bearer-authed callers.
  • 200 Response: { "success": true }

DELETE /api/v1/auth/accounts/{id}

  • Description: Disconnect an Instagram account. Deletes the account record, its encrypted tokens, and any story-repost config linked to it.
  • Path: id — UUID of the InstagramAccount.
  • 200 Response: { "success": true }
  • Errors: NOT_FOUND.
  • Gotcha: irreversible. Published posts linked to the account stay in history but cannot be retried (the token is gone).

4.3 Media

POST /api/v1/media/upload

  • Description: Upload one media file. Images are normalised to JPEG for Instagram compatibility; videos are stored as-is.
  • Auth: Bearer or cookie.
  • Content-Type: multipart/form-data; single field file.
  • Limits: default 100 MB (MAX_FILE_SIZE_MB env var). Accepted MIME types: image/jpeg, image/png, image/webp, video/mp4, video/quicktime, video/x-matroska, video/webm, video/x-msvideo.
  • 201 Response: MediaFile.
  • Errors: UPLOAD_ERROR, MEDIA_INVALID, PROCESSING_FAILED, VIDEO_PROCESSING_FAILED.
curl -X POST \
  -H "Authorization: Bearer $VOIDGRAM_API_KEY" \
  -F "file=@./sunset.jpg" \
  http://localhost:3001/api/v1/media/upload

GET /api/v1/media

  • Description: List uploaded media for the authenticated user, newest first.
  • Query:
    • limit — integer 1..100, default 20.
    • offset — integer >=0, default 0. Note: this is the one list endpoint that uses offset instead of page.
    • type — enum image | video; optional filter.
  • 200 Response: { "files": MediaFile[], "total": number, "hasMore": boolean }

GET /api/v1/media/{id}

  • 200 Response: MediaFile.
  • Errors: NOT_FOUND.

DELETE /api/v1/media/{id}

  • Description: Delete a media file from disk and DB. If the file is referenced by a non-draft post, the post's link is set to null but the post record is kept.
  • 200 Response: { "success": true }
  • Errors: NOT_FOUND.

4.4 Posts

All publishing endpoints accept the same request shape as drafts plus publishing-specific fields. The validation rules below apply to every endpoint that accepts mediaType.

Common request body (PublishablePost):

FieldTypeRequiredNotes
accountIdUUIDyesThe InstagramAccount.id to post from.
mediaTypeenumyesIMAGE | REELS | STORIES | CAROUSEL. UI labels CAROUSEL as "Multi-Image".
mediaFileIdUUIDsingle-mediaRequired for IMAGE, REELS, STORIES.
mediaFileIdsUUID[]CAROUSEL2–10 items, CAROUSEL only.
captionstringnoMax 2200 chars.
userTagsUserTag[]noMax 20. Only allowed on IMAGE and CAROUSEL.
collaboratorsstring[]noMax 3 usernames. Not allowed on STORIES.
locationIdstringnoFacebook Place ID from Instagram's location search.
locationNamestringnoDisplay name to pair with locationId.
crossPostToFacebookbooleannoRequires loginMethod: "facebook". Not allowed on CAROUSEL.
storyMentionsStoryMention[]noMax 5. Only on STORIES. Requires Private API credentials.

POST /api/v1/posts/draft

  • Description: Create a draft post. Doesn't touch Instagram; just persists the editor state.
  • Request: PublishablePost (only accountId + mediaType strictly required).
  • 201 Response: Post with status: "DRAFT".
  • Errors: NOT_FOUND, INVALID_CAROUSEL, INVALID_TAGS, INVALID_MENTIONS, INVALID_COLLABORATORS, INVALID_CROSS_POST, MEDIA_INVALID.

PUT /api/v1/posts/draft/{id}

  • Description: Update an existing draft. All fields optional — only provided keys are patched.
  • Errors: NOT_FOUND, INVALID_STATUS, plus the full list above.
  • Gotcha: editing a post that has moved out of DRAFT returns INVALID_STATUS. For scheduled posts, use PUT /schedule/{id} instead.

POST /api/v1/posts/publish

  • Description: Publish to Instagram immediately. Async — returns 202 to signal the job is queued, not finished.
  • Request: PublishablePost.

202 Response:

{ "postId": "uuid", "status": "QUEUED" }
  • Errors: TOKEN_EXPIRED, PRIVATE_API_REQUIRED, plus the full validation list.
  • Gotcha: 202 means "VoidGram accepted the job" — not "it's on Instagram". Poll GET /posts/{id} until status is PUBLISHED or FAILED.
  • Rate limit: Instagram caps publishing at 25 posts / 24h per account (50 for some accounts). VoidGram does not enforce this — you'll see FAILED with an Instagram error once you hit it.

GET /api/v1/posts

  • Query: status, page (>=1), limit (1–100).
  • 200 Response: { "posts": Post[], "total", "page", "limit", "hasMore" }
  • Gotcha: the list returns posts for all accounts the user owns. Filter client-side by instagramAccountId if needed.

GET /api/v1/posts/{id}

  • 200 Response: Post with mediaFiles array populated.
  • Errors: NOT_FOUND.

POST /api/v1/posts/{id}/retry

  • Description: Requeue a FAILED post. Status moves back to QUEUED, retryCount is unchanged, errorCode / errorMessage cleared.
  • 202 Response: { "postId": "uuid", "status": "QUEUED" }
  • Errors: NOT_FOUND, MEDIA_INVALID.
  • Gotcha: only FAILED posts are retryable. Do not "retry" by calling POST /posts/publish again — that creates a duplicate with a new container.

DELETE /api/v1/posts/{id}

  • Description: Clear a post from local history. Does not touch Instagram. Soft-deleted into the DeletedPost archive table.
  • 200 Response: { "success": true, "clearedPostId": "uuid" }

DELETE /api/v1/posts/{id}/instagram

  • Description: Delete a published post from Instagram (and locally). Only works on PUBLISHED posts with an igMediaId.

200 Response:

{
  "success": true,
  "wasDeletedFromInstagram": true,
  "clearedPostId": "uuid",
  "deleteError": null,
  "permalink": "https://instagram.com/p/..."
}
  • Errors: NOT_FOUND, INVALID_STATUS, NO_MEDIA_ID, STORIES_CANNOT_DELETE.
  • Gotcha: if the Graph API call fails but the local record is cleared, wasDeletedFromInstagram is false and deleteError carries the reason.

4.5 Schedule

All schedule endpoints accept the PublishablePost body from Posts plus two extra fields:

FieldTypeRequiredNotes
scheduledForISO 8601yesMust be strictly in the future.
timezonestringnoIANA tz name, default "UTC". Stored alongside the UTC timestamp for display.

Whether a scheduled post runs locally (SQLite queue, requires desktop running) or in the cloud (Cloudflare Worker, fires even if desktop is closed) depends on whether the user has configured Cloudflare. The isCloudflare boolean in the response tells you which path was chosen.

POST /api/v1/schedule

201 Response:

{
  "postId": "uuid",
  "jobId": "string",
  "cloudflareJobId": "string (optional)",
  "status": "SCHEDULED",
  "scheduledFor": "2026-04-20T14:00:00.000Z",
  "timezone": "America/Los_Angeles",
  "isCloudflare": true
}
  • Errors: INVALID_SCHEDULE, PRIVATE_API_REQUIRED, INVALID_CAROUSEL, INVALID_TAGS, INVALID_MENTIONS, INVALID_COLLABORATORS, INVALID_CROSS_POST, NOT_FOUND, MEDIA_INVALID.
  • Gotchas:
    • The same 25/24h Instagram cap applies at fire time. Stagger by ≥20 min.
    • Cloud-scheduled posts ignore crossPostToFacebook. Local-scheduled posts honor it.

GET /api/v1/schedule

  • Query: status (SCHEDULED | QUEUED | PROCESSING | PUBLISHING), page, limit.
  • 200 Response: { "posts": Post[], "total", "page", "limit", "hasMore" }

GET /api/v1/schedule/{id}

PUT /api/v1/schedule/{id}

  • Description: Edit a scheduled post. Both content and scheduledFor can change.
  • Request body fields: caption, scheduledFor, timezone, userTags, locationId, locationName, collaborators, crossPostToFacebook, storyMentions. All optional.
  • 200 Response: updated Post.
  • Errors: NOT_FOUND, INVALID_SCHEDULE.
  • Gotcha: for cloud-scheduled posts, editing fires an upsert into the Worker's KV.

DELETE /api/v1/schedule/{id}

  • Description: Cancel a scheduled post. Works against both queues.
  • 200 Response: { "success": true }
  • Errors: NOT_FOUND.

GET /api/v1/schedule/queue/status

  • Description: Local SQLite queue counters. Does not reflect cloud-scheduled jobs.

200 Response:

{
  "queue": "local-sqlite",
  "counts": { "waiting": 3, "active": 0, "delayed": 12, "total": 15 }
}
  • waiting — PENDING jobs with runAt <= now.
  • active — PROCESSING jobs.
  • delayed — PENDING jobs with runAt > now.

4.6 Cloudflare

All Cloudflare endpoints in v1 are read-only. Deploy, validate, redeploy, and KV-mutation endpoints exist on the internal /api/* surface only.

GET /api/v1/cloudflare/status

200 Response:

{
  "isConfigured": true,
  "isVerified": true,
  "lastVerifiedAt": "2026-04-18T10:00:00.000Z",
  "r2Configured": true,
  "workerConfigured": true,
  "r2BucketName": "voidgram-media",
  "r2PublicUrl": "https://media.example.com",
  "workerUrl": "https://voidgram-worker.example.workers.dev",
  "hasStoredApiToken": true
}

GET /api/v1/cloudflare/diagnostics

200 Response:

{
  "version": "3.3.0",
  "hasEncryptionKey": true,
  "lastCronRun": "2026-04-19T09:59:00.000Z",
  "scheduledCount": 12,
  "repostConfigCount": 1,
  "pendingRepostCount": 0,
  "completedCount": 47,
  "failedCount": 1,
  "recentJobs": [ /* opaque job objects */ ],
  "timestamp": "2026-04-19T10:00:00.000Z",
  "offline": false,
  "offlineReason": ""
}
  • Errors: CLOUDFLARE_NOT_CONFIGURED, WORKER_UNREACHABLE.
  • Gotcha: lastCronRun older than ~90s means the cron isn't firing — common for the first 5–15 min after a fresh deploy while CF propagates the schedule.

GET /api/v1/cloudflare/cron-status

200 Response:

{
  "configured": true,
  "crons": [ { "cron": "*/1 * * * *" } ],
  "hasCron": true,
  "lastCronRun": "2026-04-19T09:59:00.000Z",
  "modified_on": "2026-04-15T08:32:00.000Z"
}

GET /api/v1/cloudflare/kv

  • Description: List KV keys. Diagnostic; not for consumption in business logic.
  • Query: prefix (optional) — restrict listing.

200 Response:

{
  "configured": true,
  "keys": [{ "name": "scheduled:uuid-..." }, ...],
  "keyCount": 42,
  "metaValues": { "meta:last-cron-run": "2026-04-19T09:59:00.000Z" }
}

4.7 Story Repost

Only the seven primary endpoints are in v1. The other ~20 routes on the internal /api/story-repost/* surface are excluded from the frozen contract for now.

GET /api/v1/story-repost/status

  • Query: accountId (UUID, optional).

200 Response:

{
  "configured": true,
  "isEnabled": true,
  "config": { /* StoryRepostConfig */ }
}

POST /api/v1/story-repost/config

Request:

{
  "accountId": "uuid",
  "pollIntervalHours": 12,
  "autoTagOriginalPoster": true
}
  • pollIntervalHours — 6 to 48 hours.
  • 200 Response: StoryRepostConfig.
  • Errors: NOT_FOUND, CREDENTIALS_NOT_CONFIGURED.

DELETE /api/v1/story-repost/config

  • Query: accountId (UUID, required).
  • 200 Response: { "success": true }

POST /api/v1/story-repost/enable

  • Request: { "accountId": "uuid" }
  • 200 Response: { "success": true }
  • Errors: CREDENTIALS_NOT_CONFIGURED, NOT_FOUND.

POST /api/v1/story-repost/disable

  • Request: { "accountId": "uuid" }
  • 200 Response: { "success": true }

POST /api/v1/story-repost/scan

  • Description: Manually scan the DM inbox for new xma_reel_mention items right now. Ignores the poll interval.

200 Response:

{
  "detected": 3,
  "pending": 1,
  "items": [ /* opaque per-mention objects */ ]
}
  • Errors: CREDENTIALS_NOT_CONFIGURED, LOGIN_RATE_LIMITED, LOGIN_FAILED, TWO_FACTOR_REQUIRED.

GET /api/v1/story-repost/history

  • Query: accountId (UUID, optional).
  • 200 Response: { "reposts": [...] } — each item has at least id, status, originalUsername, createdAt, and (when published) a permalink.

4.8 Settings — Private API

Private API credentials are required for (a) publishing stories with @mention stickers and (b) the story-repost detector. They are stored encrypted and never returned.

GET /api/v1/settings/private-api-credentials/{accountId}

  • Description: Probe whether credentials are configured. Does not return secrets.

200 Response:

{
  "configured": true,
  "igUsername": "your_account",
  "lastLoginAt": "2026-04-18T12:00:00.000Z",
  "challengeState": null,
  "hasSession": true
}

DELETE /api/v1/settings/private-api-credentials/{accountId}

  • Description: Forget credentials + session for an account. Also disables story repost if enabled.
  • 200 Response: { "success": true }

POST /api/v1/settings/private-api-credentials

  • Description: Save IG credentials and perform a test login.
  • Request: { "accountId": "uuid", "igUsername": "string", "igPassword": "string" }

200 Response:

{
  "success": true,
  "message": "Login succeeded",
  "challengeRequired": false,
  "contactPoint": "t***@example.com",
  "challengeType": "email",
  "versionWarning": "Instagram app version is 12 days stale"
}
  • Errors: LOGIN_FAILED, LOGIN_RATE_LIMITED, TWO_FACTOR_REQUIRED.
  • Gotcha: on success with challengeRequired: true, the credentials are NOT yet saved — complete the challenge flow to finalise them.

POST /api/v1/settings/private-api-credentials/challenge

  • Request: { "accountId": "uuid", "code": "123456" }
  • 200 Response: { "success": true, "resolved": true, "message": "Challenge cleared" }
  • Errors: INVALID_CODE, NO_ACTIVE_SESSION, MISSING_ACCOUNT_ID.

GET /api/v1/settings/session-phase/{accountId}

  • Description: Read the session-warming phase for an account.

200 Response:

{
  "hasCredentials": true,
  "hasSession": true,
  "cloudConfigured": true,
  "phase": 3,
  "desktopOnline": true,
  "lastWarmupAt": "2026-04-19T09:45:00.000Z",
  "lastWarmupError": null,
  "exists": true
}
  • phase 1 / 2 / 3 — GET-only / +DM / full. Driven by the 48h phased warming cron in the Worker.

4.9 Settings — API Keys

All three endpoints are cookie-only. A Bearer-authenticated request gets 403 COOKIE_AUTH_REQUIRED. This is the privilege-escalation guard from section 3.

POST /api/v1/settings/api-keys

  • Auth: Cookie only.
  • Request: { "name": "batch-scheduler" } — 1–64 chars.

201 Response:

{
  "key": { /* ApiKey — safe metadata */ },
  "fullKey": "vg_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
  • fullKey is the only time the raw value is returned.

GET /api/v1/settings/api-keys

  • Auth: Bearer or cookie. (Reading the list is safe.)
  • 200 Response: { "keys": ApiKey[] }

DELETE /api/v1/settings/api-keys/{id}

  • Auth: Cookie only.
  • 200 Response: { "success": true, "alreadyRevoked": false }

5. Schemas

Every component schema in the OpenAPI spec, carried forward with its .describe() annotations. Types match the OpenAPI nullable convention (type: [T, "null"]).

InstagramAccount

A single Instagram Business / Creator account connected to VoidGram. accessToken is never returned — it stays server-side.

FieldTypeNullNotes
iduuidnoPrimary key.
igUserIdstringnoNumeric Instagram user id.
usernamestringno
namestringyesDisplay name.
profilePictureUrlstringyesRelative /uploads/avatars/....
accountTypestringyes"BUSINESS" or "CREATOR".
facebookPageIdstringyesOnly for loginMethod: "facebook".
facebookPageNamestringyes
tokenExpiresAtdate-timeyesWhen to reconnect.
loginMethodenumno"instagram" | "facebook".
followersCountnumberyes
mediaCountnumberyes
lastSyncedAtdate-timeyes
createdAtdate-timeno

MediaFile

Uploaded image or video. Publishing always references processedPath when set, else storagePath.

FieldTypeNullNotes
iduuidno
originalFilenamestringno
storagePathstringnoRelative under uploads/ — not a URL.
mimeTypestringno
fileSizeintegernoBytes.
width / heightintegeryesPixels.
durationSecondsnumberyesVideos only.
aspectRatiostringyese.g. "9:16".
isValidbooleannofalse if validation failed.
validationErrorsstringyesJSON-encoded array of messages, or null.
thumbnailPathstringyes
processedPathstringyesSet once editor transforms are rendered.
createdAtdate-timeno
previewUrlstringyesRelative URL served by local Express.
publicUrlstringyesFull ngrok/public URL for Graph API.
thumbnailUrlstringyes
thumbnailPreviewUrlstringyes

Post

A single Instagram post in any lifecycle stage. Transitions: DRAFT → QUEUED → PROCESSING → PUBLISHING → PUBLISHED | FAILED; scheduled posts start at SCHEDULED.

FieldTypeNullNotes
iduuidno
userIduuidno
instagramAccountIduuidno
mediaTypeenumnoIMAGE | REELS | STORIES | CAROUSEL.
sourceTypeenumnoMANUAL | SCHEDULED | STORY_REPOST | AUTO_REPOST.
captionstringyes
hashtagsstringyesCurrently unused — hashtags live inline in captions.
locationIdstringyesFacebook Place ID.
locationNamestringyes
userTagsstringyesJSON-encoded UserTag[].
collaboratorsstringyesJSON-encoded string[] of usernames, max 3.
storyMentionsstringyesJSON-encoded StoryMention[].
publishedViaPrivateApibooleanno
crossPostToFacebookbooleanno
facebookPostIdstringyes
facebookPermalinkstringyes
isScheduledbooleanno
scheduledFordate-timeyes
timezonestringnoIANA zone.
statusenumnoDRAFT | SCHEDULED | QUEUED | PROCESSING | PUBLISHING | PUBLISHED | FAILED.
igContainerIdstringyesTransient during publish.
igMediaIdstringyesInstagram media id for a PUBLISHED post.
igPermalinkstringyes
cloudflareJobIdstringyesSet when scheduled via Worker.
r2MediaKeystringyesR2 key for cloud-scheduled media.
errorMessagestringyes
errorCodestringyesOne of the codes in Errors.
retryCountintegerno
lastAttemptAtdate-timeyes
publishedAtdate-timeyes
createdAtdate-timeno
updatedAtdate-timeno
mediaFilesMediaFile[]noPopulated on GET /posts/{id}.

ScheduledJob

Local SQLite-backed job queue row. Polled every 5s for status=PENDING + runAt <= now. Cloud-scheduled posts do not live here — they live in Cloudflare KV under the Worker.

FieldTypeNullNotes
iduuidno
typeenumnoPUBLISH | STORY_REPOST_CHECK.
statusenumnoPENDING | PROCESSING | COMPLETED | FAILED | CANCELLED.
runAtdate-timenoEarliest pickup time.
repeatIntervalMsintegeryesRepeating-job interval.
repeatKeystringyesDedup key for upserts.
attempts / maxAttemptsintegernoAfter attempts == maxAttempts a failed job stays FAILED.
lastErrorstringyes
createdAt / updatedAt / completedAtdate-timecompletedAt nullable

StoryRepostConfig

Per-account config for the automated story-repost subsystem. Carries polling cadence plus a stack of safety rails: warm-up, sleep window, daily caps, challenge escalation, auto-disable.

FieldTypeNullNotes
id / userIduuidno
instagramAccountIduuidyes
isEnabledbooleanno
igUsernamestringno
pollIntervalHoursintegerno6–48.
jitterMinutesintegernoRandomised ± offset to defeat cadence detection.
lastPollAtdate-timeyes
lastPollStatusenumyesSUCCESS | FAILED | NO_MENTIONS.
lastPollErrorstringyes
consecutiveFailuresintegerno
autoDisabledAtdate-timeyesSet when N failures trip the breaker.
autoDisableReasonstringyes
challengeCount30dintegerno
lastChallengeAtdate-timeyes
challengePauseUntildate-timeyesPolling paused until this time.
configuredAtdate-timeyes
warmUpCompletebooleannoFalse during the 30-day burn-in.
sleepWindowStart / sleepWindowEndintegernoLocal hours 0–23 for quiet mode.
dailyRepostLimitintegerno
repostsTodayintegerno
repostsTodayResetAtdate-timeyes
autoRepostEnabledbooleanno
lastAutoSyncAtdate-timeyes
autoTagOriginalPosterbooleanno
signatureOverlaystringyesJSON-encoded text overlay for attribution.
createdAt / updatedAtdate-timeno

ApiKey

The plaintext value is returned only by the create endpoint, only once.

FieldTypeNullNotes
iduuidno
namestringnoUser label.
prefixstringnoFirst 12 chars of the full key — safe to display.
scopesstringnoComma-separated. Empty string = full access (Phase 1).
createdAtdate-timeno
lastUsedAtdate-timeyes
revokedAtdate-timeyesNon-null = dead key; requests return AUTH_INVALID_KEY.

UserTag

FieldTypeNotes
usernamestring1–30 chars.
xnumber0 (left) to 1 (right).
ynumber0 (top) to 1 (bottom).

StoryMention

An @mention sticker on a story. Requires Private API; Graph API does not support stickers.

FieldTypeNotes
usernamestring1–30 chars.
x / ynumber0–1, both default 0.5.

ErrorResponse

FieldTypeNotes
errorstringHuman message. Do not pattern-match.
codeErrorCodeStable enum. Match on this.
statusintegerHTTP status.
detailsanyDev-mode only for VALIDATION_ERROR.
retryAfterintegerRATE_LIMITED only. Seconds.

For the full ErrorCode enum with HTTP statuses and recovery hints, see the dedicated Errors reference.

6. Errors

The full error code catalog lives on its own page for easy scanning:

→ Error codes reference

All 50 codes grouped by category (authentication, validation, media pipeline, Private API, Cloudflare, rate limiting) with HTTP status and recovery hints for each.

7. Rate limits

Defaults: 100 requests / minute and 1000 requests / hour per API key — whichever fills first. Cookie-authenticated traffic is not rate-limited. Override with env vars RATE_LIMIT_RPM / RATE_LIMIT_RPH.

Scoping: per API key, not per user. A user with two keys has two independent buckets — useful for isolating a heavy batch job from an interactive integration.

Reading the headers

Every successful response carries:

RateLimit-Limit: 100
RateLimit-Remaining: 72
RateLimit-Reset: 37
RateLimit-Policy: 100;w=60, 1000;w=3600
  • RateLimit-Remaining — requests left in the tightest window that is currently nearest to exhaustion.
  • RateLimit-Reset — seconds until that window rolls.
  • RateLimit-Policy100;w=60, 1000;w=3600 = 100/60s and 1000/3600s.

On 429

HTTP/1.1 429 Too Many Requests
Retry-After: 7
Content-Type: application/json

{ "error": "Rate limited", "code": "RATE_LIMITED", "status": 429, "retryAfter": 7 }

Wait exactly retryAfter seconds, then try once. Do not exponential-backoff — you waste your own budget.

Patterns

  • Tight polls (publish status): 3–5 s is safe — you'll make ≤20 calls per post, nowhere near the limit.
  • Long polls (repost history): 60 s or more. The detector cron runs on the same cadence; faster polling gains nothing.
  • Bulk schedules: stay under ~3 schedule calls per second (the hour limit is the real constraint there — 1000/h = 16/s sustained, but you'll blow past the minute window first).

8. Webhooks

Not available in v3.3.0.

For anything event-driven, poll:

  • Post lifecycle: GET /posts/recent-completions?since=<ISO> (fire-and-forget) or GET /posts/{id} (tight polling, 3–5 s, up to 3 min).
  • Story reposts: GET /story-repost/history?accountId=... with a Set<id> diff — see Quickstart workflow 5.
  • Scheduled queue health: GET /schedule/queue/status + GET /cloudflare/diagnostics.

Webhooks are on the roadmap but not in v1. When they ship they'll be opt-in per event type and will reuse the same ErrorCode enum in failure payloads for symmetry.

9. Stability & versioning

  • /api/v1/* is frozen for the v3.x line. No field removals, no renames, no type changes on existing endpoints.
  • New fields are additive on both requests (ignored when absent) and responses (clients must ignore unknown keys).
  • New endpoints may appear inside v1 within v3.x minor releases; the TOC here and the OpenAPI spec are the inventory.
  • Breaking changes require /api/v2, mounted alongside v1.
  • Deprecations get ≥3 months notice via GitHub release notes on REMvisual/VoidGram-releases. During the window the endpoint returns Deprecation and Sunset headers per RFC 8594.
  • /api/* (no /v1) is the internal UI contract — do not integrate against it; it will move with the UI between minor releases.

10. Appendix — common patterns

Publishing an image post

End-to-end in one sentence: POST /media/uploadPOST /posts/publish with that mediaFileId → poll GET /posts/{id} until PUBLISHED. Full code examples live in the Quickstart → workflow 1.

Scheduling

Same upload, then POST /schedule with scheduledFor in the future. isCloudflare in the response tells you whether the job will fire from the local SQLite queue (desktop must be open) or from the Worker cron (runs even when closed). See the Quickstart → workflow 2.

Polling post status

for i in $(seq 1 60); do
  DATA=$(curl -sH "Authorization: Bearer $VOIDGRAM_API_KEY" \
    http://localhost:3001/api/v1/posts/$POST_ID)
  STATUS=$(echo "$DATA" | jq -r .status)
  case "$STATUS" in
    PUBLISHED) echo "$DATA" | jq .igPermalink; break ;;
    FAILED)    echo "$DATA" | jq '{errorCode,errorMessage}'; exit 1 ;;
  esac
  sleep 3
done

Every 3–5 s, break on PUBLISHED or FAILED. Timeout at ~3 min for videos (container processing is the slow part).

Retrying failed posts safely

Use POST /posts/{id}/retry — it reuses the same Post record and preserves history. Do not call POST /posts/publish again with the same media; that creates a duplicate with a fresh container. retryCount is preserved across retries; errorCode/errorMessage are cleared.

Reading cloud job health

curl -sH "Authorization: Bearer $VOIDGRAM_API_KEY" \
  http://localhost:3001/api/v1/cloudflare/diagnostics | jq '
    { lastCronRun, scheduledCount, pendingRepostCount, failedCount, offline }
  '
  • lastCronRun older than ~90 s = cron isn't firing (normal for 5–15 min after a fresh deploy).
  • offline: true = Worker unreachable. offlineReason carries the detail.
  • failedCount growing without completedCount growing = every job is failing; check Worker logs.

Detecting session expiry

Treat AUTH_INVALID_KEY as fatal for the calling tool — do not retry. Your options:

  1. The key was revoked → create a new one in the UI.
  2. The user wiped .env → the server's JWT_SECRET rotated → revoke & recreate.

Separately: TOKEN_EXPIRED from a publishing endpoint means the Instagram access token is stale, not your API key. Tell the user to reconnect the IG account; the API key is still fine.

Safe batch scheduling

  • Respect Instagram's 25/24h publishing cap per account. Spread the schedule out and/or rotate across accounts.
  • Stagger by ≥20 min between posts on the same account.
  • Before enqueueing >10 jobs, read GET /schedule/queue/status (local) and GET /cloudflare/diagnostics (cloud) to know what's already in flight.
  • Cross-post to Facebook only on local-scheduled posts.
  • If you need scheduled stories with @mention stickers, make sure Private API credentials are configured and the session warming has reached phase 3.

Document version: matches VoidGram v3.3.0. Source of truth is the OpenAPI spec at GET /api/v1/openapi.json. For walkthrough-style examples, see the Quickstart. For MCP integration, see the MCP docs.