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
scopesfield onApiKeyexists 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/publishorPOST /scheduleafter 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/v2alongside 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/uploadonly:Content-Type: multipart/form-data, single fieldfile. - 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 forVALIDATION_ERROR; the raw Zod error tree. Absent in production builds.retryAfter— only present onRATE_LIMITED; seconds until the window resets.
Dates
- Wire format: ISO 8601.
- Responses: always UTC with the
Zsuffix, 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.
| Endpoint | Uses | Defaults | Max |
|---|---|---|---|
GET /posts | ?page + ?limit | page 1, limit 20 | limit 100 |
GET /schedule | ?page + ?limit | page 1, limit 20 | limit 100 |
GET /media | ?offset + ?limit | offset 0, limit 20 | limit 100 |
All list responses include hasMore: boolean — use that rather than computing total > offset + limit yourself (counts can shift under concurrent writes).
Note:
/mediais the one endpoint that takesoffsetinstead ofpage. Every other paginator usespage. 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:
| Mode | Header / transport | Used by | Rate-limited? |
|---|---|---|---|
| Bearer | Authorization: Bearer vg_live_... | External integrations, CLI, MCP, agents | Yes |
| Session cookie | Cookie: session=<JWT> | The embedded Electron UI | No |
Key lifecycle
- Create — cookie-only.
POST /settings/api-keys. The fullvg_live_...value is returned exactly once infullKey. Store it immediately. - Use — pass
Authorization: Bearer vg_live_...on every request. - Revoke — cookie-only.
DELETE /settings/api-keys/{id}. Revocation is immediate: the next request with the old key returns401 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
} statusenum: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 }—usernameis only included whenauthenticated: 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 theInstagramAccount. - 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 fieldfile. - Limits: default 100 MB (
MAX_FILE_SIZE_MBenv 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— integer1..100, default 20.offset— integer>=0, default 0. Note: this is the one list endpoint that usesoffsetinstead ofpage.type— enumimage|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):
| Field | Type | Required | Notes |
|---|---|---|---|
accountId | UUID | yes | The InstagramAccount.id to post from. |
mediaType | enum | yes | IMAGE | REELS | STORIES | CAROUSEL. UI labels CAROUSEL as "Multi-Image". |
mediaFileId | UUID | single-media | Required for IMAGE, REELS, STORIES. |
mediaFileIds | UUID[] | CAROUSEL | 2–10 items, CAROUSEL only. |
caption | string | no | Max 2200 chars. |
userTags | UserTag[] | no | Max 20. Only allowed on IMAGE and CAROUSEL. |
collaborators | string[] | no | Max 3 usernames. Not allowed on STORIES. |
locationId | string | no | Facebook Place ID from Instagram's location search. |
locationName | string | no | Display name to pair with locationId. |
crossPostToFacebook | boolean | no | Requires loginMethod: "facebook". Not allowed on CAROUSEL. |
storyMentions | StoryMention[] | no | Max 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(onlyaccountId+mediaTypestrictly required). - 201 Response:
Postwithstatus: "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
DRAFTreturnsINVALID_STATUS. For scheduled posts, usePUT /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}untilstatusisPUBLISHEDorFAILED. - Rate limit: Instagram caps publishing at 25 posts / 24h per account (50 for some accounts). VoidGram does not enforce this — you'll see
FAILEDwith 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
instagramAccountIdif needed.
GET /api/v1/posts/{id}
- 200 Response:
PostwithmediaFilesarray populated. - Errors:
NOT_FOUND.
POST /api/v1/posts/{id}/retry
- Description: Requeue a
FAILEDpost. Status moves back toQUEUED,retryCountis unchanged,errorCode/errorMessagecleared. - 202 Response:
{ "postId": "uuid", "status": "QUEUED" } - Errors:
NOT_FOUND,MEDIA_INVALID. - Gotcha: only
FAILEDposts are retryable. Do not "retry" by callingPOST /posts/publishagain — 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
DeletedPostarchive 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
PUBLISHEDposts with anigMediaId.
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,
wasDeletedFromInstagramisfalseanddeleteErrorcarries the reason.
4.5 Schedule
All schedule endpoints accept the PublishablePost body from Posts plus two extra fields:
| Field | Type | Required | Notes |
|---|---|---|---|
scheduledFor | ISO 8601 | yes | Must be strictly in the future. |
timezone | string | no | IANA 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}
- 200 Response:
Post.
PUT /api/v1/schedule/{id}
- Description: Edit a scheduled post. Both content and
scheduledForcan 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 withrunAt <= now.active— PROCESSING jobs.delayed— PENDING jobs withrunAt > 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:
lastCronRunolder 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_mentionitems 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 leastid,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
} phase1 / 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"
} fullKeyis 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.
| Field | Type | Null | Notes |
|---|---|---|---|
id | uuid | no | Primary key. |
igUserId | string | no | Numeric Instagram user id. |
username | string | no | — |
name | string | yes | Display name. |
profilePictureUrl | string | yes | Relative /uploads/avatars/.... |
accountType | string | yes | "BUSINESS" or "CREATOR". |
facebookPageId | string | yes | Only for loginMethod: "facebook". |
facebookPageName | string | yes | — |
tokenExpiresAt | date-time | yes | When to reconnect. |
loginMethod | enum | no | "instagram" | "facebook". |
followersCount | number | yes | — |
mediaCount | number | yes | — |
lastSyncedAt | date-time | yes | — |
createdAt | date-time | no | — |
MediaFile
Uploaded image or video. Publishing always references processedPath when set, else storagePath.
| Field | Type | Null | Notes |
|---|---|---|---|
id | uuid | no | — |
originalFilename | string | no | — |
storagePath | string | no | Relative under uploads/ — not a URL. |
mimeType | string | no | — |
fileSize | integer | no | Bytes. |
width / height | integer | yes | Pixels. |
durationSeconds | number | yes | Videos only. |
aspectRatio | string | yes | e.g. "9:16". |
isValid | boolean | no | false if validation failed. |
validationErrors | string | yes | JSON-encoded array of messages, or null. |
thumbnailPath | string | yes | — |
processedPath | string | yes | Set once editor transforms are rendered. |
createdAt | date-time | no | — |
previewUrl | string | yes | Relative URL served by local Express. |
publicUrl | string | yes | Full ngrok/public URL for Graph API. |
thumbnailUrl | string | yes | — |
thumbnailPreviewUrl | string | yes | — |
Post
A single Instagram post in any lifecycle stage. Transitions: DRAFT → QUEUED → PROCESSING → PUBLISHING → PUBLISHED | FAILED; scheduled posts start at SCHEDULED.
| Field | Type | Null | Notes |
|---|---|---|---|
id | uuid | no | — |
userId | uuid | no | — |
instagramAccountId | uuid | no | — |
mediaType | enum | no | IMAGE | REELS | STORIES | CAROUSEL. |
sourceType | enum | no | MANUAL | SCHEDULED | STORY_REPOST | AUTO_REPOST. |
caption | string | yes | — |
hashtags | string | yes | Currently unused — hashtags live inline in captions. |
locationId | string | yes | Facebook Place ID. |
locationName | string | yes | — |
userTags | string | yes | JSON-encoded UserTag[]. |
collaborators | string | yes | JSON-encoded string[] of usernames, max 3. |
storyMentions | string | yes | JSON-encoded StoryMention[]. |
publishedViaPrivateApi | boolean | no | — |
crossPostToFacebook | boolean | no | — |
facebookPostId | string | yes | — |
facebookPermalink | string | yes | — |
isScheduled | boolean | no | — |
scheduledFor | date-time | yes | — |
timezone | string | no | IANA zone. |
status | enum | no | DRAFT | SCHEDULED | QUEUED | PROCESSING | PUBLISHING | PUBLISHED | FAILED. |
igContainerId | string | yes | Transient during publish. |
igMediaId | string | yes | Instagram media id for a PUBLISHED post. |
igPermalink | string | yes | — |
cloudflareJobId | string | yes | Set when scheduled via Worker. |
r2MediaKey | string | yes | R2 key for cloud-scheduled media. |
errorMessage | string | yes | — |
errorCode | string | yes | One of the codes in Errors. |
retryCount | integer | no | — |
lastAttemptAt | date-time | yes | — |
publishedAt | date-time | yes | — |
createdAt | date-time | no | — |
updatedAt | date-time | no | — |
mediaFiles | MediaFile[] | no | Populated 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.
Field Type Null Notes iduuid no — typeenum no PUBLISH | STORY_REPOST_CHECK. statusenum no PENDING | PROCESSING | COMPLETED | FAILED | CANCELLED. runAtdate-time no Earliest pickup time. repeatIntervalMsinteger yes Repeating-job interval. repeatKeystring yes Dedup key for upserts. attempts / maxAttemptsinteger no After attempts == maxAttempts a failed job stays FAILED. lastErrorstring yes — createdAt / updatedAt / completedAtdate-time completedAt 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.
Field Type Null Notes id / userIduuid no — instagramAccountIduuid yes — isEnabledboolean no — igUsernamestring no — pollIntervalHoursinteger no 6–48. jitterMinutesinteger no Randomised ± offset to defeat cadence detection. lastPollAtdate-time yes — lastPollStatusenum yes SUCCESS | FAILED | NO_MENTIONS. lastPollErrorstring yes — consecutiveFailuresinteger no — autoDisabledAtdate-time yes Set when N failures trip the breaker. autoDisableReasonstring yes — challengeCount30dinteger no — lastChallengeAtdate-time yes — challengePauseUntildate-time yes Polling paused until this time. configuredAtdate-time yes — warmUpCompleteboolean no False during the 30-day burn-in. sleepWindowStart / sleepWindowEndinteger no Local hours 0–23 for quiet mode. dailyRepostLimitinteger no — repostsTodayinteger no — repostsTodayResetAtdate-time yes — autoRepostEnabledboolean no — lastAutoSyncAtdate-time yes — autoTagOriginalPosterboolean no — signatureOverlaystring yes JSON-encoded text overlay for attribution. createdAt / updatedAtdate-time no —
ApiKey
The plaintext value is returned only by the create endpoint, only once.
Field Type Null Notes iduuid no — namestring no User label. prefixstring no First 12 chars of the full key — safe to display. scopesstring no Comma-separated. Empty string = full access (Phase 1). createdAtdate-time no — lastUsedAtdate-time yes — revokedAtdate-time yes Non-null = dead key; requests return AUTH_INVALID_KEY.
UserTag
Field Type Notes usernamestring 1–30 chars. xnumber 0 (left) to 1 (right). ynumber 0 (top) to 1 (bottom).
StoryMention
An @mention sticker on a story. Requires Private API; Graph API does not support stickers.
Field Type Notes usernamestring 1–30 chars. x / ynumber 0–1, both default 0.5.
ErrorResponse
Field Type Notes errorstring Human message. Do not pattern-match. codeErrorCode Stable enum. Match on this. statusinteger HTTP status. detailsany Dev-mode only for VALIDATION_ERROR. retryAfterinteger RATE_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:
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-Policy — 100;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/upload → POST /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:
- The key was revoked → create a new one in the UI.
- 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.