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

# Webhook Integrations

> Receive platform events via outbound webhooks — configure destinations per project and build custom integrations.

# Webhook Integrations

Each project can receive platform events through outbound webhooks. Configure where events go (your HTTPS endpoint, a queue, an S3 bucket) and subscribe to only the event types you care about.

The first use case is **video source fulfillment** — when Facebook refuses raw byte access for videos in an imported creative (common for Dynamic Creative / dynamic-format ads), we publish a request for your integration to deliver the original master bytes, which lets you relaunch the creative in new ad accounts.

## Getting started

1. Navigate to your project's **Settings → Integrations** page
2. Click **Open Webhooks Portal** — you'll land in a Hookdeck-hosted portal scoped to your project
3. Create a destination (type: Webhook, URL: your HTTPS endpoint), choose which topics to subscribe to, and save
4. Copy the signing secret from the destination's detail view — you'll need it to verify incoming webhooks

All destination management (create, edit, disable, retry failed deliveries, inspect delivery logs) lives inside the portal. You can return any time — the button on the Settings page logs you back in.

## Event catalog

| Topic                    | Triggered by                                                   | Payload                                                                          |
| ------------------------ | -------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `video.source_requested` | Import job finds a video without accessible source bytes       | Concept coordinates + pre-signed upload URL + callback URL                       |
| `video.source_fulfilled` | Fulfiller successfully delivers bytes                          | Concept coordinates (with the delivered aspect) + storage key + delivery channel |
| `video.source_timeout`   | 1-hour wait window elapses without delivery                    | Concept coordinates + requested/expired timestamps                               |
| `video.source_rejected`  | Delivered bytes failed validation (wrong aspect, corrupt file) | Concept coordinates + rejection reason                                           |

## Two request flavours

Every `video.source_requested` event falls into one of two shapes depending on what we know about the asset:

### OSS-committed (exact aspect)

When the source creative uses a classic single-placement upload (`object_story_spec.video_data`), we measure the master video's dimensions at import time and commit to a specific aspect ratio. The request's `creative.aspectRatio` is set; the fulfiller uploads bytes matching that aspect.

### AFS-broad (placement family)

When the source creative is Dynamic Creative / dynamic-format (`asset_feed_spec`), FB hides the originals and we can't measure anything. Instead of faking an aspect, we ask for a **placement family** and let the fulfiller pick from the aspects that family accepts:

| `placementFamily` | Placements it covers                                                 | Aspects the fulfiller may deliver (`acceptedAspects`) |
| ----------------- | -------------------------------------------------------------------- | ----------------------------------------------------- |
| `feed`            | Facebook feed, Instagram feed, marketplace, explore, in-stream       | `1x1`, `3x4`, `4x5`                                   |
| `story_reel`      | Facebook Stories, Facebook Reels, Instagram Stories, Instagram Reels | `9x16`                                                |

For AFS requests, `creative.aspectRatio` is **absent**. The fulfiller chooses one of `creative.acceptedAspects` and includes the choice in the completion callback body (see below).

### `video.source_requested` — OSS example

```jsonc theme={null}
{
  "projectId": "019d928c-02e1-...",
  "projectSlug": "hearty",
  "creative": {
    "code": "HRT-1212",
    "variation": 2,
    "locale": "en",
    "aspectRatio": "9x16",
    "kind": "video"
  },
  "facebook": {
    "facebookMediaIds": ["fm_a", "fm_b"],
    "ads": [
      { "id": "120244000819040432", "name": "15-08-25 HRT-1212-2", "adAccountId": "743795164364156" }
    ],
    "adsets": [
      { "id": "120234224516300432", "name": "Broad - US - v2" }
    ],
    "campaigns": [
      { "id": "120234224515520432", "name": "HRT Prospecting - Sep" }
    ]
  },
  "fulfillment": {
    "uploadUrl": "https://r2.../...?X-Amz-Signature=...",
    "storageKey": "fb-external/019d928c/HRT-1212/v2/en/9x16.video",
    "expiresAt": "2026-04-24T01:00:00Z",
    "completeCallbackUrl": "https://api.trigger.dev/api/v1/waitpoints/tokens/waitpoint_abc/complete"
  }
}
```

### `video.source_requested` — AFS example

```jsonc theme={null}
{
  "projectId": "019d928c-02e1-...",
  "projectSlug": "hearty",
  "creative": {
    "code": "HRT-1212",
    "variation": 2,
    "locale": "en",
    "placementFamily": "feed",
    "acceptedAspects": ["1x1", "3x4", "4x5"],
    "kind": "video"
    // aspectRatio is absent — fulfiller picks one and reports it in the callback
  },
  "facebook": {
    "facebookMediaIds": [],
    "ads": [
      { "id": "120244000819040432", "name": "15-08-25 HRT-1212-2", "adAccountId": "743795164364156" }
    ],
    "adsets": [
      { "id": "120234224516300432", "name": "Broad - US - v2" }
    ],
    "campaigns": [
      { "id": "120234224515520432", "name": "HRT Prospecting - Sep" }
    ]
  },
  "fulfillment": {
    "uploadUrl": "https://r2.../fb-external/019d928c/HRT-1212/v2/en/feed.video?X-Amz-Signature=...",
    "storageKey": "fb-external/019d928c/HRT-1212/v2/en/feed.video",
    "expiresAt": "2026-04-24T01:00:00Z",
    "completeCallbackUrl": "https://api.trigger.dev/api/v1/waitpoints/tokens/waitpoint_abc/complete"
  }
}
```

The `storageKey` for AFS requests uses the placement family in the path (`feed.video` / `story_reel.video` for video creatives, `feed.image` / `story_reel.image` for images) instead of a concrete aspect — since we don't know the aspect yet. The Size row gets created when the callback arrives with the reported aspect.

**Path layout:** `fb-external/{projectId}/{slug}/v{N}/{locale}/{aspect-or-family}.{kind}` where `kind` is `video` or `image`. The path **does not carry a file extension** — videos can land as `mp4`, `mov`, `webm`, `mkv`, etc., and images as `jpg`, `png`, `webp`, `avif`, etc., so any single extension would mislead downstream Content-Type sniffing. Set the `Content-Type` header on your PUT (and report the actual extension in the callback body — see below) so we record it.

**Deadline:** `expiresAt` is one hour from `video.source_requested`. Both the pre-signed R2 upload URL and the wait token expire together at that time — after that, the request falls through to manual-upload state (see the creative detail page in the web app).

**Per-delivery HTTP timeout:** our webhook gateway waits up to **5 minutes** for your endpoint to respond to the POST before it gives up and schedules a retry. That ceiling is set to match AWS Lambda's maximum function runtime so synchronous handlers (ack after the full Drive fetch + R2 upload complete) fit cleanly. If your handler does the work synchronously and it takes longer than 5 min, it'll be retried and duplicate work may land — in which case either split the handler (ack 202 immediately, process async on your side) or shrink the work. Most masters finish in well under 30 s.

## Fulfilling a request

Two steps.

### 1. Upload bytes to R2

Use whatever format you have — videos can be `mp4` / `mov` / `webm` / `mkv` and images can be `jpg` / `png` / `webp` / `avif`. The pre-signed URL is format-agnostic; only the bytes' `Content-Type` matters. Pass the matching mime so R2 stores it as object metadata, then report the extension in the callback (step 2).

```bash theme={null}
curl -X PUT \
  -H "Content-Type: video/mp4" \
  --data-binary @my-master.mp4 \
  "$UPLOAD_URL"
```

### 2. Complete the wait token

POST JSON to `completeCallbackUrl`. For **OSS** requests echo back the `storageKey` from the event; for **AFS** requests also include the concrete `aspectRatio` you delivered.

**OSS callback:**

```bash theme={null}
curl -X POST \
  -H "Content-Type: application/json" \
  -d '{
        "storageKey": "fb-external/019d928c/HRT-1212/v2/en/9x16.video",
        "extension": "mp4",
        "contentHash": "sha256:..."
      }' \
  "$COMPLETE_CALLBACK_URL"
```

**AFS callback** — pick one of `creative.acceptedAspects`:

```bash theme={null}
curl -X POST \
  -H "Content-Type: application/json" \
  -d '{
        "storageKey": "fb-external/019d928c/HRT-1212/v2/en/feed.video",
        "aspectRatio": "4x5",
        "extension": "mp4",
        "contentHash": "sha256:..."
      }' \
  "$COMPLETE_CALLBACK_URL"
```

The callback URL is a Trigger.dev-hosted webhook — unique per request, signed token baked into the path, valid for the 1-hour window. The POSTed JSON body becomes the waitpoint's output and wakes our internal pipeline. For AFS requests it creates the Size row at that moment with your reported aspect, then fans the bytes onto every FacebookMedia row sharing the bucket.

No additional auth header or HMAC signature is required on this leg — the token in the URL is the credential.

### Callback body

| Field         | Required                        | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| ------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `skip`        | optional                        | Negative-ack — tells us you looked but have nothing for this bucket. When `true`, no upload is required and every other field below is ignored. We mark the `SourceRequest` as `rejected` with reason `file_not_available` and resume the import immediately. **Critical** for hybrid creatives where we fan out to both `feed` and `story_reel` — only one family typically has a master, and the other should `skip` so the import doesn't park on a 1-hour wait-token TTL. |
| `reason`      | optional, with `skip`           | Human-readable detail for the skip (e.g. `"no_match"`, `"folder_empty"`). Stored as the `detail` on the rejection event.                                                                                                                                                                                                                                                                                                                                                      |
| `storageKey`  | required when `skip` is absent  | Echo back the `fulfillment.storageKey` from the request (or your own R2 key if you wrote elsewhere — it just has to exist when we validate)                                                                                                                                                                                                                                                                                                                                   |
| `aspectRatio` | **AFS only**, with positive-ack | One of `acceptedAspects`. Rejected as `aspect_ratio_not_accepted_for_<family>` if outside the set.                                                                                                                                                                                                                                                                                                                                                                            |
| `extension`   | optional                        | Concrete file extension you wrote: `mp4`, `mov`, `webm`, `mkv` for videos; `jpg`, `png`, `webp`, `avif` for images. The URL doesn't carry an extension — this is how we know what to set as Content-Type when serving the bytes back. Stored on `CreativeSize.extension`. Strongly encouraged.                                                                                                                                                                                |
| `contentHash` | optional                        | `sha256:<hex>`. Surfaced on the `video.source_fulfilled` event for audit.                                                                                                                                                                                                                                                                                                                                                                                                     |

**Negative-ack example** — fulfiller couldn't find a matching file in its own catalog:

```bash theme={null}
curl -X POST \
  -H "Content-Type: application/json" \
  -d '{ "skip": true, "reason": "no_match:acceptedAspects=[1x1,3x4,4x5]" }' \
  "$COMPLETE_CALLBACK_URL"
```

### Response codes

| Code        | Meaning                                                                                                                                                                                                          |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `200 / 202` | Accepted — waitpoint is marked completed, our pipeline will apply the bytes. Watch for the `video.source_fulfilled` event as confirmation; if validation fails you'll get `video.source_rejected` shortly after. |
| `409`       | Already completed — token was fulfilled or canceled previously                                                                                                                                                   |
| `410`       | Expired — the 1-hour TTL has passed                                                                                                                                                                              |

## Verifying incoming webhooks

Every webhook we send is signed by Outpost with HMAC-SHA256 using the destination's own signing secret (viewable and rotatable from the portal). The signature arrives in the `X-Outpost-Signature` header as `v0=<hex_digest>`.

**You verify this on your receiver** — we don't do it for you. Always verify against the raw request body (any JSON re-serialization invalidates the signature).

Node.js example:

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

function verify(rawBody: string, header: string, secret: string): boolean {
  if (!header.startsWith("v0=")) return false;
  const expected = "v0=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  if (header.length !== expected.length) return false;
  return timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}
```

Python example:

```python theme={null}
import hmac, hashlib

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    if not header.startswith("v0="):
        return False
    expected = "v0=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(header, expected)
```

## Falling back to manual upload

If no webhook destination is configured for the project, or if the 1-hour window elapses without delivery, the web UI shows a manual-upload affordance on the creative detail page. Bytes uploaded manually land in the same R2 location and update the same `FacebookMedia` rows — the two paths converge.

You can also upload manually **while** a webhook is in flight; the wait token is completed by the manual flow and your fulfiller's subsequent callback lands on `409 already completed`.

## Retry behavior

Outpost retries failed deliveries with exponential backoff (default: 10 attempts over \~5 hours). If the final attempt fails, the destination gets a delivery-failed alert in the portal and automatic delivery halts until you investigate. Manual retries from the portal are available any time.

For critical events you want to guarantee processing on your side, include the `event_id` (sent in the `X-Outpost-Event-Id` header) and deduplicate — Outpost guarantees at-least-once delivery, not exactly-once.
