Skip to main content

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

TopicTriggered byPayload
video.source_requestedImport job finds a video without accessible source bytesConcept coordinates + pre-signed upload URL + callback URL
video.source_fulfilledFulfiller successfully delivers bytesConcept coordinates (with the delivered aspect) + storage key + delivery channel
video.source_timeout1-hour wait window elapses without deliveryConcept coordinates + requested/expired timestamps
video.source_rejectedDelivered 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:
placementFamilyPlacements it coversAspects the fulfiller may deliver (acceptedAspects)
feedFacebook feed, Instagram feed, marketplace, explore, in-stream1x1, 3x4, 4x5
story_reelFacebook Stories, Facebook Reels, Instagram Stories, Instagram Reels9x16
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

{
  "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

{
  "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).
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:
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:
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

FieldRequiredNotes
skipoptionalNegative-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.
reasonoptional, with skipHuman-readable detail for the skip (e.g. "no_match", "folder_empty"). Stored as the detail on the rejection event.
storageKeyrequired when skip is absentEcho back the fulfillment.storageKey from the request (or your own R2 key if you wrote elsewhere — it just has to exist when we validate)
aspectRatioAFS only, with positive-ackOne of acceptedAspects. Rejected as aspect_ratio_not_accepted_for_<family> if outside the set.
extensionoptionalConcrete 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.
contentHashoptionalsha256:<hex>. Surfaced on the video.source_fulfilled event for audit.
Negative-ack example — fulfiller couldn’t find a matching file in its own catalog:
curl -X POST \
  -H "Content-Type: application/json" \
  -d '{ "skip": true, "reason": "no_match:acceptedAspects=[1x1,3x4,4x5]" }' \
  "$COMPLETE_CALLBACK_URL"

Response codes

CodeMeaning
200 / 202Accepted — 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.
409Already completed — token was fulfilled or canceled previously
410Expired — 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:
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:
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.