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

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

video.source_requested — AFS example

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

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:
AFS callback — pick one of creative.acceptedAspects:
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

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

Response codes

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:
Python example:

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.