Skip to content
Reading with an AI assistant?View as MarkdownOpen in ClaudeOpen in ChatGPT

Guides

Webhooks: the post published webhook, signed and delivered

The post published webhook fires once a scheduled post has gone live everywhere it was going, and target.published the instant each destination goes live — along with every other event on your posts, media, channels and team, all signed, retried, and de-duplicated. This page is the complete catalog, rendered from the same registry the API validates against, so it cannot be out of date.

Browse docs

8 min read

Why subscribe to the post published webhook, not poll for it#

Every state change fires an event at the instant it commits — the same code path that writes your Activity Log. A poll sees the result later, costs a request per tick, and has to know which field to wait on; a subscription is told. The register response for an upload even names the event to wait for (readyEvent: "media.ready").

Polling still works everywhere and is the right choice when your integration cannot accept inbound HTTP (a laptop script, a locked-down CI runner). If you poll media, wait on publishable: true, not on a platform appearing in variants[].

bash
curl -X POST https://api.skedcast.com/v1/webhooks \
  -H "Authorization: Bearer sked_live_YOUR_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/skedcast/events",
    "events": ["media.ready", "media.failed", "target.published", "target.failed", "account.reauth_required"]
  }'
Register an endpoint. The signing secret is returned ONCE — store it from this response.

What a delivery looks like#

Every delivery is a POST with a JSON envelope { id, type, created, data } and two independent signature schemes: the Standard Webhooks headers (webhook-id, webhook-timestamp, webhook-signature — verify with any off-the-shelf library) and SkedCast's own X-SkedCast-Signature: t=<unix-seconds>,v1=<hex> (HMAC-SHA256 over "<t>.<rawBody>"). Verify one; both are always present.

Delivery is at-least-once with exponential-backoff retries; X-SkedCast-Attempt counts them. Dedupe on the payload id — it is stable across retries. Events that can be observed twice on our side (a replayed job, two workers seeing one transition) are minted at-most-once for the fact itself: post.published, post.failed, post.partially_published, post.missed, post.canceled, media.failed, media.quarantined, media.variant.*, account.reauth_required.

json
{
  "id": "5f0c2d4e-3b1a-4c8e-9d7f-0a1b2c3d4e5f",
  "type": "media.ready",
  "created": "2026-09-02T19:39:37.129Z",
  "data": { "assetId": "eb1882be-1686-4199-a169-1d6cece2eaf2", "status": "transcoded" }
}
A media.ready delivery body.

Posts#

EventFires whendata fields
post.scheduledA compose committed and fanned out to one or more destinations.postId, status, targetCount, platforms
post.publishedEvery destination of a post that went out published (any others were canceled). At most once per post; each destination is also reported by target.published.postId, publishedCount, canceledCount, totalTargets, publishedAt
post.failedNo destination of a post published and at least one failed (others may have been missed or canceled). At most once per post; each destination is also reported by target.failed or target.missed.postId, failedCount, missedCount, canceledCount, totalTargets, failedAt
post.partially_publishedThe LAST destination of a post settled and the outcomes were mixed (at least one published, at least one failed or missed). At most once per post.postId, publishedCount, failedCount, missedCount, canceledCount, totalTargets, partiallyPublishedAt
post.missedNo destination of a post published or failed, and at least one was missed: its time passed more than 6 hours while it waited (others may have been canceled). At most once per post; each destination is also reported by target.missed.postId, missedCount, canceledCount, totalTargets, missedAt
post.canceledEvery destination of a post ended canceled, so nothing of it will publish. At most once per post.postId, canceledCount, totalTargets, reason, canceledAt
post.revisedAn already-scheduled post had its content edited in place. reGated is true when the edit returned an approved post to review, in which case nothing publishes until it is approved again.postId, changed, reGated, destinationsTouched, destinationsAdded, destinationsRemoved, revisedAt
post.recycledAn evergreen post was recycled: a fresh copy was created and scheduled to publish again. postId is the new post; sourcePostId is the evergreen original.postId, sourcePostId, targetsCreated, scheduledAt

Destinations (one per connected account)#

EventFires whendata fields
target.on_holdA destination is on hold; holdReason says why and requiredAction says what you must do, or is null when nothing is needed. Most holds (a platform limit, spacing to protect the account, a quota, its media still preparing) go out automatically (requiredAction null); plan_limit (change_plan), channel_needs_reconnect (reconnect_channel) and channel_paused (contact_support) wait for you.postTargetId, platform, accountLabel, status, holdReason, requiredAction, retryAfterMs, quotaReason, heldAt
target.publishedA destination published successfully.postTargetId, platform, accountLabel, externalId, publishedAt
target.failedA destination failed terminally (including retry exhaustion). failureReason says why it failed (rejected, content_rule, retries_exhausted, internal_error or not_delivered) and requiredAction is retry: retry it, or edit it and retry. When the send may already have reached the platform and its result was lost, failureReason is null, outcomeReason is delivery_unconfirmed and requiredAction is check_platform: the post may already be live, so check the account before sending it again (a retry is refused).postTargetId, platform, accountLabel, reason, category, failureReason, outcomeReason, requiredAction, failedAt
target.missedA destination was not sent: its time passed more than 6 hours while it waited (on hold, or a publishing backlog). outcomeReason says why; requiredAction is always reschedule: reschedule it to send it.postTargetId, postId, platform, accountLabel, outcomeReason, requiredAction, scheduledAt, missedAt
target.canceledA pending destination was canceled — by a member, a bulk action, or because its account was disconnected.postTargetId, postId, platform, reason, canceledAt
target.rescheduledA pending destination was moved to a new instant.postTargetId, postId, platform, scheduledAt, rescheduledAt

Media#

EventFires whendata fields
media.readyAn asset finished scanning and transcoding and is publishable.assetId, status
media.failedAn asset could not be processed (unsupported, or un-encodable within budget) and will not be retried.assetId, status, reason
media.quarantinedAn asset was quarantined and will not be served. quarantineReason says why: malware_detected (a security finding), unsupported_type, scan_too_large, image_too_large, scanner_unavailable or unknown; only malware_detected is a finding about the file.assetId, status, reason, quarantineReason
media.deletedAn asset was moved to the recycle bin (soft-deleted); it purges after the plan window.assetId, purgeDueAt
media.variant.readyA per-platform rendition of an asset is ready (at ingest, or from a publish-time fill).assetId, platform, status
media.variant.failedA per-platform rendition could not be produced (for example, the source exceeds that platform's duration cap). The asset may still publish elsewhere.assetId, platform, status

Connected accounts#

EventFires whendata fields
account.connectedA channel was connected for the first time.accountId, platform, clientId, connectedVia, handle
account.reconnectedAn existing channel was re-authorized (its credential refreshed or reclaimed).accountId, platform, clientId, connectedVia, handle
account.disconnectedA channel was disconnected — by a member, a whole-login disconnect, an operator, or a platform data-deletion request.accountId, platform, clientId, reason, disconnectedAt
account.token_expiringCredentials on one or more channels are about to expire — one aggregated event per workspace per tick.count, accounts[{accountId, platform}]
account.reauth_requiredA channel's credential is proven dead and publishing to it is paused until a person reconnects it.accountId, platform, clientId, source, reason

Approvals#

EventFires whendata fields
approval.requestedA post was submitted for review and is waiting on a reviewer.postId, status
approval.decidedA reviewer approved or rejected a post.postId, status, releasedTargets
approval.withdrawnA pending review request was withdrawn — the author un-requested it, or its channel was disconnected.approvalId, postId, reason, withdrawnAt

Analytics#

EventFires whendata fields
analytics.updatedAn account's analytics pull finished and its stored metrics moved — read them back now instead of polling.socialAccountId, platform, postsUpdated, daysUpdated, fetchedAt

Reports#

EventFires whendata fields
report.completedA report run rendered and its export is ready to download.reportId, runId, status
report.failedA report run failed to render.reportId, runId, status, error

Clients#

EventFires whendata fields
client.createdA client (workspace customer) was created.clientId, name, type
client.updatedA client's settings changed.clientId, changed
client.deletedA client was archived (soft-deleted).clientId

Campaigns#

EventFires whendata fields
campaign.createdA campaign was created.campaignId, name, status, clientId
campaign.updatedA campaign's fields changed.campaignId, changed
campaign.deletedA campaign was deleted; its posts survive un-grouped.campaignId
campaign.shiftedA campaign's pending posts were rescheduled together by a fixed offset.campaignId, shiftMinutes, accepted, skipped

Team#

EventFires whendata fields
member.invitedA person was invited to the workspace.inviteId, email, role, expiresAt
member.joinedAn invitee accepted and became a member.inviteId, memberId, role
member.removedA member was removed from the workspace.memberUserId
EventFires whendata fields
link_in_bio.signupA visitor joined a link-in-bio page’s list: an email sign-up once they confirm it (verified), a phone sign-up when it is made (recorded consent, not a verified number).pageId, slug, signupId, kind, email, phone, name, verified, consentedAt, confirmedAt

Delivery pacing#

Deliveries are paced so a burst — a bulk schedule that emits dozens of events at once — cannot overwhelm your server or crowd out other workspaces. An endpoint is sent at most 300 events a minute, and a workspace at most 1200 a minute across all of its endpoints.

An event over the limit is not dropped and not counted as a failed attempt: it is held and delivered in a following minute, so it arrives a little later than it otherwise would. Deliveries can arrive out of order (retries do the same), so order events by the created timestamp in the payload. Only if the limit stays exceeded for over an hour is a held event given up on, and it then shows as failed in GET /webhooks/:id/deliveries with the reason. A test delivery is never held.

Secret rotation without a gap#

POST /webhooks/:id/rotate-secret returns the new secret in full (once). The previous secret keeps being signed with for 24 hours, and during that window both signature headers carry one entry per valid secret — a receiver is correct if ANY entry matches. Rotate, deploy the new secret at your leisure, and no event is dropped in between.

webhookseventssigningretries

FAQ

Do I need a public endpoint?
For webhooks, yes — an HTTPS URL we can POST to. If you cannot expose one, poll instead: GET /media/:id (wait on publishable), GET /post-targets (per-destination status), GET /accounts (health). Everything a webhook says is also readable.
Why did I get the same event twice?
Delivery is at-least-once: if your endpoint processed an event but we never saw the 2xx (a timeout, a dropped connection), we retry. The payload id is stable across retries — dedupe on it. Facts that can be observed twice on our side are already minted at-most-once, so a duplicate is always a retry, never a second occurrence.
Which event tells me a video is ready to publish?
media.ready. The register response names it as readyEvent so you never have to look it up. If a per-platform rendition is produced later (at ingest or during a publish-time fill) you also get media.variant.ready or media.variant.failed for that platform; the asset-level media.ready is the one that means "you can compose against this now".

Ready to broadcast everywhere?

Sign up free — no credit card. You land on the Free plan, and you can start a one-time 7-day Studio trial from your workspace whenever you are ready. Connect your first accounts, import a batch, and watch one post fan out across every platform.