# Webhooks

### Overview

Firework Webhooks notify your application in real time when events occur on the platform. Instead of polling for changes, your server receives HTTP POST callbacks as events happen.

#### How It Works

```
┌──────────────┐         Event occurs         ┌──────────────┐
│   Firework   │  ──────────────────────────▶  │  Your Server │
│   Platform   │   HTTP POST with signed JSON  │  (Webhook    │
│              │                               │   Endpoint)  │
│              │  ◀──────────────────────────  │              │
│              │   2xx response                │              │
└──────────────┘                               └──────────────┘
```

1. An event occurs on Firework (e.g., video finishes processing)
2. Firework sends an HTTP POST request to your configured callback URL
3. The request includes a JSON payload describing the event and an HMAC signature for verification
4. Your server processes the event and responds with a 2xx status code

#### Available Event Types

This document covers **video-related events** only. Additional event types (e.g., livestream) may be documented separately.

| Event Type            | Description                                                |
| --------------------- | ---------------------------------------------------------- |
| `video_created`       | Video created — transcoding complete or failed             |
| `video_updated`       | Video metadata or state changed after creation             |
| `video_import_failed` | Async URL import failed (download error, validation error) |

***

### Configuration

Webhook endpoints are **not self-service** at this time. To set up webhooks for your business:

**Contact the Firework IS team** and provide:

| Information      | Description                                                        | Example                                                 |
| ---------------- | ------------------------------------------------------------------ | ------------------------------------------------------- |
| **Callback URL** | The HTTPS endpoint on your server that will receive webhook events | `https://yourapp.com/webhooks/firework`                 |
| **Event types**  | Which event types you want to subscribe to                         | `video_created`, `video_updated`, `video_import_failed` |
| **Business ID**  | Your Firework business identifier                                  | `AbCdEfG`                                               |

After configuration, the IS team will provide you with:

* **Endpoint secret** — Used to verify webhook signatures (see [Signature Verification](#signature-verification))

> **Important:** Store your endpoint secret securely. If you suspect it has been compromised, contact the IS team to rotate it.

***

### Delivery

#### Delivery Mechanics

| Property         | Value                                         |
| ---------------- | --------------------------------------------- |
| **HTTP Method**  | `POST`                                        |
| **Content-Type** | `application/json`                            |
| **Timeout**      | 30 seconds per delivery attempt               |
| **Signature**    | HMAC-SHA256 in `FW-Webhooks-Signature` header |

#### Success Criteria

A delivery is considered successful when your server responds with:

| Status Code | Meaning    |
| ----------- | ---------- |
| `200`       | OK         |
| `202`       | Accepted   |
| `204`       | No Content |

Any other status code (or a timeout) is treated as a failure and triggers a retry.

#### Retry Policy

Failed deliveries are retried with increasing backoff:

| Attempt | Backoff  |
| ------- | -------- |
| 1–3     | 1 hour   |
| 4–6     | 24 hours |

Maximum **6 attempts** per event delivery. After all attempts are exhausted, the event is dropped.

***

### Signature Verification

Every webhook request includes an `FW-Webhooks-Signature` header. You **must** verify this signature before processing the payload to ensure the request is authentic.

#### Header Format

```
FW-Webhooks-Signature: t=1738152000000,v1=dGhpcyBpcyBhIHNhbXBsZSBzaWduYXR1cmU=
```

| Component | Description                                                         |
| --------- | ------------------------------------------------------------------- |
| `t`       | Unix timestamp in **milliseconds** when the signature was generated |
| `v1`      | **Base64-encoded** HMAC-SHA256 signature                            |

#### Verification Steps

1. **Extract** the `t` (timestamp) and `v1` (signature) values from the header
2. **Construct** the signed payload string: `{t}.{raw_request_body}`
3. **Compute** HMAC-SHA256 of the signed payload using your endpoint secret
4. **Base64-encode** the result
5. **Compare** your computed signature with `v1` using a constant-time comparison

#### Worked Example

Given:

* **Endpoint secret** (provided by IS team): `a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2`
* **Signature header received**:

  ```
  FW-Webhooks-Signature: t=1738152300000,v1=K7vMFnQ3xYz...==
  ```
* **Raw request body received**:

  ```
  {"event_type":"video_created","version":"2023-06-06","created":1738152300,"business_id":"AbCdEfG","data":{"id":"xyz123","status":"approved","import_id":null,"access":"public","audio_disabled":false,"caption":"My Video","description":null,"hashtags":[],"archived_at":null,"product_ids":[],"custom_fields":{},"display_social_attributions":true,"external_media":null,"thumbnail_url":"https://cdn.firework.com/medias/thumbnails/xyz123.jpg","video_posters":[]}}
  ```

**Step 1** — Parse the header:

```
t  = "1738152300000"
v1 = "K7vMFnQ3xYz...=="
```

**Step 2** — Concatenate `t` + `.` + raw request body:

```
1738152300000.{"event_type":"video_created","version":"2023-06-06","created":1738152300,"business_id":"AbCdEfG","data":{"id":"xyz123","status":"approved","import_id":null,"access":"public","audio_disabled":false,"caption":"My Video","description":null,"hashtags":[],"archived_at":null,"product_ids":[],"custom_fields":{},"display_social_attributions":true,"external_media":null,"thumbnail_url":"https://cdn.firework.com/medias/thumbnails/xyz123.jpg","video_posters":[]}}
```

**Step 3** — HMAC-SHA256 this string using your endpoint secret as the key.

**Step 4** — Base64-encode the HMAC result.

**Step 5** — Compare your Base64 result with `v1`. If they match, the webhook is authentic.

> **Important:** Use the **raw request body** exactly as received (do not re-serialize the JSON). JSON key ordering and whitespace must match exactly for the signature to verify.

> **Tip:** Optionally check the timestamp `t` to reject stale requests (e.g., older than 5 minutes) to prevent replay attacks.

#### Example: Python

```
import hmac
import hashlib
import base64

def verify_webhook(payload_body: str, signature_header: str, secret: str) -> bool:
    t_part, v1_part = signature_header.split(",", 1)
    timestamp = t_part[2:]
    expected_sig = v1_part[3:]

    signed_payload = f"{timestamp}.{payload_body}"
    computed = hmac.new(
        secret.encode(), signed_payload.encode(), hashlib.sha256
    ).digest()
    computed_sig = base64.b64encode(computed).decode()

    return hmac.compare_digest(computed_sig, expected_sig)
```

#### Example: Node.js

```
const crypto = require("crypto");

function verifyWebhook(payloadBody, signatureHeader, secret) {
  const [tPart, v1Part] = signatureHeader.split(",");
  const timestamp = tPart.substring(2);
  const expectedSig = v1Part.substring(3);

  const signedPayload = `${timestamp}.${payloadBody}`;
  const computedBuf = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest();
  const expectedBuf = Buffer.from(expectedSig, "base64");

  if (computedBuf.length !== expectedBuf.length) return false;

  return crypto.timingSafeEqual(computedBuf, expectedBuf);
}
```

#### Example: Elixir

```
def verify_webhook(payload_body, signature_header, secret) do
  ["t=" <> timestamp, "v1=" <> expected_sig] = String.split(signature_header, ",")

  signed_payload = "#{timestamp}.#{payload_body}"

  computed_sig =
    :crypto.mac(:hmac, :sha256, secret, signed_payload)
    |> Base.encode64()

  Plug.Crypto.secure_compare(computed_sig, expected_sig)
end
```

***

### Event Payload

#### Envelope Schema

All webhook events share the same envelope structure:

```
{
  "event_type": "video_created",
  "version": "2023-06-06",
  "created": 1738152000,
  "business_id": "AbCdEfG",
  "data": { ... }
}
```

| Field         | Type    | Description                                                   |
| ------------- | ------- | ------------------------------------------------------------- |
| `event_type`  | string  | Event type identifier (e.g., `"video_created"`)               |
| `version`     | string  | Payload schema version (currently `"2023-06-06"`)             |
| `created`     | integer | Unix timestamp (**seconds**) when the event was generated     |
| `business_id` | string  | Encoded ID of the business that owns the resource             |
| `data`        | object  | Event-specific payload — varies by event type (see Section 6) |

#### Video Data Object

Used by `video_created` and `video_updated` events. The payload mirrors `GET /api/v1/videos/{id}` with additional webhook-specific fields (`status`, `import_id`), so consumers do not need a follow-up GET call.

| Field                         | Type      | Nullable | Description                                                                  |
| ----------------------------- | --------- | -------- | ---------------------------------------------------------------------------- |
| `id`                          | string    | ❌        | Encoded video ID                                                             |
| `status`                      | string    | ❌        | Current video status (see status values below)                               |
| `import_id`                   | string    | ✅        | Encoded import job ID. Present only for videos created via async URL import. |
| `access`                      | string    | ❌        | Video visibility: `"public"` or `"private"`                                  |
| `audio_disabled`              | boolean   | ❌        | Whether the video audio is muted                                             |
| `caption`                     | string    | ✅        | Video title/caption                                                          |
| `description`                 | string    | ✅        | Video description                                                            |
| `hashtags`                    | string\[] | ❌        | List of hashtags (empty array if none)                                       |
| `archived_at`                 | string    | ✅        | ISO 8601 timestamp if archived, `null` otherwise                             |
| `product_ids`                 | string\[] | ❌        | List of encoded product IDs tagged on the video (empty array if none)        |
| `custom_fields`               | object    | ❌        | Key-value map of custom metadata fields (empty object if none)               |
| `display_social_attributions` | boolean   | ✅        | Whether to display social attribution overlays                               |
| `external_media`              | object    | ✅        | External media source info (see below), or `null`                            |
| `thumbnail_url`               | string    | ✅        | CDN URL for the video thumbnail image, or `null` if not available            |
| `video_posters`               | object\[] | ❌        | List of poster image renditions (see below). Empty array if none.            |

**Video Posters Object**

Each entry in the `video_posters` array describes a poster image rendition of the video.

| Field          | Type    | Description                            |
| -------------- | ------- | -------------------------------------- |
| `url`          | string  | CDN URL for this poster image          |
| `aspect_ratio` | string  | Aspect ratio (e.g., `"9:16"`, `"1:1"`) |
| `format`       | string  | Image format (e.g., `"jpeg"`, `"gif"`) |
| `width`        | integer | Poster width in pixels                 |
| `height`       | integer | Poster height in pixels                |

**External Media Object**

Present only when the video was imported from an external source.

| Field      | Type   | Description                         |
| ---------- | ------ | ----------------------------------- |
| `source`   | string | Platform name (e.g., `"instagram"`) |
| `username` | string | Original creator's username         |
| `url`      | string | Original content URL                |

**Video Status Values**

| Status     | Description                                        |
| ---------- | -------------------------------------------------- |
| `pending`  | Video is being transcoded — not yet ready          |
| `approved` | Transcoding complete — video is ready for playback |
| `errored`  | Transcoding failed                                 |

> **Note:** `video_created` fires with `"approved"` status on successful transcoding, or `"errored"` status on transcoding failure. `video_updated` only fires for videos that have already received a `video_created` event.

#### Import Data Object

Used by `video_import_failed` events. No video record exists when this event fires. Use `GET /api/v1/videos/imports/{import_id}` to fetch import job details.

| Field       | Type   | Nullable | Description                                     |
| ----------- | ------ | -------- | ----------------------------------------------- |
| `import_id` | string | ❌        | Encoded import job ID                           |
| `reason`    | string | ❌        | Machine-readable failure code (see table below) |

**Import Failure Reason Values**

| Reason                  | Description                                               |
| ----------------------- | --------------------------------------------------------- |
| `download_failed`       | Could not download the file (timeout, 404, network error) |
| `invalid_format`        | File is not a supported video format                      |
| `duration_out_of_range` | Video shorter than 3s or longer than 1h                   |
| `file_size_exceeded`    | File exceeds 5GB limit                                    |
| `internal_error`        | Unexpected server-side error                              |

***

### Video Events

#### `video_created`

Fired when a video's transcoding completes (successfully or with failure). The `data` field contains the full video resource (same fields as `GET /api/v1/videos/{id}`).

**When it fires:**

* After transcoding completes and the video status transitions to `"approved"` — the video is ready for playback
* After transcoding fails and the video status transitions to `"errored"`

**Example — file upload / S3 key:**

```
{
  "event_type": "video_created",
  "version": "2023-06-06",
  "created": 1738152300,
  "business_id": "AbCdEfG",
  "data": {
    "id": "encoded_video_id_xyz123",
    "status": "approved",
    "import_id": null,
    "access": "public",
    "audio_disabled": false,
    "caption": "My Video Title",
    "description": "A short description",
    "hashtags": ["demo", "product"],
    "archived_at": null,
    "product_ids": ["encoded_product_1"],
    "custom_fields": {},
    "display_social_attributions": true,
    "external_media": null,
    "thumbnail_url": "https://cdn.firework.com/medias/thumbnails/encoded_video_id_xyz123.jpg",
    "video_posters": [
      {
        "url": "https://cdn.firework.com/medias/posters/encoded_video_id_xyz123_9x16.jpeg",
        "aspect_ratio": "9:16",
        "format": "jpeg",
        "width": 360,
        "height": 640
      }
    ]
  }
}
```

**Example — async URL import:**

```
{
  "event_type": "video_created",
  "version": "2023-06-06",
  "created": 1738152300,
  "business_id": "AbCdEfG",
  "data": {
    "id": "encoded_video_id_xyz123",
    "status": "approved",
    "import_id": "encoded_import_abc123",
    "access": "public",
    "audio_disabled": false,
    "caption": "Imported Video",
    "description": null,
    "hashtags": [],
    "archived_at": null,
    "product_ids": [],
    "custom_fields": {},
    "display_social_attributions": true,
    "external_media": null,
    "thumbnail_url": "https://cdn.firework.com/medias/thumbnails/encoded_video_id_xyz123.jpg",
    "video_posters": [
      {
        "url": "https://cdn.firework.com/medias/posters/encoded_video_id_xyz123_9x16.jpeg",
        "aspect_ratio": "9:16",
        "format": "jpeg",
        "width": 360,
        "height": 640
      }
    ]
  }
}
```

> If the status is `"approved"`, the video is ready for playback. If the status is `"errored"`, transcoding failed. If `import_id` is present, you can also check `GET /api/v1/videos/imports/{import_id}` for import job details.

**Example — transcoding failed:**

```
{
  "event_type": "video_created",
  "version": "2023-06-06",
  "created": 1738153000,
  "business_id": "AbCdEfG",
  "data": {
    "id": "encoded_video_id_xyz123",
    "status": "errored",
    "import_id": null,
    "access": "public",
    "audio_disabled": false,
    "caption": "My Video Title",
    "description": null,
    "hashtags": [],
    "archived_at": null,
    "product_ids": [],
    "custom_fields": {},
    "display_social_attributions": true,
    "external_media": null,
    "thumbnail_url": null,
    "video_posters": []
  }
}
```

#### `video_updated`

Fired when a video's state or metadata changes **after** a `video_created` event has been sent. The `data` field contains the **full video resource** after the update (same fields as `GET /api/v1/videos/{id}`), so consumers do not need a follow-up GET call.

**When it fires:**

* After a metadata update (caption, description, access, hashtags, products, poster, etc.)
* After any other video state change (admin actions, automations, AICC, etc.)

> This event only fires for videos that have already received a `video_created` event (i.e., videos with `"approved"` status). It does **not** fire for transcoding failures — those are covered by `video_created` with `"errored"` status.

**Example — metadata update via PATCH:**

```
{
  "event_type": "video_updated",
  "version": "2023-06-06",
  "created": 1738153500,
  "business_id": "AbCdEfG",
  "data": {
    "id": "encoded_video_id_xyz123",
    "status": "approved",
    "import_id": null,
    "access": "public",
    "audio_disabled": false,
    "caption": "Updated Title",
    "description": "New description",
    "hashtags": ["sale", "new"],
    "archived_at": null,
    "product_ids": ["encoded_product_1", "encoded_product_2"],
    "custom_fields": { "sku": "ABC-123" },
    "display_social_attributions": true,
    "external_media": null,
    "thumbnail_url": "https://cdn.firework.com/medias/thumbnails/encoded_video_id_xyz123.jpg",
    "video_posters": [
      {
        "url": "https://cdn.firework.com/medias/posters/encoded_video_id_xyz123_9x16.jpeg",
        "aspect_ratio": "9:16",
        "format": "jpeg",
        "width": 360,
        "height": 640
      }
    ]
  }
}
```

> If `import_id` is present, you can also check `GET /api/v1/videos/imports/{import_id}` for import job details.

#### `video_import_failed`

Fired when an async URL import fails **before** a video record is created. This covers failures during download, URL validation, or file validation.

**When it fires:**

* Download from the source URL fails (timeout, 404, network error)
* Downloaded file fails validation (invalid format, duration out of range, file size exceeded)

> This event only applies to async URL imports (`POST /api/v1/videos` with `"async": true`). It does **not** fire for transcoding failures — those are covered by `video_created` with `"errored"` status.

**Example — download failed:**

```
{
  "event_type": "video_import_failed",
  "version": "2023-06-06",
  "created": 1738153000,
  "business_id": "AbCdEfG",
  "data": {
    "import_id": "encoded_import_abc123",
    "reason": "download_failed"
  }
}
```

**Example — invalid file:**

```
{
  "event_type": "video_import_failed",
  "version": "2023-06-06",
  "created": 1738153000,
  "business_id": "AbCdEfG",
  "data": {
    "import_id": "encoded_import_abc123",
    "reason": "invalid_format"
  }
}
```

> Use `GET /api/v1/videos/imports/{import_id}` to fetch the import job details.

***

### Best Practices

#### Respond Quickly

Your webhook endpoint should return a 2xx response within **30 seconds**. If you need to perform lengthy processing, acknowledge the webhook immediately and process asynchronously.

```
# Good: acknowledge first, process later
POST /webhooks/firework → 200 OK (immediate)
  └─▶ enqueue background job for processing
```

#### Verify Signatures

Always verify the `FW-Webhooks-Signature` header before processing webhook payloads. This protects against spoofed requests. See [Section Signature Verification](#signature-verification) for implementation details.

#### Handle Duplicates

In rare cases, the same event may be delivered more than once. Design your handler to be **idempotent** — processing the same event twice should not cause issues.

**Deduplication key:** `event_type` + `data.id` + `created` (for `video_import_failed`, use `data.import_id` instead of `data.id`)

#### Use HTTPS

Always use an HTTPS callback URL. Firework will not deliver webhooks to plain HTTP endpoints.


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.firework.com/firework-for-developers/api/webhooks.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
