# 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}}
  ```

**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}}
```

**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`                            |

**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

#### &#x20;`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
  }
}
```

**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
  }
}
```

> 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
  }
}
```

#### `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
  }
}
```

> 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.
