> ## Documentation Index
> Fetch the complete documentation index at: https://docs.skills.video/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive generation task lifecycle events via signed HTTP POST requests.

Webhooks let your backend react to generation task updates without polling. skills.video sends signed `POST` requests to your configured endpoint when a generation task is created, starts running, completes, fails, or is canceled.

Webhook delivery is **at least once**. Your handler must verify the signature, reject replayed requests, and process events idempotently.

## Configure Endpoints

Configure webhook endpoints from the developer dashboard:

[https://skills.video/dashboard/developer](https://skills.video/dashboard/developer)

Use the dashboard to manage endpoint URLs, event filters, enablement, test deliveries, and secret rotation. Store the signing secret in server-side secret storage when it is shown. Do not place the webhook secret in frontend code, mobile apps, logs, or public repositories.

Each endpoint is scoped to a workspace and event filter. It only receives events for the selected workspace and selected task lifecycle events.

## When Webhooks Are Sent

Webhooks are emitted for generation tasks created through:

```http theme={null}
POST /api/v1/generation/:provider/:model+
```

Each webhook endpoint is scoped to a workspace. It only receives events for tasks in that workspace and only for event types enabled on that endpoint.

## Event Types

Supported event types:

| Event            | Meaning                                    |
| ---------------- | ------------------------------------------ |
| `task.created`   | A generation task was accepted and queued. |
| `task.started`   | The worker started processing the task.    |
| `task.completed` | The task finished successfully.            |
| `task.failed`    | The task failed.                           |
| `task.canceled`  | The task was canceled.                     |

Terminal events are `task.completed`, `task.failed`, and `task.canceled`. Once your local task record reaches a terminal state, do not let an older non-terminal event move it backward.

Webhook event names describe lifecycle transitions. `prediction.state` and `prediction.status` describe the current task state in the payload.

| Webhook event    | `prediction.state` | Terminal |
| ---------------- | ------------------ | -------- |
| `task.created`   | `queued`           | No       |
| `task.started`   | `running`          | No       |
| `task.completed` | `succeeded`        | Yes      |
| `task.failed`    | `failed`           | Yes      |
| `task.canceled`  | `canceled`         | Yes      |

Use the event name to decide which handler branch to run. Use `prediction.state` to update your local task record.

## Delivery Format

Your endpoint receives an HTTP `POST` request with a JSON body.

```http theme={null}
POST /webhooks/skills-video HTTP/1.1
Host: example.com
Content-Type: application/json
webhook-id: evt_2f8f5c2e1c9f4db19e7e4b3d8a1f7caa
webhook-timestamp: 1777370400
webhook-signature: v1,<base64_hmac_sha256>
X-Webhook-Signature: v1=<hex_hmac_sha256>
X-Webhook-Timestamp: 1777370400
X-Webhook-Event-Id: evt_2f8f5c2e1c9f4db19e7e4b3d8a1f7caa
X-Webhook-Event-Type: task.completed
```

Headers:

| Header                 | Description                                                                                         |
| ---------------------- | --------------------------------------------------------------------------------------------------- |
| `webhook-id`           | Standard Webhooks event id. Use this as your primary idempotency key.                               |
| `webhook-timestamp`    | Standard Webhooks Unix timestamp in seconds. Used for replay protection and signature verification. |
| `webhook-signature`    | Standard Webhooks signature in the format `v1,<base64>`. Recommended for new integrations.          |
| `X-Webhook-Signature`  | Legacy v1 HMAC signature in the format `v1=<hex>`. Kept for backward compatibility.                 |
| `X-Webhook-Timestamp`  | Legacy v1 timestamp. Same value as `webhook-timestamp`.                                             |
| `X-Webhook-Event-Id`   | Legacy v1 event id. Same value as `webhook-id`.                                                     |
| `X-Webhook-Event-Type` | Event type, such as `task.completed`.                                                               |
| `Content-Type`         | Always `application/json`.                                                                          |

Example body:

```json theme={null}
{
  "event": "task.completed",
  "prediction": {
    "id": "TASK_DOCUMENT_ID",
    "state": "succeeded",
    "status": "succeeded",
    "input": {
      "prompt": "sunrise over mountains in watercolor style"
    },
    "template": null,
    "error": null,
    "logs": null,
    "created_at": "2026-01-01T00:00:00.000Z",
    "started_at": "2026-01-01T00:00:01.000Z",
    "completed_at": "2026-01-01T00:00:10.000Z",
    "workspace": "WORKSPACE_DOCUMENT_ID",
    "owner": {
      "id": 1,
      "email": "user@example.com",
      "name": "User",
      "avatar": "https://example.com/avatar.png"
    },
    "artifacts": [
      {
        "documentId": "ARTIFACT_DOCUMENT_ID",
        "createdAt": "2026-01-01T00:00:00.000Z",
        "state": "succeeded",
        "type": "image",
        "provider": "google",
        "model": "nano-banana",
        "duration": null,
        "size": null,
        "visibility": "private",
        "tags": [],
        "prompt": "sunrise over mountains in watercolor style",
        "actual_prompt": "sunrise over mountains in watercolor style",
        "resolution": "1080p",
        "aspect_ratio": "9:16",
        "mode": "text-image",
        "like_count": 0,
        "url": "https://example-cdn.com/output.png",
        "asset": {
          "url": "https://example-cdn.com/output.png",
          "name": "output.png",
          "mime": "image/png",
          "width": 1080,
          "height": 1920,
          "formats": {}
        }
      }
    ],
    "credits": {
      "available": 3533,
      "usage": 4
    }
  }
}
```

For `task.failed`, `prediction.error` contains a sanitized error object with `code` and `message`. Raw provider errors and internal error details are not included. For `task.completed`, generated media is usually available in `prediction.artifacts` and, depending on the model, task output fields returned by the generation result endpoint.

Failed task example:

```json theme={null}
{
  "event": "task.failed",
  "prediction": {
    "id": "TASK_DOCUMENT_ID",
    "state": "failed",
    "status": "failed",
    "input": {
      "prompt": "Turn into a sea of flowers.",
      "duration": 7
    },
    "template": null,
    "error": {
      "code": "VIDEO_GENERATION_TIMEOUT",
      "message": "Generation timed out."
    },
    "logs": null,
    "created_at": "2026-01-01T00:00:00.000Z",
    "started_at": "2026-01-01T00:00:01.000Z",
    "completed_at": "2026-01-01T00:00:10.000Z",
    "workspace": "WORKSPACE_DOCUMENT_ID",
    "owner": null,
    "artifacts": [],
    "credits": {
      "available": 3533,
      "usage": 0
    }
  }
}
```

### Payload Field Reference

Common fields:

| Field                          | Type             | Description                                                                    |
| ------------------------------ | ---------------- | ------------------------------------------------------------------------------ |
| `event`                        | `string`         | Webhook event type, such as `task.completed`.                                  |
| `prediction.id`                | `string`         | Task document id. Use this to correlate with generation results.               |
| `prediction.state`             | `string`         | Current task state: `queued`, `running`, `succeeded`, `failed`, or `canceled`. |
| `prediction.status`            | `string`         | Same state value as `prediction.state`.                                        |
| `prediction.input`             | `object`         | Normalized model input that was used for the task.                             |
| `prediction.template`          | `object \| null` | Template metadata when the task was created from a template.                   |
| `prediction.error`             | `object \| null` | Sanitized error details for failed tasks, usually with `code` and `message`.   |
| `prediction.logs`              | `any \| null`    | Provider or worker logs when available.                                        |
| `prediction.created_at`        | `string \| null` | Task creation timestamp.                                                       |
| `prediction.started_at`        | `string \| null` | Worker start timestamp.                                                        |
| `prediction.completed_at`      | `string \| null` | Terminal timestamp for completed, failed, or canceled tasks.                   |
| `prediction.workspace`         | `string \| null` | Workspace document id.                                                         |
| `prediction.owner`             | `object \| null` | Basic owner profile for the task.                                              |
| `prediction.artifacts`         | `object[]`       | Generated assets associated with the task.                                     |
| `prediction.credits.available` | `number`         | Remaining available credits after the task update.                             |
| `prediction.credits.usage`     | `number`         | Credits used by the task.                                                      |

Artifact fields vary by model and media type, but commonly include `documentId`, `state`, `type`, `provider`, `model`, `prompt`, `actual_prompt`, `resolution`, `aspect_ratio`, `url`, and `asset`.

## Signature Algorithm

Each webhook endpoint has a secret. Store it on your server and use it to verify every incoming webhook before parsing or processing the JSON payload.

New integrations should use the Standard Webhooks v2 headers. Legacy v1 `X-Webhook-*` headers are still sent for backward compatibility with existing receivers.

### Standard Webhooks v2

skills.video sends Standard Webhooks-compatible headers:

```text theme={null}
webhook-id: <event_id>
webhook-timestamp: <unix_seconds>
webhook-signature: v1,<base64_hmac_sha256>
```

The v2 signature is computed from the event id, timestamp, and exact raw request body:

```text theme={null}
signed_payload = webhook_id + "." + webhook_timestamp + "." + raw_body
webhook-signature = "v1," + base64(hmac_sha256(base64_decode(webhook_secret_without_whsec_prefix), signed_payload))
```

Use the official verifier when possible:

```bash theme={null}
npm install standardwebhooks
```

Express example:

```ts theme={null}
import express from "express";
import { Webhook } from "standardwebhooks";

const app = express();
const webhook = new Webhook(process.env.SKILLS_VIDEO_WEBHOOK_SECRET!);

app.post(
  "/webhooks/skills-video",
  express.raw({ type: "application/json", limit: "2mb" }),
  async (req, res) => {
    const rawBody = req.body.toString("utf8");
    let event: unknown;

    try {
      event = webhook.verify(rawBody, {
        "webhook-id": req.header("webhook-id")!,
        "webhook-timestamp": req.header("webhook-timestamp")!,
        "webhook-signature": req.header("webhook-signature")!
      });
    } catch {
      return res.status(401).json({ error: "invalid_signature" });
    }

    const eventType =
      req.header("X-Webhook-Event-Type") ??
      (event as any).event ??
      (event as any).type;

    await handleWebhookEvent({
      eventId: req.header("webhook-id")!,
      eventType,
      payload: event
    });

    return res.status(204).send();
  }
);
```

The `standardwebhooks` verifier checks the timestamp tolerance and compares signatures safely. Your route still needs the raw body; do not mount a JSON parser before the webhook route.

### Java / Spring Boot Verification

This example verifies Standard Webhooks v2 with JDK crypto APIs. Keep the body as raw bytes and parse JSON only after verification succeeds.

```java theme={null}
package com.example.webhooks;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class SkillsVideoWebhookController {
  private static final long TOLERANCE_SECONDS = 300;
  private static final String WHSEC_PREFIX = "whsec_";

  private final ObjectMapper objectMapper;
  private final String webhookSecret;

  public SkillsVideoWebhookController(
      ObjectMapper objectMapper,
      @Value("${skills.video.webhook-secret}") String webhookSecret) {
    this.objectMapper = objectMapper;
    this.webhookSecret = webhookSecret;
  }

  @PostMapping("/webhooks/skills-video")
  public ResponseEntity<Void> receive(
      @RequestBody byte[] rawBody,
      @RequestHeader(value = "webhook-id", required = false) String webhookId,
      @RequestHeader(value = "webhook-timestamp", required = false) String timestamp,
      @RequestHeader(value = "webhook-signature", required = false) String signatureHeader,
      @RequestHeader(value = "X-Webhook-Event-Type", required = false) String eventType)
      throws Exception {
    boolean valid = verifyStandardWebhook(
        webhookSecret,
        webhookId,
        timestamp,
        signatureHeader,
        rawBody);

    if (!valid) {
      return ResponseEntity.status(401).build();
    }

    JsonNode event = objectMapper.readTree(rawBody);
    String resolvedEventType =
        eventType != null ? eventType : event.path("event").asText(null);

    recordDurableReceipt(webhookId, resolvedEventType, event);
    handleWebhookEvent(webhookId, resolvedEventType, event);

    return ResponseEntity.noContent().build();
  }

  private boolean verifyStandardWebhook(
      String secret,
      String webhookId,
      String timestamp,
      String signatureHeader,
      byte[] rawBody)
      throws Exception {
    if (secret == null || webhookId == null || timestamp == null || signatureHeader == null) {
      return false;
    }

    long requestTime;
    try {
      requestTime = Long.parseLong(timestamp);
    } catch (NumberFormatException ignored) {
      return false;
    }

    long now = Instant.now().getEpochSecond();
    if (Math.abs(now - requestTime) > TOLERANCE_SECONDS) {
      return false;
    }

    String receivedDigestBase64 = parseStandardSignature(signatureHeader);
    if (receivedDigestBase64 == null) {
      return false;
    }

    byte[] receivedDigest;
    try {
      receivedDigest = Base64.getDecoder().decode(receivedDigestBase64);
    } catch (IllegalArgumentException ignored) {
      return false;
    }

    byte[] key;
    try {
      key = decodeStandardSecret(secret);
    } catch (IllegalArgumentException ignored) {
      return false;
    }

    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(key, "HmacSHA256"));
    mac.update((webhookId + "." + timestamp + ".").getBytes(StandardCharsets.UTF_8));
    byte[] expectedDigest = mac.doFinal(rawBody);

    return MessageDigest.isEqual(expectedDigest, receivedDigest);
  }

  private String parseStandardSignature(String signatureHeader) {
    for (String part : signatureHeader.trim().split("\\s+")) {
      if (part.startsWith("v1,") && part.length() > 3) {
        return part.substring(3);
      }
    }
    return null;
  }

  private byte[] decodeStandardSecret(String secret) {
    if (!secret.startsWith(WHSEC_PREFIX)) {
      throw new IllegalArgumentException("Webhook secret must start with whsec_");
    }
    return Base64.getDecoder().decode(secret.substring(WHSEC_PREFIX.length()));
  }

  private void recordDurableReceipt(String eventId, String eventType, JsonNode event) {
    // Insert eventId into a table with a unique constraint before side effects.
  }

  private void handleWebhookEvent(String eventId, String eventType, JsonNode event) {
    // Run idempotent business logic after signature verification and durable receipt.
  }
}
```

### Legacy v1 Compatibility

The signature is computed from the timestamp and the exact raw request body:

```text theme={null}
signed_payload = timestamp + "." + raw_body
signature = "v1=" + hex(hmac_sha256(webhook_secret, signed_payload))
```

Important details:

* Use the value of `X-Webhook-Timestamp` exactly as received.
* Use the raw request body exactly as received. Do not verify against parsed and re-serialized JSON.
* Use HMAC-SHA256 with the endpoint secret as the HMAC key.
* Compare signatures with a constant-time comparison function.
* Reject requests whose timestamp is outside a short tolerance window, such as 5 minutes.
* Verify the signature before trusting `X-Webhook-Event-Id`, `X-Webhook-Event-Type`, or any body fields.

### Legacy v1 Node.js Verification

This example verifies the signature and timestamp. Mount the raw body parser before any JSON parser for the webhook route.

```ts theme={null}
import crypto from "node:crypto";
import express from "express";

const app = express();

function parseSignature(header: string | undefined) {
  if (!header) return null;
  const [version, digest] = header.split("=", 2);
  if (version !== "v1" || !digest || !/^[a-f0-9]{64}$/i.test(digest)) {
    return null;
  }
  return digest;
}

function timingSafeEqualHex(actualHex: string, expectedHex: string) {
  const actual = Buffer.from(actualHex, "hex");
  const expected = Buffer.from(expectedHex, "hex");
  return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
}

function verifyWebhookSignature(options: {
  secret: string;
  timestamp: string | undefined;
  signatureHeader: string | undefined;
  rawBody: string;
  toleranceSeconds?: number;
}) {
  const {
    secret,
    timestamp,
    signatureHeader,
    rawBody,
    toleranceSeconds = 300
  } = options;

  if (!timestamp) return false;

  const requestTime = Number(timestamp);
  if (!Number.isFinite(requestTime)) return false;

  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - requestTime) > toleranceSeconds) return false;

  const receivedDigest = parseSignature(signatureHeader);
  if (!receivedDigest) return false;

  const signedPayload = `${timestamp}.${rawBody}`;
  const expectedDigest = crypto
    .createHmac("sha256", secret)
    .update(signedPayload, "utf8")
    .digest("hex");

  return timingSafeEqualHex(expectedDigest, receivedDigest);
}

app.post(
  "/webhooks/skills-video",
  express.raw({ type: "application/json", limit: "2mb" }),
  async (req, res) => {
    const rawBody = req.body.toString("utf8");
    const valid = verifyWebhookSignature({
      secret: process.env.SKILLS_VIDEO_WEBHOOK_SECRET!,
      timestamp: req.header("X-Webhook-Timestamp"),
      signatureHeader: req.header("X-Webhook-Signature"),
      rawBody
    });

    if (!valid) {
      return res.status(401).json({ error: "invalid_signature" });
    }

    const event = JSON.parse(rawBody);
    await handleWebhookEvent({
      eventId: req.header("X-Webhook-Event-Id")!,
      eventType: req.header("X-Webhook-Event-Type")!,
      payload: event
    });

    return res.status(204).send();
  }
);
```

### Python Verification

```py theme={null}
import hashlib
import hmac
import re
import time

SIGNATURE_RE = re.compile(r"^v1=([a-fA-F0-9]{64})$")

def verify_webhook_signature(
    *,
    secret: str,
    timestamp: str | None,
    signature_header: str | None,
    raw_body: bytes,
    tolerance_seconds: int = 300,
) -> bool:
    if not timestamp or not signature_header:
        return False

    try:
        request_time = int(timestamp)
    except ValueError:
        return False

    if abs(int(time.time()) - request_time) > tolerance_seconds:
        return False

    match = SIGNATURE_RE.match(signature_header)
    if not match:
        return False

    received_digest = match.group(1)
    signed_payload = timestamp.encode("utf-8") + b"." + raw_body
    expected_digest = hmac.new(
        secret.encode("utf-8"),
        signed_payload,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected_digest, received_digest)
```

## Signature Test Vector

Use this fixed example to verify your implementation. The exact `raw_body` string matters.

```text theme={null}
secret = whsec_dGVzdF9zZWNyZXRfa2V5
webhook_id = evt_test_123
timestamp = 1777370400
raw_body = {"event":"webhook.test","data":{"message":"hello"}}
```

Standard Webhooks v2 signed payload:

```text theme={null}
evt_test_123.1777370400.{"event":"webhook.test","data":{"message":"hello"}}
```

Expected Standard Webhooks v2 signature:

```text theme={null}
webhook-signature: v1,TFcCC2CA8KYwWjkvbI+0XLo5fDzKZjBSlHtL1tbFaDE=
```

Legacy v1 is also sent for existing receivers. Its signed payload is `timestamp + "." + raw_body`, and the expected legacy signature for the same example is:

```text theme={null}
X-Webhook-Signature: v1=82e5a76a4cf5455093bf5dd082c73f7e1b8ad759f0eb742d2ce863358552d4b3
```

Node.js check:

```ts theme={null}
import crypto from "node:crypto";

const secret = "whsec_dGVzdF9zZWNyZXRfa2V5";
const webhookId = "evt_test_123";
const timestamp = "1777370400";
const rawBody = '{"event":"webhook.test","data":{"message":"hello"}}';

const standardKey = Buffer.from(secret.slice("whsec_".length), "base64");
const standardDigest = crypto
  .createHmac("sha256", standardKey)
  .update(`${webhookId}.${timestamp}.${rawBody}`, "utf8")
  .digest("base64");

console.log(`v1,${standardDigest}`);
```

Java check:

```java theme={null}
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class WebhookSignatureVector {
  public static void main(String[] args) throws Exception {
    String secret = "whsec_dGVzdF9zZWNyZXRfa2V5";
    String webhookId = "evt_test_123";
    String timestamp = "1777370400";
    String rawBody = "{\"event\":\"webhook.test\",\"data\":{\"message\":\"hello\"}}";

    byte[] key = Base64.getDecoder().decode(secret.substring("whsec_".length()));
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(key, "HmacSHA256"));
    byte[] digest = mac.doFinal(
        (webhookId + "." + timestamp + "." + rawBody).getBytes(StandardCharsets.UTF_8));

    System.out.println("v1," + Base64.getEncoder().encodeToString(digest));
  }
}
```

## Signature Troubleshooting

Most signature failures come from verifying different bytes than the bytes that were signed.

| Symptom                                                 | Likely cause                                                            | Fix                                                                                    |
| ------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| Signature works locally but fails in production         | Proxy, framework, or middleware changed the request body.               | Capture the raw body before JSON parsing and verify that exact string.                 |
| Signature fails after `JSON.parse` and `JSON.stringify` | Re-serialized JSON changed whitespace or field order.                   | Verify against raw body only. Parse JSON after signature verification.                 |
| Every request fails after secret rotation               | Receiver still uses the old secret or only one server was updated.      | Deploy the new secret everywhere. During rollout, support both old and new secrets.    |
| Requests fail intermittently with timestamp errors      | Server clock drift or too narrow tolerance.                             | Sync server time with NTP and use a tolerance such as 5 minutes.                       |
| Signature header looks malformed                        | Missing `v1,` prefix or invalid base64 digest for Standard Webhooks v2. | Prefer the `standardwebhooks` verifier and pass the raw body plus `webhook-*` headers. |
| Duplicate events trigger duplicate work                 | Event id is not stored before business logic runs.                      | Insert `webhook-id` with a unique constraint before side effects.                      |

Log enough metadata to debug failures, but never log the webhook secret, full API keys, or sensitive user input.

## Replay Protection

Signature verification proves the request was signed with the endpoint secret, but a valid signed request can still be replayed for a short period. Combine timestamp checks with event id deduplication.

Recommended checks:

1. Reject requests with missing or invalid signature headers.
2. Reject requests whose `webhook-timestamp` is outside your tolerance window, such as 5 minutes.
3. Insert `webhook-id` into a table with a unique constraint before doing any business work.
4. If the insert conflicts, treat the event as already received and return `2xx`.

Webhook deliveries do not include a separate nonce header. Use the timestamp to limit signature lifetime and use `webhook-id` as the replay and idempotency key. Legacy receivers can use `X-Webhook-Timestamp` and `X-Webhook-Event-Id`, which carry the same values.

Keep event ids for at least the full retry window. A 24-hour retention period is a practical minimum for most integrations.

## Idempotency

Webhook delivery uses an at-least-once model. Your endpoint must assume these cases can happen:

* The same event is delivered more than once.
* A delivery succeeds in your system, but the HTTP response is lost and skills.video retries.
* Events for the same task arrive out of order because older deliveries are retried later.
* A terminal event is processed before an older `task.created` or `task.started` retry.

Use two layers of idempotency:

| Layer                 | Key                      | Purpose                                                                                                           |
| --------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| Event receipt         | `webhook-id`             | Prevent processing the same webhook event twice.                                                                  |
| Business side effects | `prediction.id + action` | Prevent duplicate actions such as asset import, user notification, order fulfillment, or downstream job creation. |

### Recommended Tables

```sql theme={null}
CREATE TABLE skills_video_webhook_events (
  event_id TEXT PRIMARY KEY,
  event_type TEXT NOT NULL,
  task_id TEXT,
  received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  payload JSONB NOT NULL
);

CREATE TABLE skills_video_tasks (
  task_id TEXT PRIMARY KEY,
  state TEXT NOT NULL,
  last_event_id TEXT,
  last_event_type TEXT,
  last_event_received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  payload JSONB NOT NULL
);

CREATE TABLE skills_video_task_side_effects (
  task_id TEXT NOT NULL,
  action TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (task_id, action)
);
```

### Processing Flow

1. Read the raw request body.
2. Verify `webhook-signature` and `webhook-timestamp`.
3. Parse the JSON body.
4. Insert `webhook-id` into `skills_video_webhook_events`.
5. If the insert conflicts, return `204 No Content`.
6. Upsert your local task record by `prediction.id`.
7. Apply terminal-state protection so older non-terminal events do not overwrite a completed, failed, or canceled task.
8. Before executing any side effect, insert `(prediction.id, action)` into `skills_video_task_side_effects`.
9. Only run the side effect if that insert succeeds.
10. Return a `2xx` response quickly.

Terminal-state protection example:

```ts theme={null}
const terminalStates = new Set(["succeeded", "failed", "canceled"]);

function shouldApplyTaskState(currentState: string | null, nextState: string) {
  if (!currentState) return true;
  if (terminalStates.has(currentState) && !terminalStates.has(nextState)) {
    return false;
  }
  return true;
}
```

Side-effect idempotency example:

```ts theme={null}
async function runOnceForTask(taskId: string, action: string, fn: () => Promise<void>) {
  const inserted = await insertTaskSideEffect(taskId, action);
  if (!inserted) return;
  await fn();
}

if (event.event === "task.completed") {
  await runOnceForTask(event.prediction.id, "import_generated_assets", async () => {
    await importGeneratedAssets(event.prediction.artifacts);
  });
}
```

## Responses And Retries

Return any `2xx` status code to acknowledge delivery. `204 No Content` is recommended.

Non-`2xx` responses, network failures, and timeouts are retried.

| Behavior                           | Value                                                      |
| ---------------------------------- | ---------------------------------------------------------- |
| Delivery timeout                   | 30 seconds                                                 |
| Maximum delivery attempts          | 5 total attempts                                           |
| Retry delays after failed attempts | Approximately 1 minute, 5 minutes, 15 minutes, then 1 hour |

Recommended response behavior:

| Case                          | Response       | Notes                                               |
| ----------------------------- | -------------- | --------------------------------------------------- |
| Valid event processed         | `204`          | Acknowledge quickly.                                |
| Duplicate event id            | `204`          | Duplicate delivery is not an error.                 |
| Invalid signature             | `401`          | Do not parse or process the payload.                |
| Invalid JSON                  | `400`          | Log a short request summary without secrets.        |
| Temporary internal failure    | `500`          | Let skills.video retry. Keep processing idempotent. |
| Business record not ready yet | `202` or `204` | Store the event and reconcile asynchronously.       |

Do not wait for long downloads, media processing, or downstream API calls before responding. Enqueue that work and return `2xx` after the event has been durably recorded.

## Test Events

Webhook endpoint test deliveries use the same signature headers. Test events have:

```json theme={null}
{
  "type": "webhook.test",
  "data": {
    "message": "This is a test webhook delivery",
    "timestamp": "2026-01-01T00:00:00.000Z"
  }
}
```

Your handler can either store test events separately or return `204` after signature verification.

## Delivery Logs

Use the developer dashboard to inspect the 20 most recent delivery attempts for a webhook endpoint:

[https://skills.video/dashboard/developer](https://skills.video/dashboard/developer)

Delivery log entries contain fields like this:

```json theme={null}
{
  "eventId": "evt_2f8f5c2e1c9f4db19e7e4b3d8a1f7caa",
  "eventType": "task.completed",
  "taskId": "TASK_DOCUMENT_ID",
  "status": "success",
  "attempts": 1,
  "httpStatus": 204,
  "signatureVersion": "legacy-v1+standard-webhooks-v2",
  "signedPayloadFormat": "v1:timestamp.raw_body; v2:webhook_id.timestamp.raw_body",
  "error": null,
  "nextRetryAt": null,
  "createdAt": "2026-01-01T00:00:10.000Z"
}
```

Delivery fields:

| Field                 | Description                                                                                              |
| --------------------- | -------------------------------------------------------------------------------------------------------- |
| `id`                  | Webhook delivery document id.                                                                            |
| `eventId`             | Unique event id sent in `webhook-id` and `X-Webhook-Event-Id`.                                           |
| `eventType`           | Event type sent in `X-Webhook-Event-Type`.                                                               |
| `taskId`              | Related task id when available.                                                                          |
| `status`              | Delivery state: `pending`, `processing`, `success`, or `failed`.                                         |
| `attempts`            | Number of delivery attempts already made.                                                                |
| `httpStatus`          | Last HTTP status returned by your endpoint.                                                              |
| `signatureVersion`    | Signing scheme used for that delivery attempt, when recorded. Older logs may be `null`.                  |
| `signedPayloadFormat` | Canonical signed payload format used for that delivery attempt, when recorded. Older logs may be `null`. |
| `error`               | Last network or HTTP error summary.                                                                      |
| `nextRetryAt`         | Next scheduled retry time, if the delivery is pending retry.                                             |
| `createdAt`           | Delivery record creation time.                                                                           |

`success` means your endpoint returned a `2xx` response. `failed` means the delivery exhausted retries or could not be queued. `pending` means it is waiting for its next attempt. `processing` means a worker has claimed the delivery.

## Create Request Idempotency

Webhook idempotency is supported through `webhook-id` on delivered events. Legacy receivers can use the equivalent `X-Webhook-Event-Id`. Do not rely on `Idempotency-Key` for generation create requests unless your integration has confirmed support for that header.

If your client retries `POST /api/v1/generation/...`, store the first returned task id and reconcile by that id before retrying from your own backend. This prevents your application from creating duplicate jobs when the first request succeeded but the client connection failed.

## Implementation Checklist

* Configure one endpoint per destination URL and workspace in the developer dashboard.
* Store the endpoint secret in server-side secret storage.
* Verify Standard Webhooks v2 signatures with `webhook_id + "." + timestamp + "." + raw_body`.
* Reject timestamps outside your replay window.
* Insert `webhook-id` before running business logic.
* Guard terminal task states from older non-terminal retries.
* Make side effects idempotent with `prediction.id + action`.
* Return `2xx` after durable receipt and process slow work asynchronously.
* Use delivery logs to inspect failed attempts and retry timing.

## Security Checklist

* Accept only `POST` requests.
* Accept only `Content-Type: application/json`.
* Verify signatures before parsing JSON or executing business logic.
* Enforce a timestamp tolerance window.
* Deduplicate by `webhook-id`.
* Make task-level side effects idempotent.
* Put a maximum raw body size on the webhook route.
* Store webhook secrets only in server-side secret storage.
* Do not log webhook secrets, full signatures, API keys, or sensitive user payloads.
* Rotate the webhook secret immediately if exposure is suspected.
