Skip to content

Guides

Webhooks: every event, signed and delivered

Everything that happens to your posts, media, channels and team is an outbound event you can receive at your own endpoint — 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 instead of poll#

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.partially_published, 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.publishedA destination published (per target — kept for compatibility; identical to target.published).postTargetId, platform, accountLabel, externalId, publishedAt
post.failedA destination failed terminally (per target — kept for compatibility; identical to target.failed).postTargetId, platform, accountLabel, reason, category, failedAt
post.partially_publishedThe LAST destination of a post settled and the outcomes were mixed (at least one published, at least one failed). At most once per post.postId, publishedCount, failedCount, canceledCount, totalTargets, partiallyPublishedAt
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.preparingA destination is parked waiting for its media to finish preparing; it retries automatically.postTargetId, platform, accountLabel, status, statusReason, retryAfterMs, preparingAt
target.rate_limitedA destination was spaced out to protect the account (or by quota) and was rescheduled automatically.postTargetId, platform, accountLabel, status, retryAfterMs, quotaReason, rateLimitedAt
target.publishedA destination published successfully.postTargetId, platform, accountLabel, externalId, publishedAt
target.failedA destination failed terminally (including retry exhaustion).postTargetId, platform, accountLabel, reason, category, failedAt
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 refused as unsafe (malware, spoofed type) and will never be served.assetId, status, reason
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

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.