# Webhooks: the post published webhook, signed and delivered
URL: https://skedcast.com/developers/webhooks

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.

## 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[].

## Register an endpoint. The signing secret is returned ONCE — store it from this response.
```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"]
  }'
```

## 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.

## A media.ready delivery body.
```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" }
}
```

## Naming, so you can guess an event before you look it up
object.verb, dotted, lowercase. Past tense for an outcome (published, failed, deleted); the state's name for a state you should be able to see (target.on_hold, with its holdReason); a predictive form for a warning that precedes the outcome (account.token_expiring, then account.reauth_required). A sub-resource nests under its parent (media.variant.ready). Every branch that can end badly has its own failure event — silence is never a state.
post.* is the POST, target.* is one DESTINATION. post.published fires once when every destination of a post that went out published (post.failed when every one failed, post.partially_published for a mix, post.missed when none went out and at least one was missed); target.published, target.failed and target.missed fire for each destination as it settles, and target.on_hold when it starts to wait — also for a hold only you can lift (plan_limit: change the plan; channel_needs_reconnect: reconnect the channel). Before 2026-10-27 post.published and post.failed fired per destination; they were made post-level when every deprecated field was removed (ADR-0320).

## Posts
Event | Fires when | `data` fields
`post.scheduled` | A compose committed and fanned out to one or more destinations. | `postId`, `status`, `targetCount`, `platforms`
`post.published` | Every 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.failed` | No 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_published` | The 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.missed` | No 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.canceled` | Every destination of a post ended canceled, so nothing of it will publish. At most once per post. | `postId`, `canceledCount`, `totalTargets`, `reason`, `canceledAt`
`post.revised` | An 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.recycled` | An 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)
Event | Fires when | `data` fields
`target.on_hold` | A 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.published` | A destination published successfully. | `postTargetId`, `platform`, `accountLabel`, `externalId`, `publishedAt`
`target.failed` | A 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.missed` | A 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.canceled` | A pending destination was canceled — by a member, a bulk action, or because its account was disconnected. | `postTargetId`, `postId`, `platform`, `reason`, `canceledAt`
`target.rescheduled` | A pending destination was moved to a new instant. | `postTargetId`, `postId`, `platform`, `scheduledAt`, `rescheduledAt`

## Media
Event | Fires when | `data` fields
`media.ready` | An asset finished scanning and transcoding and is publishable. | `assetId`, `status`
`media.failed` | An asset could not be processed (unsupported, or un-encodable within budget) and will not be retried. | `assetId`, `status`, `reason`
`media.quarantined` | An 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.deleted` | An asset was moved to the recycle bin (soft-deleted); it purges after the plan window. | `assetId`, `purgeDueAt`
`media.variant.ready` | A per-platform rendition of an asset is ready (at ingest, or from a publish-time fill). | `assetId`, `platform`, `status`
`media.variant.failed` | A 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
Event | Fires when | `data` fields
`account.connected` | A channel was connected for the first time. | `accountId`, `platform`, `clientId`, `connectedVia`, `handle`
`account.reconnected` | An existing channel was re-authorized (its credential refreshed or reclaimed). | `accountId`, `platform`, `clientId`, `connectedVia`, `handle`
`account.disconnected` | A 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_expiring` | Credentials on one or more channels are about to expire — one aggregated event per workspace per tick. | `count`, `accounts[{accountId, platform}]`
`account.reauth_required` | A channel's credential is proven dead and publishing to it is paused until a person reconnects it. | `accountId`, `platform`, `clientId`, `source`, `reason`

## Approvals
Event | Fires when | `data` fields
`approval.requested` | A post was submitted for review and is waiting on a reviewer. | `postId`, `status`
`approval.decided` | A reviewer approved or rejected a post. | `postId`, `status`, `releasedTargets`
`approval.withdrawn` | A pending review request was withdrawn — the author un-requested it, or its channel was disconnected. | `approvalId`, `postId`, `reason`, `withdrawnAt`

## Analytics
Event | Fires when | `data` fields
`analytics.updated` | An account's analytics pull finished and its stored metrics moved — read them back now instead of polling. | `socialAccountId`, `platform`, `postsUpdated`, `daysUpdated`, `fetchedAt`

## Reports
Event | Fires when | `data` fields
`report.completed` | A report run rendered and its export is ready to download. | `reportId`, `runId`, `status`
`report.failed` | A report run failed to render. | `reportId`, `runId`, `status`, `error`

## Clients
Event | Fires when | `data` fields
`client.created` | A client (workspace customer) was created. | `clientId`, `name`, `type`
`client.updated` | A client's settings changed. | `clientId`, `changed`
`client.deleted` | A client was archived (soft-deleted). | `clientId`

## Campaigns
Event | Fires when | `data` fields
`campaign.created` | A campaign was created. | `campaignId`, `name`, `status`, `clientId`
`campaign.updated` | A campaign's fields changed. | `campaignId`, `changed`
`campaign.deleted` | A campaign was deleted; its posts survive un-grouped. | `campaignId`
`campaign.shifted` | A campaign's pending posts were rescheduled together by a fixed offset. | `campaignId`, `shiftMinutes`, `accepted`, `skipped`

## Team
Event | Fires when | `data` fields
`member.invited` | A person was invited to the workspace. | `inviteId`, `email`, `role`, `expiresAt`
`member.joined` | An invitee accepted and became a member. | `inviteId`, `memberId`, `role`
`member.removed` | A member was removed from the workspace. | `memberUserId`

## Link in Bio
Event | Fires when | `data` fields
`link_in_bio.signup` | A 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.

## FAQ
**Q: Do I need a public endpoint?**
A: 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.

**Q: Why did I get the same event twice?**
A: 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.

**Q: Which event tells me a video is ready to publish?**
A: 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".
