---
title: "Approve or reject a post"
method: POST
path: "/v1/posts/{postId}/review"
tags: ["Posts"]
---

# Approve or reject a post

`POST /v1/posts/{postId}/review`

## Request

- Base URL: `https://zernio.com/api`
- URL: `https://zernio.com/api/v1/posts/{postId}/review`
- Auth: HTTP bearer

Approves or rejects a post saved with `requiresApproval: true` (status `pending_approval`).
`approve` schedules the post for its `scheduledFor` and fires the `post.scheduled` webhook. When that time has already passed, the post publishes within a couple of minutes.
`reject` returns the post to `draft`.
Returns 409 when the post is not `pending_approval`.

## Path parameters

- `postId` string, required

## Request body

- object
  - `decision` 'approve' | 'reject', required: approve schedules the post; reject returns it to draft.

## Response `200`

The reviewed post

- PostGetResponse
  - `post` Post
    - `_id` string
    - `userId` union
      - string
      - User
        - `_id` string
        - `email` string
        - `name` string
        - `role` string
        - `createdAt` string, date-time
    - `title` string: Stored on the post for reference/display only. This field is NOT used as the video title when publishing. To set a YouTube video title, use platformSpecificData.title on the youtube platform target (falls back to the first line of content when omitted).
    - `content` string
    - `mediaItems` MediaItem[]
      - `type` 'image' | 'video' | 'gif' | 'document'
      - `url` string, uri: A media item with a null, missing or empty url is dropped.
      - `title` string: Optional title for the media item. Used as the document title for LinkedIn PDF/carousel posts. If omitted, falls back to the post title, then the filename.
      - `altText` string: Accessibility alternative text for an image, applied on every platform that supports it: Instagram (feed images only, not Reels/Stories), Facebook, Threads, X (max 1000 chars), LinkedIn, Bluesky, and Pinterest (max 500 chars). Ignored on platforms without alt-text support (TikTok, YouTube, Snapchat, Telegram, Reddit, Google Business Profile, WhatsApp) and on video items where the platform does not accept it. Set once per image; the same value is sent to each selected platform.
      - `filename` string
      - `size` integer: Optional file size in bytes
      - `mimeType` string: Optional MIME type (e.g. image/jpeg, video/mp4)
      - `thumbnail` string, uri: Optional custom thumbnail/cover image URL for videos. Supported for Facebook video posts, Facebook Reels, regular video uploads, and LinkedIn video posts. Max 10MB, JPG/PNG recommended.
      - `instagramThumbnail` string, uri: Custom cover image URL for Instagram Reels. Can also be set via platformSpecificData.instagramThumbnail or platformSpecificData.reelCover. Resolution order: this field > platformSpecificData.instagramThumbnail > platformSpecificData.reelCover > platformSpecificData.thumbnailUrl (legacy).
      - `subtitles` MediaSubtitle[]: Subtitle (closed caption) files for a video item, in SRT or WebVTT. Each language may appear once per video. Sent to YouTube, Facebook videos, LinkedIn, X and Bluesky; ignored with a warning on every other platform. Zernio downloads each file when the post is created or updated and converts it to the format each platform requires. Per-platform rules: YouTube takes every track; Facebook takes every track, up to 200 KB each; Bluesky takes every track, up to 20 KB each after conversion to WebVTT; X takes the first track only (one subtitle per video); LinkedIn takes the first English track only. Tracks a platform cannot take are skipped and reported in the response warnings. A file that is unreachable, redirects, exceeds 1 MB, is not valid SRT or WebVTT, or is over a platform limit is rejected with a 400 (code INVALID_SUBTITLES). If a platform rejects a file at publish time, the post is still published, without subtitles.
        - `url` string, uri, required: Public http(s) URL of an SRT or WebVTT file: your own host, or an upload from POST /v1/media/presign (contentType application/x-subrip or text/vtt). It must serve the file directly, since redirects are refused.
        - `language` string, required: BCP-47 language code with a two-letter language, e.g. en, es or pt-BR. Viewers see the track labelled with the language name, e.g. English.
      - `tiktokProcessed` boolean: Internal flag indicating the image was resized for TikTok
    - `platforms` PlatformTarget[]
      - `platform` string: Supported values: twitter, threads, instagram, youtube, facebook, linkedin, pinterest, reddit, tiktok, bluesky, googlebusiness, telegram
      - `accountId` union
        - string
        - SocialAccount
          - `_id` string, required
          - `platform` 'tiktok' | 'instagram' | 'facebook' | 'youtube' | 'linkedin' | 'twitter' | 'threads' | 'pinterest' | 'reddit' | 'bluesky' | 'googlebusiness' | 'telegram' | 'snapchat' | 'discord' | 'slack' | 'whatsapp' | 'shopify' | 'wordpress' | 'wix' | 'linkedinads' | 'metaads' | 'pinterestads' | 'tiktokads' | 'xads' | 'googleads' | 'openaiads' | 'redditads' | 'sms' | 'phone' | 'rcs' | 'whopads' | 'hubspot' | 'webflow', required
          - `profileId` union, required
            - string
            - Profile
              - …
          - `username` string
          - `displayName` string
          - `platformUserId` string: The account's id on its platform as the platform reports it to Zernio; stable across reconnects, so it is the key to match an account against your own records. Instagram: the app-scoped user id on Instagram Login accounts (the professional account id is in `metadata.instagramScopedId`), the professional account id (`17841...`) on Facebook Login accounts. TikTok: the open_id of Zernio's TikTok app, which differs from the open_id any other app sees for the same user. Either value can be passed back as `expectedPlatformUserId` on GET /v1/connect/{platform}.
          - `tiktokAccountType` 'business' | 'personal' | 'unknown': TikTok accounts only. The account type TikTok reported when the account was connected. `personal` accounts cannot use TikTok direct messages through the API (TikTok limits Business Messaging to Business Accounts): skip the inbox for them and tell the user to switch to a Business Account in the TikTok app, then reconnect. `business` is the prerequisite, not a guarantee; messaging also needs the messaging scopes granted and TikTok's regional availability. `unknown` on accounts connected before this was captured or whose grant left out the account-type scope.
          - `profilePicture` string, nullable: URL to the account's profile picture on the platform. May be null if the platform does not provide one.
          - `profileUrl` string: Full profile URL for the connected account on its platform.
          - `isActive` boolean, required
          - `needsReconnection` boolean: The platform definitively reported the stored OAuth token as dead. While true, GET /v1/connect/{platform}/ads returns a fresh authUrl (implicit force=true) instead of alreadyConnected, so re-running the connect flow recovers the account. Cleared automatically when the account is re-authorized.
          - `followersCount` number: Follower count (only included if user has analytics add-on)
          - `followersLastUpdated` string, date-time: Last time follower count was updated (only included if user has analytics add-on)
          - `parentAccountId` string, nullable: Reference to the parent posting SocialAccount. Set for ads accounts that share or derive from a posting account's OAuth token. null for standalone ads (Google Ads) and all posting accounts. Meta ads business-login accounts also have no parent.
          - `enabled` boolean: Whether the user explicitly activated this account. false means the account was created as a side effect (e.g., posting account auto-created when user connected ads first). GET /v1/accounts still returns such accounts unless `excludeHidden=true` is passed. They cannot be posted to (`ACCOUNT_NOT_ENABLED_FOR_POSTING`) and are not billed as connected accounts. On an ads account (`metaads`, `googleads`, ...) the value does not hide the connection: ads accounts are listed whatever their `enabled` value and keep working for ads.
          - `rateLimitedUntil` string, date-time, nullable: End of the account's publishing cooldown. Compare it with the current time: a value in the past means the cooldown has ended (the field is cleared on the next successful publish), null means no cooldown since the last successful publish. A cooldown starts when a post to the account fails with `errorCategory` `user_abuse` (spam, abuse or rate-limit errors), or when 10 or more posts to it fail within an hour with `user_content`, `platform_error`, `platform_rate_limit` or `unknown`. It doubles with each start inside a 24-hour window that opens at the first one (10, 20, 40, 80 minutes and so on, capped at 24 hours). On a YouTube account with a recorded successful upload it instead runs until 24 hours after that upload, or 1 hour when that upload is older than 24 hours. While it is in the future, POST /v1/posts (drafts excepted) and POST /v1/posts/{postId}/retry return 429 for this account, and this account's pending targets on posts scheduled before the cooldown ends are failed when it starts.
          - `metadata` object: Platform-specific metadata. Fields vary by platform. For WhatsApp accounts, includes: - qualityRating: Phone number quality rating from Meta (GREEN, YELLOW, RED, or UNKNOWN) - nameStatus: Display name review status (APPROVED, PENDING_REVIEW, DECLINED, or NONE). A declined or pending display name does not by itself block sending; sendability is reported separately via health_status (can_send_message). - messagingLimitTier: Maximum unique business-initiated conversations per 24h rolling window (TIER_250, TIER_1K, TIER_10K, TIER_100K, or TIER_UNLIMITED). Scales automatically as quality rating improves. - verifiedName: Meta-verified business display name - displayPhoneNumber: Formatted phone number (e.g., "+1 555-123-4567") - wabaId: WhatsApp Business Account ID - phoneNumberId: Meta phone number ID For Meta ads business-login accounts: - tokenType: system-user - businessId: The owning Business Manager ID when there is one owner; null for multiple owners. - businessIds: Owning Business Manager IDs discovered from granted ad accounts. - grantedAdAccountIds: Ad-account IDs granted to the token. - adAccountBusinesses: Map from ad-account ID to its owning business ID or null. - availablePages: Granted Page IDs and names. No Page tokens are exposed. - selectedPageId: The Page selected for creatives and lead forms, or null. - scopedAdAccountIds: Existing sync scope preserved on reconnect. Non-expiring tokens have no tokenExpiresAt field. Parent posting reconnects do not replace this token. For LinkedIn accounts, profileData carries the profile details refreshed on each daily snapshot: - profileData.bio: The member's headline for personal accounts, or the organization description for organization accounts. null when the member has not set one. - profileData.extraData.vanityName: The member's profile slug, i.e. the /in/{vanityName} segment of profileUrl. Personal accounts only; an organization's own slug is in metadata.organizationInfo.vanityName. For Instagram accounts: - loginMethod: "facebook_login" when the account was connected through Facebook Login. Absent on accounts connected with Instagram Login. On facebook_login accounts, comment reads leave hidden comments out entirely instead of returning them with isHidden true. - instagramScopedId: the Instagram professional account id (`17841...`). On Instagram Login accounts this is the id that is the same whichever app connected the account, while platformUserId is app-scoped; Facebook Login accounts hold this id as platformUserId. For X (Twitter) accounts: - profileData.extraData.isPremium: Whether X reports a paid subscription (Basic, Premium, Premium+, or a blue verified badge), which raises the post length limit from 280 to 25,000 characters. Read live at connect and reconnect and refreshed by the daily follower snapshot; because X intermittently reports no subscription for subscribed accounts, a cancellation is stored on the fourth consecutive daily snapshot that reports it (about four days). Accounts connected before the extraData layout carry the same flag at profileData.isPremium.
      - `customContent` string: Platform-specific text override. When set, this content is used instead of the top-level post content for this platform. Useful for tailoring captions per platform (e.g. keeping tweets under 280 characters).
      - `customMedia` MediaItem[]
        - `type` 'image' | 'video' | 'gif' | 'document'
        - `url` string, uri: A media item with a null, missing or empty url is dropped.
        - `title` string: Optional title for the media item. Used as the document title for LinkedIn PDF/carousel posts. If omitted, falls back to the post title, then the filename.
        - `altText` string: Accessibility alternative text for an image, applied on every platform that supports it: Instagram (feed images only, not Reels/Stories), Facebook, Threads, X (max 1000 chars), LinkedIn, Bluesky, and Pinterest (max 500 chars). Ignored on platforms without alt-text support (TikTok, YouTube, Snapchat, Telegram, Reddit, Google Business Profile, WhatsApp) and on video items where the platform does not accept it. Set once per image; the same value is sent to each selected platform.
        - `filename` string
        - `size` integer: Optional file size in bytes
        - `mimeType` string: Optional MIME type (e.g. image/jpeg, video/mp4)
        - `thumbnail` string, uri: Optional custom thumbnail/cover image URL for videos. Supported for Facebook video posts, Facebook Reels, regular video uploads, and LinkedIn video posts. Max 10MB, JPG/PNG recommended.
        - `instagramThumbnail` string, uri: Custom cover image URL for Instagram Reels. Can also be set via platformSpecificData.instagramThumbnail or platformSpecificData.reelCover. Resolution order: this field > platformSpecificData.instagramThumbnail > platformSpecificData.reelCover > platformSpecificData.thumbnailUrl (legacy).
        - `subtitles` MediaSubtitle[]: Subtitle (closed caption) files for a video item, in SRT or WebVTT. Each language may appear once per video. Sent to YouTube, Facebook videos, LinkedIn, X and Bluesky; ignored with a warning on every other platform. Zernio downloads each file when the post is created or updated and converts it to the format each platform requires. Per-platform rules: YouTube takes every track; Facebook takes every track, up to 200 KB each; Bluesky takes every track, up to 20 KB each after conversion to WebVTT; X takes the first track only (one subtitle per video); LinkedIn takes the first English track only. Tracks a platform cannot take are skipped and reported in the response warnings. A file that is unreachable, redirects, exceeds 1 MB, is not valid SRT or WebVTT, or is over a platform limit is rejected with a 400 (code INVALID_SUBTITLES). If a platform rejects a file at publish time, the post is still published, without subtitles.
          - `url` string, uri, required: Public http(s) URL of an SRT or WebVTT file: your own host, or an upload from POST /v1/media/presign (contentType application/x-subrip or text/vtt). It must serve the file directly, since redirects are refused.
          - `language` string, required: BCP-47 language code with a two-letter language, e.g. en, es or pt-BR. Viewers see the track labelled with the language name, e.g. English.
        - `tiktokProcessed` boolean: Internal flag indicating the image was resized for TikTok
      - `scheduledFor` string, date-time: Optional per-platform scheduled time override (uses post.scheduledFor when omitted)
      - `platformSpecificData` object: The platform-specific options stored on this target, echoed back as they were sent. Typed per platform on the way in (see the *PlatformData schemas on the request body); free-form on the way out, because a response is not guaranteed to match exactly one of those variants and generated clients that pick a variant by structure reject the entire response when it doesn't. Zernio's internal publishing state (snapshots, container ids, publish stage) is never returned here, and the key is omitted rather than sent as an empty object.
      - `status` string: Platform-specific status: pending, processing, uploading, published, failed, cancelled (removed from the platform via POST /v1/posts/{postId}/unpublish)
      - `platformPostId` string: The native post ID on the platform (populated after successful publish)
      - `platformPostUrl` string, nullable: Public URL of the published post. Included in the response for immediate posts; for scheduled posts, fetch via GET /v1/posts/{postId} after publish time. Empty when the platform confirmed the publish without returning an id a permalink can be built from (TikTok returns a publish id for some uploads); the TikTok reconcile cron backfills it later. If TikTok never returns one, /v1/analytics reports that entry as syncStatus unavailable.
      - `publishedAt` string, date-time: Timestamp when the post was published to this platform. YouTube uploads a scheduled public video up to 15 minutes early as private with YouTube's own release time, so its entry is published with this set to scheduledFor, the moment the video goes public.
      - `removedFromPlatformAt` string, date-time, nullable: Set when a post that was successfully published later disappears from the platform (deleted on-platform or taken down by the platform). status stays "published" (it reflects the publish outcome); poll this field to detect post-publish removals. Absent while the post is live, and cleared if the post reappears. Detection runs with the analytics sync, so expect up to a few hours of lag.
      - `isTrialReel` boolean: Present and true only when this Instagram reel was launched as a Trial through Zernio (created with platformSpecificData.trialParams). Use it to segment trial reels in analytics. Note: Instagram's Graph API exposes no readable trial field, so this reflects creation-time intent only. It indicates the reel STARTED as a trial, not whether or when it graduated.
      - `trialGraduationStrategy` 'MANUAL' | 'SS_PERFORMANCE': Graduation strategy the trial reel was launched with. Present only when isTrialReel is true.
      - `publishAttempts` integer: Number of times the publishing pipeline claimed this target to publish it since it was created or last manually retried (a manual retry resets it, except on TikTok). Not the number of retries (see publishRetries).
      - `publishRetries` integer: Instagram only. Automatic re-sends of media_publish on the same container that Zernio scheduled after Meta answered a transient error (30 min, 2 h and 6 h after each failure, at most 3). While the target is still processing, the latest one may not have been sent yet. Not reset by a manual retry. Independent of publishAttempts, which counts publish-leg claims. Absent when none was scheduled.
      - `errorMessage` string: Human-readable error message when status is failed. Contains platform-specific error details explaining why the publish failed.
      - `errorCategory` 'auth_expired' | 'user_content' | 'user_abuse' | 'account_issue' | 'platform_rejected' | 'platform_error' | 'platform_rate_limit' | 'quota_exhausted' | 'system_error' | 'unknown': Error category for programmatic handling: auth_expired (token expired/revoked), user_content (wrong format/too long), user_abuse (rate limits/spam), account_issue (config problems), platform_rejected (policy violation), platform_error (5xx/maintenance), platform_rate_limit (platform throttling, retried automatically), quota_exhausted (a shared quota pool the integration draws on is empty, including our own capacity gate in front of one; not caused by your content or account, and safe to retry once the pool frees up), system_error (Zernio infra), unknown
      - `errorSource` 'user' | 'platform' | 'system': Who caused the error: user (fix content/reconnect), platform (outage/API change), system (Zernio issue, rare)
      - `platformError` PostPlatformError: The platform's own error for this target, so you can see what the platform said (access tokens are redacted). Set when the last attempt failed with an error the platform described (currently Instagram and Facebook), and kept while an automatic retry of that failure is pending or running; cleared when the target publishes or is retried manually. errorMessage stays the human-readable summary; this block is for diagnostics and support tickets. Instagram media processing failures carry no code or subcode, only Meta's status text in message. On an Instagram publish whose outcome is uncertain (for example errorMessage starting "Instagram did not confirm"), carries the last media_publish error Meta returned, when it returned one; errorMessage stays the verdict, so do not read this block as a confirmed failure.
        - `code` integer: Meta error code, when Meta sent one.
        - `subcode` integer: Meta error_subcode, when Meta sent one.
        - `message` string, required: The platform's raw error message or media processing status text.
    - `scheduledFor` string, date-time
    - `timezone` string
    - `status` 'draft' | 'pending_approval' | 'scheduled' | 'publishing' | 'published' | 'partial' | 'failed' | 'cancelled': `cancelled` is set by POST /v1/posts/{postId}/unpublish once every platform entry has been removed from its platform (a post with published entries left becomes `partial`); cancelled posts can be edited and rescheduled like drafts. `pending_approval` (set by `requiresApproval: true`) never publishes until POST /v1/posts/{postId}/review approves it.
    - `tags` string[]: YouTube constraints: each tag max 100 chars, combined max 500 chars, duplicates removed.
    - `hashtags` string[]: Stored for reference only. Hashtags are NOT automatically appended to the caption when publishing. Include hashtags directly in the content field (platforms like Instagram only support hashtags as caption text). For YouTube keywords, use the tags field instead.
    - `mentions` string[]: Stored for reference only. This field does NOT automatically create @mentions when publishing. For LinkedIn @mentions, use the /v1/accounts/{accountId}/linkedin-mentions endpoint to resolve profile URLs to URNs, then embed the returned mentionFormat directly in the post content field.
    - `visibility` 'public' | 'private' | 'unlisted'
    - `metadata` object
    - `recycling` RecyclingState: Current recycling configuration and state on a post
      - `enabled` boolean: Whether recycling is currently active
      - `gap` integer: Number of interval units between reposts
      - `gapFreq` 'week' | 'month': Interval unit (week or month)
      - `startDate` string, date-time
      - `expireCount` integer
      - `expireDate` string, date-time
      - `contentVariations` string[]: Content variations for recycled copies (if configured)
      - `contentVariationIndex` integer: Current position in the content variations rotation (read-only)
      - `recycleCount` integer: How many recycled copies have been created so far (read-only)
      - `nextRecycleAt` string, date-time: When the next recycled copy will be created (read-only)
      - `lastRecycledAt` string, date-time: When the last recycled copy was created (read-only)
    - `recycledFromPostId` string: ID of the original post if this post was created via recycling
    - `queuedFromProfile` string: Profile ID if the post was scheduled via the queue
    - `queueId` string: Queue ID if the post was scheduled via a specific queue
    - `createdAt` string, date-time
    - `updatedAt` string, date-time: Last change to the post or to any of its platforms (a publish, a failure, a resolved URL).

## Other responses

- `400`: Invalid request
- `401`: Missing or invalid API key. `code` is `missing_credentials` when no Authorization header was sent and `invalid_credentials` when the key is unknown, revoked or expired.
- `403`: Forbidden
- `404`: Resource not found
- `409`: The post is not pending_approval (code: invalid_resource_state), for example because it was already approved or rejected.

## Changes

- **2026-10-22** `592c016c2d6d`: 1 info
  - endpoint added

[Operation history](https://skmtc.dev/zernio/apis/zernio-api/changes/v1/posts/:postId/review/post.md)

---

[API](https://skmtc.dev/zernio/apis/zernio-api.md) · [All operations](https://skmtc.dev/zernio/apis/zernio-api/llms.txt) · [OpenAPI document](https://skmtc.dev/zernio/apis/zernio-api/revisions/4e74db179e73?raw)
