Skip to content

feat(web): webhooks for Cap Infra videos (video.ready, video.errored) - #4139

Merged
richiemcilroy merged 19 commits into
mainfrom
feat/infra-webhooks
Nov 13, 2026
Merged

richiemcilroy merged 19 commits into
mainfrom
feat/infra-webhooks

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Nov 13, 2026 •

Copy link
Copy Markdown
Member

What this adds

Cap Infra apps can register a URL and be told when a video is ready or has failed, instead of polling.

  • Endpoints API under /api/developer/v1/webhooks (secret key only): create, list, get, update (url, events, enabled), delete, rotate the signing secret, and send a test event. An app can have 5 endpoints. Unknown body fields are refused.
  • Events: video.ready and video.errored. The body is { id, type, createdAt, data }, where data is the video exactly as GET /videos/:id returns it.
  • Signing: Cap-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, plus Cap-Event-Id and Cap-Event-Type. The signing secret (cws_...) is returned once, on create or on POST /webhooks/:id/rotate-secret, and stored encrypted with the same helper as the app's API keys. Rotation takes effect at once: every later request, retries included, is signed with the new secret.
  • Delivery runs in a workflow, so it survives the request that triggered it. Success is any 2xx within 10 seconds. A failed delivery is retried after 1 min, 5 min, 30 min, 2 h, 5 h, 8 h and 8 h: 8 attempts over about a day, then the event is dropped. An endpoint that fails 20 attempts in a row over five days or more is disabled, and PATCH { "enabled": true } turns it back on.
  • Docs: webhooks.mdx is rewritten to describe what exists, rest-api.mdx gains a short Webhooks section that links to it, and the OpenAPI description gains the seven operations, their schemas and the three outbound requests.

How an event is sent

Every status change of a developer video goes through one row-locked update (mutateDeveloperVideo). That is the only place this PR touches in the processing code: one call after the update, which does nothing unless the status has just become ready or errored.

  • The row lock means only one writer sees the change, so an encoder callback delivered twice, or both outputs finishing at once, produce one event.
  • The call is never awaited and its errors are caught and logged, so a webhook can neither slow nor fail a video. It reads the app's endpoints first, and an app with none stops there after one indexed query.
  • The event is built from the committed row: a locking read waits for the transaction to end, and a change that was rolled back sends nothing. A video deleted before it became ready sends nothing.
  • Only endpoints of the app that owns the video are read. Each attempt re-reads the endpoint, so a disabled or deleted endpoint, or a deleted app, stops its pending retries.
  • The event id is derived from the video id and the event type, so every retry and every endpoint sees the same id, and a receiver can drop a repeat.

SSRF guard

The URL is supplied by a customer and requested by our servers, so the request is treated as hostile input. This reuses the guard already on main (lib/s3-endpoint-policy.ts and requestPublicOnly in lib/storage-fetch.ts, the same one the speech API uses for URL sources) rather than adding a second one.

Merge conditions, each tested:

  1. Private targets are never allowed outside tests. There is no environment variable and no NODE_ENV check. Plain http and private addresses are reachable only when a caller passes allowPrivate: true, and only tests do; the routes and the delivery workflow pass nothing. developer-webhook-private-targets.test.ts runs the production path under NODE_ENV = development, test, staging, preview, empty, unset and production against a real local server and sees zero requests. With the old NODE_ENV !== "production" rule put back, all seven cases fail.
  2. Redirects are refused, not followed. requestPublicOnly never follows a redirect. Any 3xx is a failed delivery with code redirect, and the Location is never requested. Tested with a redirect to the metadata address and against a real server.
  3. The checked address is the dialled address, at delivery time. The connection resolves through publicOnlyLookup, which checks every address in the answer and refuses the connection if any is private; Node dials the address that lookup returned, with the original Host header and SNI. The early check at create, update and before each delivery is extra; it is not what keeps the connection safe. developer-webhook-rebinding.test.ts mocks DNS to answer public on the check and private on the connection's lookup (loopback, metadata, fd00:ec2::254, mapped v4, a mixed answer) and the delivery never connects. A control request without the guard does reach the local server, which shows the second answer is the one that gets dialled. With the connect-time check turned off, all seven tests fail.

Mixed DNS answers

When a name resolves to several addresses, the guard does not treat every refused range the same way. The same rule applies at save time (isBlockedS3Endpoint) and at connect time (publicOnlyLookup), for webhooks and for regular Cap's custom S3 endpoints.

  • Refused if ANY address is in it (whole name refused): private, loopback, link-local, carrier NAT, metadata, multicast, unspecified, IPv4-mapped and IPv4-compatible forms of those, and NAT64 64:ff9b::/96. This list and this behaviour are exactly main's, unchanged.
  • Dropped, and a public address is used instead: the ranges this PR adds, which are the documentation ranges (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24, 2001:db8::/32), 6to4 relay anycast 192.88.99.0/24, 6to4 2002::/16, Teredo 2001::/32, the discard prefix 100::/64 and local-use NAT64 64:ff9b:1::/48. These addresses are never dialled. A name whose answer holds a public address too connects to the public one, and only the public addresses are handed to the connection. A name with nothing else is refused.

So a mixed answer never gets stricter than on main. The only new refusals are a name or a literal made up entirely of the added ranges. Real storage hosts do not publish documentation, 6to4 or Teredo addresses. NAT64 addresses only appear from a DNS64 resolver on an IPv6-only network, which Vercel functions do not use, and 64:ff9b::/96 was already refused on main.

A one-commit undo of the added ranges is pushed as branch revert/storage-guard-ranges (no PR), in case custom bucket uploads show refusals after deploy.

Also:

  • https only, default port only, at most 2,048 characters as stored, no username or password.
  • Refused: private, loopback, link-local, carrier NAT, metadata, multicast, documentation and reserved ranges, in IPv4, IPv6, IPv4-mapped, NAT64, 6to4 and Teredo forms, and written as a decimal, hex or octal number. A name with one private address among public ones is refused.
  • A host lookup counts toward the same 10 second deadline as the request.
  • The response body is never read or stored. The connection is closed as soon as the status arrives. Failures are recorded as one of a fixed set of codes, so nothing a customer's server returns can reach another customer.
  • The request carries only the body, the content type, a user agent, the signature and the two event headers. No Cap credentials.
  • A request with a missing or malformed key is refused before any database query. An endpoint is only ever looked up together with the app that owns the key.

Changes to the shared guard, and why regular Cap is unaffected

Callers of these two files: custom S3 endpoints (actions/organization/storage.ts, desktop s3Config.ts, api/v1 route), fetchStorageUrl (transcription, OG thumbnails, desktop manifest rebuild) and public-media-fetch.ts (speech URL sources).

  1. A new reserved list holds the documentation ranges, 6to4 relay anycast, 6to4, Teredo, the discard prefix and local-use NAT64. Those addresses are never dialled, but they do not refuse a whole name the way private addresses do: a DNS answer that mixes a public address with a reserved one connects to the public one, and only the public addresses are handed to the connection. A name whose answer is only reserved addresses is refused.
    • Mixed answers, before and after. On main, both the save-time check (isBlockedS3Endpoint) and the connect-time publicOnlyLookup refuse the whole name when any address in the answer is private or loopback. That is unchanged. A public A with a 6to4, Teredo, local NAT64, discard or documentation AAAA still connects, as it did on main. The 11 mixed-answer cases in s3-endpoint-policy.test.ts were also run against main's copy of the file and give the same result there. The only stricter cases are a name or literal made up entirely of the new ranges.
    • Tests also pin public AWS, Cloudflare and Google addresses in v4 and v6 (including 2001:1::1, 2001:db9::1 and 2003::1 next to the new prefixes) as still allowed.
  2. requestPublicOnly refuses a private IP literal itself, because Node skips a custom lookup for literals. Every existing caller already checks the URL first, so none of them sees a difference.
  3. requestPublicOnly accepts a text body, which webhooks need. A non-text body is still refused.
  4. A 204, 205 or 304 that carries content anyway now drops the connection instead of draining it without end. An empty one flows to its end as before.

The guard suites for every caller pass: s3-endpoint-policy, storage-fetch, public-media-fetch, organization-storage-s3-endpoint, agent-s3-endpoint-policy.

Migration

packages/database/migrations/0058_developer_webhook_endpoints.sql (journal idx 58, when 1794549219180, newer than every entry on main). It creates one table, developer_webhook_endpoints, with a foreign key to developer_apps and one index (appId, deletedAt, createdAt, id). Nothing existing is altered. The snapshot meta/0058_snapshot.json chains from main's 0057, and drizzle-kit generate reports no schema changes against it. #4145 takes the next number after this one. It needs to be applied by deploy request before this is merged.

What I ran

  • bunx vitest run on every webhook, developer, infra, guard and guard caller test file: 144 files, 2,953 passed. The whole web unit suite: green apart from share-stage-identity, which already fails locally on main.
  • tsc --noEmit for apps/web after rebuilding the referenced packages: clean apart from one error in organization-settings-ownership.test.ts, which this PR does not touch and which is the same on main.
  • Biome clean on every touched file.
  • The production transport against a real local HTTP server over real sockets: exact body and length, a 503, a redirect that is not followed, an endpoint that never answers, a response body that never ends, and a 205 that floods or stalls.
  • The Node example in the docs is extracted and run by a test against a real signature. The Python example was run by hand against a captured delivery.

Earlier end to end run on a scratch local database, local storage, a local media server and a local HTTP server as the customer endpoint:

Step Result
Create three endpoints: one healthy, one that answers 500 once, one that never answers Created, secret returned once, stored as ciphertext, absent from every later response
Unknown field, non-http scheme, credentials in the URL 400
A sixth endpoint 409 webhook_limit_reached
Another app's key on the endpoint: GET, PATCH, DELETE 404 each, and that app lists none
Test event Delivered, 200, signature accepted by the docs' Node example
Upload a real clip and let it encode video.ready delivered once to each endpoint with the same event id, signature accepted by the Node and Python examples
Upload a file that is not a video video.errored delivered once, with the reason in data.errorReason
The endpoint that answered 500 Attempt 2 arrived 60.7 s later and succeeded, failure count back to 0
The endpoint that never answers Timed out after 10 s, recorded as timeout, retried 60 s later, failure count rising
Time from upload complete to ready, four videos each Median 305 ms with a hanging endpoint and four others, 346 ms with no endpoints

That run used the old local-build switch for http on localhost. That switch is gone, so a local build now needs a public https URL, such as a tunnel, to receive webhooks.

Not covered

  • There is no console page. The store functions a page would use are exported from apps/web/lib/developer-webhooks.ts.
  • There is no delivery log and no way to replay an event. Only the last result is kept on the endpoint. Events that happen while an endpoint is disabled or deleted are not sent later.
  • An event is queued just after the status change is committed. If the server process dies in that instant the event is lost, so GET /videos/:id/status stays the source of truth. A receiver can also see the same event more than once and should drop a repeated id.
  • Rotation has no overlap window: the old secret stops at once. The docs say to accept both secrets in the receiver while rotating.
  • Only video events exist. Transcripts do not send events yet.
  • Not exercised on a real deployment: the workflow engine's hosted queue and the retry gaps beyond the first minute. Both were run locally on the same engine in its local mode.

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge once the documented database migration is applied.

Summary

Adds signed webhooks for Cap Infra videos, with endpoint management, test events, retries, and automatic disabling after repeated failures.

  • App owners can manage webhook endpoints through the developer API.
  • Video status changes send signed events to subscribed endpoints.
  • Shared URL requests filter reserved addresses and can send text bodies.

Reviews (12) · Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." · Reviewed by Greptile

…n public-only requests

Node skips a custom lookup when the host is already an IP address, so requestPublicOnly on its own would have connected to a literal private address. Every caller checked the URL first, so nothing reached it, but the request now refuses the literal itself. It also sends a text body, which outbound webhooks need. The address list gains the documentation ranges, 6to4, Teredo, the discard prefix and local-use NAT64.
Comment thread apps/web/lib/developer-webhooks.ts Resolved
Comment thread apps/web/lib/developer-openapi.ts Resolved

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found 2 potential issues (2 medium).

View 2 other findings in Capy.

Open in Capy

Record a delivery's count, streak start and disable decision under the endpoint's row lock. Run the host lookup inside the delivery deadline and sign at the moment of sending. Measure a URL as it is stored, after encoding. Close the connection once the status is known, and drop a connection that sends content after a no-content answer.
@vercel
vercel Bot temporarily deployed to Preview – cap-status November 13, 2026 01:13 Inactive
@vercel

vercel Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
cap-status Ignored Ignored Preview Nov 13, 2026 7:26am UTC

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

Open in Capy

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@sandchest-com

sandchest-com Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Sandchest

Sandchest reviewed e15c648: nothing exploitable found in this change.

Sandchest reviews every change for vulnerabilities an attacker could use · 9 credits · Review details

# Conflicts:
#	apps/web/content/docs/api/webhooks.mdx
# Conflicts:
#	packages/database/migrations/meta/0057_snapshot.json
#	packages/database/migrations/meta/_journal.json
Main took 0056 and 0057 and 0058 is reserved for the storage PR. The SQL is
unchanged and the snapshot now chains from main's 0057.
POST /webhooks/:id/rotate-secret replaces the secret and returns the new one
once. Every later delivery, retries included, is signed with it.
Private and plain http targets were allowed whenever NODE_ENV was not
production, so a self-hosted install or a misconfigured server could reach
internal hosts. They are now reachable only when a caller passes
allowPrivate, which only tests do. A test runs the production path under
seven NODE_ENV values against a real local server and sees no request.
@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai please re-review

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

View 1 other finding in Capy.

Open in Capy

This PR lands before the storage PR, so it takes the next number on main.
The SQL is unchanged, the snapshot chains from main's 0057 and the journal
when is newer than every entry on main.
@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai please re-review

@vercel
vercel Bot temporarily deployed to Preview – cap-status November 13, 2026 05:54 Inactive

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found 1 potential issue (1 medium).

Open in Capy

The ranges added for webhooks (documentation, 6to4, Teredo, discard, local
NAT64) refused a whole name when one AAAA fell inside them, which custom S3
endpoints did not do before. They now drop only their own addresses, and a
name is refused only when nothing public is left. A private or loopback
address anywhere in the answer still refuses the name, as on main.
@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai please re-review

@vercel
vercel Bot temporarily deployed to Preview – cap-status November 13, 2026 05:58 Inactive

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

View 1 other finding in Capy.

Open in Capy

@vercel
vercel Bot temporarily deployed to Preview – cap-status November 13, 2026 06:42 Inactive

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

View 1 other finding in Capy.

Open in Capy

# Conflicts:
#	apps/web/app/api/developer/v1/[...route]/route.ts
#	apps/web/lib/developer-openapi.ts
@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai please re-review

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

View 2 other findings in Capy.

Open in Capy

@richiemcilroy
richiemcilroy merged commit d8157d3 into main Nov 13, 2026
42 checks passed

This branch was successfully deployed

1 active and 1 inactive (outdated) deployments
Preview – cap-web — e15c648a Deployed Nov 13, 2026 by vercel[bot]
Preview – cap-status — 9c5b59e1 Deployed Nov 13, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants