Webhooks

Subscribe to ShortFast events, verify signed deliveries, and handle every event type with example payloads.

Webhooks push events to your server as they happen: a generation finishing, an mp4 being ready, a clip going live, an automation stopping. Register an endpoint, pick the events it should receive, and verify the signature on every delivery. They are the alternative to polling GET /videos/{id} for minutes at a time.

Register an endpoint

Register endpoints under Settings > API & Webhooks in the ShortFast app, or with Register a webhook endpoint:

curl -X POST https://app.shortfast.com/api/v1/webhooks \
  -H "Authorization: Bearer sf_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/shortfast",
    "events": ["video.render_completed", "video.published", "automation.stopped"]
  }'
{
  "data": {
    "id": "wh8Kd2mR5tQ9vN3xz",
    "url": "https://hooks.example.com/shortfast",
    "events": ["video.render_completed", "video.published", "automation.stopped"],
    "enabled": true,
    "createdAt": "2026-08-06T11:00:00.000Z",
    "updatedAt": "2026-08-06T11:00:00.000Z",
    "lastDeliveryAt": null,
    "failureCount": 0,
    "disabledReason": null,
    "secret": "whsec_3nQv8Kd2mR5tQ9vN3xzLp7Yc1Wb4Ae6F"
  }
}

This is the only response that carries secret. Store it now: every later read omits it, and you need it to verify the X-ShortFast-Signature header. There is no rotation endpoint, so to change a secret you create a new endpoint and delete the old one.

Duplicated event ids are collapsed; an unknown one is refused with warning-invalid-events listing the valid ids. A workspace can hold at most 25 endpoints, and a 26th is a 409 limit-reached.

Manage endpoints with Update a webhook endpoint (any subset of url, events, enabled), Delete a webhook endpoint and Send a test delivery. Sending events replaces the whole list rather than adding to it, and re-enabling a disabled endpoint clears its failure count and its disabledReason, giving it a clean slate.

Delivery

Every delivery is an HTTP POST with a JSON body. All payloads share one envelope:

{
  "event": "video.render_completed",
  "workspaceId": "k9WmT3xR2pQ7nV4bd",
  "createdAt": "2026-08-06T09:21:10.000Z",
  "data": { }
}

createdAt is when the event was queued, not when this attempt was sent. Each request carries these headers:

HeaderMeaning
X-ShortFast-EventThe event type (same as event in the body).
X-ShortFast-DeliveryUnique delivery id. Delivery is at-least-once, so deduplicate on it.
X-ShortFast-SignatureHMAC-SHA256 hex of the raw request body, keyed by the endpoint's signing secret.

Respond with any 2xx status within 8 seconds to acknowledge. Acknowledge first and process afterwards: slow handling reads as a failure and earns a retry.

Verify signatures

Recompute the HMAC over the raw body, before any JSON parsing, and compare with crypto.timingSafeEqual. A body that has been parsed and re-serialized will not match, so mount a raw body parser on the route:

const crypto = require('crypto');

app.post('/hooks/shortfast', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.get('X-ShortFast-Signature') || '';
  const expected = crypto
    .createHmac('sha256', process.env.SHORTFAST_WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');

  const valid =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'));
  if (!valid) return res.status(401).end();

  const payload = JSON.parse(req.body);
  const deliveryId = req.get('X-ShortFast-Delivery'); // dedupe on this

  res.status(200).end(); // ack fast, process async
});

Send a test delivery fires a one-off signed payload at the endpoint straight away, so you can check the URL and your verification before wiring up real events. It is synchronous: the response reports the status your endpoint returned. Its body carries event: "webhook.test", it is not a subscribable event, it carries no X-ShortFast-Delivery header, and it creates no row in the delivery log.

Event catalog

These are the event ids you can pass in events when creating or updating an endpoint:

EventFires when
video.createdA new video, presentation or automation blueprint is created, from the API, the app or an automation run.
video.generation_completedScript, voiceover, captions and visuals are ready. The video can now be rendered.
video.generation_failedGeneration stopped before finishing.
video.render_completedThe mp4 is ready.
video.render_failedThe render stopped or timed out.
video.scheduledAn automation clip is given a publishing slot.
video.publishedA video goes live on the connected social accounts.
video.publish_failedPublishing failed.
automation.clip_createdAn automation generates a new clip to publish.
automation.blockedAn automation is missing something it needs to run and will stop if it is not fixed.
automation.stoppedAn automation is stopped automatically.

The examples below show only the data object; the envelope is always the same. video is the same shape Get a video returns without its content arrays, and automation the same shape Get an automation returns. Both are trimmed in the examples.

video.created

{
  "video": {
    "id": "7bQxL2mF9dR4tK1sv",
    "title": "Why founders burn out",
    "type": "faceless",
    "subType": "generic",
    "isAutomation": false,
    "isClip": false,
    "parentVideoId": null,
    "process": { "status": "QUEUED", "progress": 0, "statusText": null, "error": null },
    "render": null,
    "publish": null
  }
}

video.generation_completed

The script, voiceover, captions and visuals are ready, so this is the signal to queue a render (unless you passed process.render: true at creation, which chains it for you).

{
  "video": {
    "id": "7bQxL2mF9dR4tK1sv",
    "title": "Why founders burn out",
    "type": "faceless",
    "durationSeconds": 41.6,
    "media": { "audioUrl": "https://cdn.shortfast.com/uploads/audio/7bQxL2mF9dR4tK1sv.mp3", "renderUrl": null },
    "process": { "status": "COMPLETED", "progress": 100, "statusText": "Finishing up", "error": null },
    "render": null
  }
}

video.generation_failed

error.code says why and error.blocked distinguishes an expected limit from a real failure. See Credits and limits for the full code table.

{
  "video": { "id": "7bQxL2mF9dR4tK1sv", "title": "Why founders burn out" },
  "error": {
    "code": "warning-no-credits",
    "message": "No video credits available",
    "blocked": true,
    "action": "credits"
  }
}

video.render_completed

video.media.renderUrl carries the mp4. For type: ai prefer GET /videos/{id}/download, which re-stitches on demand and always returns a URL that resolves.

{
  "video": {
    "id": "7bQxL2mF9dR4tK1sv",
    "title": "Why founders burn out",
    "durationSeconds": 41.6,
    "media": {
      "thumbnailUrl": "https://cdn.shortfast.com/uploads/thumbs/7bQxL2mF9dR4tK1sv.jpg",
      "renderUrl": "https://cdn.shortfast.com/uploads/renders/7bQxL2mF9dR4tK1sv.mp4",
      "bytes": 18446231
    },
    "render": { "status": "COMPLETED", "progress": 100, "error": null }
  }
}

video.render_failed

{
  "video": { "id": "7bQxL2mF9dR4tK1sv", "title": "Why founders burn out" },
  "error": {
    "code": "warning-plan-render-time",
    "message": "Max render time of your plan exceeded, you can only render videos up to 720 seconds",
    "blocked": true,
    "action": "upgrade"
  }
}

video.scheduled

An automation clip was given a publishing slot. scheduledAt is when it will go out and platforms where.

{
  "video": { "id": "cLp3Xw8RmQz5Yp3Ns", "title": "One productivity tip for founders", "isClip": true, "parentVideoId": "aQ8vN2mK5tR9wL3xc" },
  "scheduledAt": "2026-08-06T18:00:00.000Z",
  "platforms": ["tiktok", "youtube"]
}

video.published

platforms lists the ones the video actually reached. A platform that was requested but had no connected account is reported on the video's own publish.missingPlatforms, not here.

{
  "video": {
    "id": "7bQxL2mF9dR4tK1sv",
    "title": "Why founders burn out",
    "publish": {
      "status": "COMPLETED",
      "publishedAt": "2026-08-06T09:40:31.000Z",
      "platforms": ["tiktok"],
      "missingPlatforms": ["youtube"],
      "caption": "Burnout is not a workload problem. #founders",
      "error": null
    }
  },
  "platforms": ["tiktok"]
}

video.publish_failed

error.code distinguishes an expired account (warning-publish-auth), no connected accounts (warning-publish-no-accounts) and a missing configuration (warning-publish-not-configured).

{
  "video": { "id": "7bQxL2mF9dR4tK1sv", "title": "Why founders burn out" },
  "error": {
    "code": "warning-publish-auth",
    "message": "Your social account needs to be reconnected before publishing.",
    "blocked": true,
    "action": "connect"
  }
}

automation.clip_created

automation is the blueprint and video the clip it just produced.

{
  "automation": {
    "id": "aQ8vN2mK5tR9wL3xc",
    "title": "One productivity tip for founders",
    "status": "active",
    "publishing": { "profileId": "pR4kM8xT2vQ6nL9wz", "platforms": ["tiktok", "youtube"] },
    "autoRefill": true,
    "lastRunAt": "2026-08-06T09:00:04.000Z"
  },
  "video": {
    "id": "cLp3Xw8RmQz5Yp3Ns",
    "title": "One productivity tip for founders",
    "isClip": true,
    "parentVideoId": "aQ8vN2mK5tR9wL3xc",
    "process": { "status": "QUEUED", "progress": 0, "error": null }
  }
}

automation.blocked

The automation is missing something it needs to run and will stop if it is not fixed. blockers is the setup checklist still outstanding, the same list GET /automations/{id}/health returns.

{
  "automation": {
    "id": "aQ8vN2mK5tR9wL3xc",
    "title": "One productivity tip for founders",
    "status": "active",
    "health": { "ready": false, "blockers": [{ "id": "no-platforms", "label": "Select at least one platform" }], "nextScheduledAt": null }
  },
  "blockers": [{ "id": "no-platforms", "label": "Select at least one platform" }]
}

automation.stopped

reason says why and kind whether it is a configuration problem you must fix (config) or a run of failures that may clear on its own (transient). See Automations for the full reason table.

{
  "automation": {
    "id": "aQ8vN2mK5tR9wL3xc",
    "title": "One productivity tip for founders",
    "status": "paused",
    "stopped": {
      "reason": "publish_no_accounts",
      "kind": "config",
      "label": "No account connected",
      "detail": "None of the selected platforms has a connected account. Connect one in Settings, then resume."
    }
  },
  "reason": "publish_no_accounts",
  "kind": "config"
}

Retries and deduplication

Delivery is at-least-once. The same event can arrive twice, so deduplicate on the delivery id in X-ShortFast-Delivery and make your handler idempotent.

A non-2xx response or a timeout is retried with exponential backoff (4s, 16s, 64s, about 4 minutes, about 17 minutes) for up to 6 attempts, after which the delivery is marked failed. After 25 consecutive failed attempts the endpoint is auto-disabled and stops receiving new deliveries, with disabledReason saying why. Any successful delivery resets the streak. Once your endpoint is fixed, re-enable it with PATCH /webhooks/{id} and {"enabled": true}.

An endpoint that is deleted or disabled while a delivery is still queued simply drops that delivery.

Delivery log and debugging

Every delivery attempt is recorded, with the exact payload that was POSTed:

  • List webhook deliveries: the log across every endpoint in the workspace, newest first, filterable by event, status (pending, delivered, failed) and webhookId. An unknown filter value is ignored rather than rejected, so the log stays forgiving.
  • Replay a past delivery: re-queues a stored payload to the endpoint's current URL, in case it was corrected since. It gets a new delivery id, so a consumer that dedupes will re-process it, which is the point. The reply is the queued row (status: "pending", deliveredAt: null); poll the log for the outcome.

Each row carries attempts, responseStatus, lastError and nextAttemptAt, which is usually enough to tell a bad URL from a slow handler.

Delivery rows are dropped 7 days after creation. If you need a permanent record, poll the log or persist events on receipt.

Reference

On this page