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:
| Header | Meaning |
|---|---|
X-ShortFast-Event | The event type (same as event in the body). |
X-ShortFast-Delivery | Unique delivery id. Delivery is at-least-once, so deduplicate on it. |
X-ShortFast-Signature | HMAC-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:
| Event | Fires when |
|---|---|
video.created | A new video, presentation or automation blueprint is created, from the API, the app or an automation run. |
video.generation_completed | Script, voiceover, captions and visuals are ready. The video can now be rendered. |
video.generation_failed | Generation stopped before finishing. |
video.render_completed | The mp4 is ready. |
video.render_failed | The render stopped or timed out. |
video.scheduled | An automation clip is given a publishing slot. |
video.published | A video goes live on the connected social accounts. |
video.publish_failed | Publishing failed. |
automation.clip_created | An automation generates a new clip to publish. |
automation.blocked | An automation is missing something it needs to run and will stop if it is not fixed. |
automation.stopped | An 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) andwebhookId. 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.