diff --git a/.changeset/fair-queue-concurrency-slot-leak.md b/.changeset/fair-queue-concurrency-slot-leak.md new file mode 100644 index 0000000000..41f41e7e3d --- /dev/null +++ b/.changeset/fair-queue-concurrency-slot-leak.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/redis-worker": patch +--- + +Fair queue tenants can no longer get permanently stuck behind leaked concurrency slots. Slots are now freed on every path that finishes a message, a failed release no longer causes a message to run twice or lose its retry, and a background sweep frees any slot that does leak, so a tenant's queues recover on their own instead of needing manual cleanup. diff --git a/.changeset/lucky-pillows-invite.md b/.changeset/lucky-pillows-invite.md new file mode 100644 index 0000000000..2f43f51ff9 --- /dev/null +++ b/.changeset/lucky-pillows-invite.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Task metrics no longer go missing for projects that configure their own `metricExporters` or `metricReaders`, and the flush error that came with it is gone. diff --git a/.changeset/prebuilt-base-images.md b/.changeset/prebuilt-base-images.md new file mode 100644 index 0000000000..4f3f0be094 --- /dev/null +++ b/.changeset/prebuilt-base-images.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Deployment builds now use custom base layer images and no longer install system packages during every build. This improves layer caching resulting in both faster deployments and faster image pulls on the worker cluster side. diff --git a/.changeset/smooth-schedule-windows.md b/.changeset/smooth-schedule-windows.md new file mode 100644 index 0000000000..6e7bba2eb7 --- /dev/null +++ b/.changeset/smooth-schedule-windows.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/core": patch +"@trigger.dev/sdk": patch +--- + +Define stable execution windows on declarative scheduled tasks. Schedule API responses now expose both the nominal CRON time and its assigned time, while the dashboard shows configured windows and upcoming assignments. diff --git a/.claude/REVIEW.md b/.claude/REVIEW.md index 3bf89ff193..b9438c08af 100644 --- a/.claude/REVIEW.md +++ b/.claude/REVIEW.md @@ -56,8 +56,8 @@ Every PR gets a performance pass — not just the ones that look perf-sensitive. ## Always check - **Tests use testcontainers, not mocks.** Vitest with `redisTest` / `postgresTest` / `containerTest` from `@internal/testcontainers`. Any new `vi.mock(...)` on Redis, Postgres, BullMQ, or other infra is wrong here — 🔴 if added in production-path tests, 🟡 if isolated unit test. -- **Public-package changes have a changeset.** `pnpm run changeset:add` produces `.changeset/*.md`. Required for any edit under `packages/*`. Missing → 🟡; missing on a breaking change → 🔴. -- **Server-only changes have `.server-changes/*.md`.** Required for `apps/webapp/`, `apps/supervisor/` edits with no public-package change. Body should be 1-2 sentences (it has to fit as one bullet in a future changelog). Missing → 🟡. +- **User-facing public-package changes have a changeset.** `pnpm run changeset:add` produces `.changeset/*.md`. Changesets are user-facing release notes, not a catalog of every change: required when a `packages/*` or `integrations/*` change is something a user would notice or act on, skipped for internal-only changes, refactors, chores, and packages not consumed independently (e.g. `@trigger.dev/redis-worker`). Missing on a user-facing change → 🟡; missing on a breaking change → 🔴. Do not flag a missing note when the change is not user-facing. +- **User-facing server-only changes have `.server-changes/*.md`.** Required for user-facing `apps/webapp/`, `apps/supervisor/` edits in a PR with no package or integration change that requires a changeset; skip internal-only or admin-only changes, refactors, and chores. Body should be 1-2 sentences (it has to fit as one bullet in a future changelog). Missing on a user-facing change → 🟡. - **Lua script naming.** Coexisting scripts use behavior-descriptive suffixes (`Tracked`), never `V2`. Old name must keep working until the next deploy clears it. - **RunQueue payload shape.** V2 run-queue payload's `projectId` is consumed by `workerQueueResolver` for override matching. If a PR drops it from the payload, 🔴. - **`safeSend` scope.** Defensive IPC wrappers belong on loop / interval / handler contexts, not one-shot terminal sends. If the PR adds `safeSend` to a single terminal call for consistency, 🟡 with a "remove this" suggestion. diff --git a/.claude/rules/server-apps.md b/.claude/rules/server-apps.md index 3bc4c0b2b9..cc3e6852a0 100644 --- a/.claude/rules/server-apps.md +++ b/.claude/rules/server-apps.md @@ -5,7 +5,7 @@ paths: # Server App Changes -When modifying server apps (webapp, supervisor, etc.) with **no package changes**, add a `.server-changes/` file instead of a changeset: +`.server-changes/` files are user-facing release notes, not a catalog of every change. When a user-facing server app change (webapp, supervisor, etc.) is in a PR with **no package or integration change that requires a changeset**, add a `.server-changes/` file instead of a changeset. Skip it for internal-only or admin-only changes, refactors, and chores: ```bash cat > .server-changes/descriptive-name.md << 'EOF' @@ -20,6 +20,6 @@ EOF - **area**: `webapp` | `supervisor` - **type**: `feature` | `fix` | `improvement` | `breaking` -- If the PR also touches `packages/`, just the changeset is sufficient (no `.server-changes/` needed). +- If the PR also touches `packages/` or `integrations/` and that change needs a changeset, the changeset covers it (no `.server-changes/` needed). If the package or integration change is internal and needs no changeset, still add a `.server-changes/` file for the user-facing server change. The body ships **verbatim in user-facing release notes**. Keep it to 1–2 short sentences, non-technical, written for a dashboard user: describe what changed for them, never the implementation (no header names, endpoints, middleware, storage mechanisms, internal tools). See `.server-changes/README.md` for full guidance. diff --git a/.github/workflows/base-images.yml b/.github/workflows/base-images.yml index 13abecdad7..2c23246de3 100644 --- a/.github/workflows/base-images.yml +++ b/.github/workflows/base-images.yml @@ -39,6 +39,7 @@ jobs: build_packages: ${{ steps.config.outputs.build_packages }} suite: ${{ steps.config.outputs.suite }} snapshot: ${{ steps.config.outputs.snapshot }} + publish_id: ${{ steps.config.outputs.publish_id }} source_date_epoch: ${{ steps.config.outputs.source_date_epoch }} push: ${{ steps.config.outputs.push }} steps: @@ -52,6 +53,7 @@ jobs: SNAPSHOT_INPUT: ${{ inputs.debian_snapshot }} EVENT_NAME: ${{ github.event_name }} REF: ${{ github.ref }} + SHA: ${{ github.sha }} run: | PACKAGES="$(jq -er '.packages' base-images/images.json)" BUILD_PACKAGES="$(jq -er '.buildPackages' base-images/images.json)" @@ -89,6 +91,7 @@ jobs: echo "build_packages=$BUILD_PACKAGES" echo "suite=$SUITE" echo "snapshot=$SNAPSHOT" + echo "publish_id=${SNAPSHOT:0:8}-${SNAPSHOT:9:6}-${SHA:0:7}" echo "source_date_epoch=$EPOCH" echo "push=$PUSH" } >> "$GITHUB_OUTPUT" @@ -164,7 +167,11 @@ jobs: platforms: linux/amd64,linux/arm64 provenance: false outputs: type=image,push=true,rewrite-timestamp=true - tags: triggerdotdev/${{ matrix.image.repo }}:${{ matrix.image.tag }} + # The dated tag is immutable and keeps every published digest + # tag-referenced forever; shipped CLI releases pin these digests + tags: | + triggerdotdev/${{ matrix.image.repo }}:${{ matrix.image.tag }} + triggerdotdev/${{ matrix.image.repo }}:${{ matrix.image.tag }}-${{ needs.setup.outputs.publish_id }} build-args: | BASE_IMAGE=${{ matrix.image.base }} DEBIAN_SNAPSHOT=${{ needs.setup.outputs.snapshot }} @@ -187,7 +194,9 @@ jobs: platforms: linux/amd64,linux/arm64 provenance: false outputs: type=image,push=true,rewrite-timestamp=true - tags: triggerdotdev/${{ matrix.image.repo }}:${{ matrix.image.tag }}-build + tags: | + triggerdotdev/${{ matrix.image.repo }}:${{ matrix.image.tag }}-build + triggerdotdev/${{ matrix.image.repo }}:${{ matrix.image.tag }}-build-${{ needs.setup.outputs.publish_id }} build-args: | BASE_IMAGE=${{ matrix.image.base }} DEBIAN_SNAPSHOT=${{ needs.setup.outputs.snapshot }} diff --git a/.github/workflows/fk-cascade-guard.yml b/.github/workflows/fk-cascade-guard.yml new file mode 100644 index 0000000000..eae272354d --- /dev/null +++ b/.github/workflows/fk-cascade-guard.yml @@ -0,0 +1,35 @@ +name: "🛡️ FK Cascade Index Guard" + +on: + workflow_call: + +permissions: + contents: read + +jobs: + fk-cascade-guard: + runs-on: warp-ubuntu-latest-x64-16x + + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: ⎔ Setup pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + version: 10.33.2 + + - name: ⎔ Setup node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24.18.0 + cache: "pnpm" + + - name: 📥 Download deps + run: pnpm install --frozen-lockfile + + - name: 🛡️ FK cascade index guard + run: pnpm --filter webapp run guard:fk-cascade-index -- --check diff --git a/.github/workflows/pr_checks.yml b/.github/workflows/pr_checks.yml index 7de0531946..f8013c50d1 100644 --- a/.github/workflows/pr_checks.yml +++ b/.github/workflows/pr_checks.yml @@ -58,6 +58,7 @@ jobs: - '.github/workflows/unit-tests-webapp.yml' - '.github/workflows/e2e-webapp.yml' - '.github/workflows/runops-guard.yml' + - '.github/workflows/fk-cascade-guard.yml' - '.configs/**' - 'package.json' - 'pnpm-lock.yaml' @@ -150,6 +151,11 @@ jobs: if: needs.changes.outputs.webapp == 'true' uses: ./.github/workflows/runops-guard.yml + fk-cascade-guard: + needs: changes + if: needs.changes.outputs.webapp == 'true' + uses: ./.github/workflows/fk-cascade-guard.yml + webapp: needs: changes if: needs.changes.outputs.webapp == 'true' @@ -206,6 +212,7 @@ jobs: - code-quality - typecheck - runops-guard + - fk-cascade-guard - webapp - e2e-webapp - packages diff --git a/.github/workflows/unit-tests-packages.yml b/.github/workflows/unit-tests-packages.yml index 465174fb95..ceb0b7b49d 100644 --- a/.github/workflows/unit-tests-packages.yml +++ b/.github/workflows/unit-tests-packages.yml @@ -99,6 +99,7 @@ jobs: pull redis:7.2 pull testcontainers/ryuk:0.14.0 pull electricsql/electric:1.2.4 + pull otel/opentelemetry-collector-k8s:0.158.0@sha256:c09130a633196a5becee164411473a0932ecf223f94fda6dab5f22798ff9f376 echo "Image pre-pull complete" - name: 📥 Download deps diff --git a/.oxlintrc.json b/.oxlintrc.json index 61e3a684a2..8ad9675e36 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,5 +1,8 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", + "categories": { + "correctness": "error" + }, "plugins": ["typescript", "import", "react"], "jsPlugins": [ "./oxlint-plugins/no-thrown-unawaited-redirect.mjs", @@ -32,7 +35,7 @@ "no-control-regex": "off", "typescript/no-non-null-asserted-optional-chain": "off", "no-unused-expressions": [ - "warn", + "error", { "allowShortCircuit": true, "allowTernary": true diff --git a/.server-changes/README.md b/.server-changes/README.md index 12815039ac..e13ebec4a7 100644 --- a/.server-changes/README.md +++ b/.server-changes/README.md @@ -4,11 +4,13 @@ This directory tracks changes to server-only components (webapp, supervisor, etc ## When to add a file -**Server-only PRs**: If your PR only changes `apps/webapp/`, `apps/supervisor/`, or other server components (and does NOT change anything in `packages/`), add a `.server-changes/` file. +These entries are **user-facing release notes**, not a catalog of every change. The test is "would a user or customer care about this change?", not "did I touch a server app?". Add one only when a server-only change is something a user would notice, act on, or want to hear about (a feature, a bug fix they could have hit, a behavior or performance change they would feel). Skip it for internal-only or admin-only changes, refactors, test-only changes, chores, and performance tuning with no user-visible effect. Anyone who wants the exact history reads the commits. When in doubt, ask a maintainer rather than adding a note by default. -**Mixed PRs** (both packages and server): Just add a changeset as usual. No `.server-changes/` file needed — the changeset covers it. +**Server-only PRs**: If your PR only changes `apps/webapp/`, `apps/supervisor/`, or other server components (and does NOT change anything in `packages/`) AND the change is user-facing, add a `.server-changes/` file. -**Package-only PRs**: Just add a changeset as usual. +**Mixed PRs** (both packages and server): the changeset covers it, so no `.server-changes/` file is needed. If the package change is internal and needs no changeset but the server change is user-facing, add a `.server-changes/` file for it. + +**Package-only PRs**: Just add a changeset as usual, when the change is user-facing. ## File format diff --git a/.server-changes/dequeue-worker-version-freshness.md b/.server-changes/dequeue-worker-version-freshness.md new file mode 100644 index 0000000000..d584ef2c56 --- /dev/null +++ b/.server-changes/dequeue-worker-version-freshness.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Fixed a brief window after promoting or rolling back a deployment where newly triggered runs could still execute on the previous version. New runs now pick up the current version immediately. diff --git a/.server-changes/hide-root-api-key-creation-date.md b/.server-changes/hide-root-api-key-creation-date.md new file mode 100644 index 0000000000..311febd2fb --- /dev/null +++ b/.server-changes/hide-root-api-key-creation-date.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Root API keys no longer show an environment creation timestamp as their creation date. diff --git a/.server-changes/org-settings-app-version.md b/.server-changes/org-settings-app-version.md new file mode 100644 index 0000000000..43ae2cc5c3 --- /dev/null +++ b/.server-changes/org-settings-app-version.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +The app version shown on the organization settings page now reports the real version instead of v0.0.0. diff --git a/.server-changes/paused-environment-stays-paused-after-deploy.md b/.server-changes/paused-environment-stays-paused-after-deploy.md new file mode 100644 index 0000000000..4c35b6e215 --- /dev/null +++ b/.server-changes/paused-environment-stays-paused-after-deploy.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Fix paused environments starting to run work again after a deploy: a paused environment now stays paused until you resume it. diff --git a/.server-changes/settings-back-to-app-org.md b/.server-changes/settings-back-to-app-org.md new file mode 100644 index 0000000000..64464505ee --- /dev/null +++ b/.server-changes/settings-back-to-app-org.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +The "Back to app" button in organization settings now returns you to that organization instead of your most recently used one. diff --git a/.server-changes/transaction-resilience-during-db-interruptions.md b/.server-changes/transaction-resilience-during-db-interruptions.md new file mode 100644 index 0000000000..c05bfd5f22 --- /dev/null +++ b/.server-changes/transaction-resilience-during-db-interruptions.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Triggering tasks is now more resilient to brief, transient service interruptions, so short stalls are less likely to surface as errors. diff --git a/AGENTS.md b/AGENTS.md index cc947c7a57..2a2dea78b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,21 @@ Dynamic imports add unnecessary overhead in hot paths and make code harder to an ## Changesets and Server Changes -When modifying any public package (`packages/*` or `integrations/*`), add a changeset: +Changesets and `.server-changes/` files are **user-facing release notes**. They ship verbatim into the changelog that customers read to decide what to upgrade for or pay attention to. They are not a catalog of every change: anyone who wants the exact history reads the commits. So the question is not "did I touch a public package or a server app?" but **"would a user or customer care about this change?"** + +**Add one** when the change is something a user would notice, act on, or want to hear about: a new feature, a bug fix they could have hit, a behavior or performance change they would feel, a breaking change. + +**Skip it** (no changeset, no `.server-changes/` file) when the change is not worth communicating to users, even if it touches a public package or a server app. For example: + +- internal-only or admin-only changes, refactors, test-only changes, chores +- performance or query tuning with no user-visible behavior change +- changes to a public package that is not consumed independently (e.g. `@trigger.dev/redis-worker`), where a version bump means nothing to a user + +When in doubt, ask a maintainer rather than adding a note by default. An unnecessary entry is noise in the changelog, not a safe default. + +### How to add one + +When a **public package** (`packages/*` or `integrations/*`) change is user-facing, add a changeset: ```bash pnpm run changeset:add @@ -102,9 +116,9 @@ pnpm run changeset:add - Confirm with maintainers before selecting **minor** (new features) - **Never** select major without explicit approval -When modifying only server components (`apps/webapp/`, `apps/supervisor/`, etc.) with no package changes, add a `.server-changes/` file instead. See `.server-changes/README.md` for format and documentation. +When a **server-only** change (`apps/webapp/`, `apps/supervisor/`, etc., with no package changes) is user-facing, add a `.server-changes/` file instead. See `.server-changes/README.md` for format and documentation. -**Write the description for users, not maintainers.** Both changesets and `.server-changes/` notes ship verbatim in user-visible release notes. Lead with what changed *for the user* - one plain sentence describing behavior, not implementation, and never naming internal tools or infra. The full writing guidance in `.server-changes/README.md` applies to changesets too. +**Write the description for users, not maintainers.** Both changesets and `.server-changes/` notes ship verbatim in user-visible release notes. Lead with what changed *for the user*: one plain sentence describing behavior, not implementation, and never naming internal tools or infra. The full writing guidance in `.server-changes/README.md` applies to changesets too. ## Dependency Pinning diff --git a/CHANGESETS.md b/CHANGESETS.md index db9d5719d9..74f8adda6c 100644 --- a/CHANGESETS.md +++ b/CHANGESETS.md @@ -4,11 +4,13 @@ Trigger.dev uses [changesets](https://github.com/changesets/changesets) to manag ## Adding a changeset (package changes) -To add a changeset, use `pnpm run changeset:add` and follow the instructions [here](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md). Please only ever select one of our public packages when adding a changeset. +Changesets and `.server-changes/` files are user-facing release notes, not a catalog of every change. Add one only when the change is something a user would notice or act on. Skip it for internal-only changes, refactors, chores, and packages that are not consumed independently (e.g. `@trigger.dev/redis-worker`). Anyone who wants the exact history reads the commits. + +To add a changeset, use `pnpm run changeset:add` and follow the [Changesets adding-a-changeset guide](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md). Please only ever select one of our public packages when adding a changeset. ## Adding a server change (server-only changes) -If your PR only changes server components (`apps/webapp/`, `apps/supervisor/`, etc.) and does NOT change any published packages, add a `.server-changes/` file instead of a changeset: +If your PR only changes server components (`apps/webapp/`, `apps/supervisor/`, etc.), does NOT change any published packages, AND the change is user-facing, add a `.server-changes/` file instead of a changeset: ```sh cat > .server-changes/fix-batch-queue-stalls.md << 'EOF' @@ -24,17 +26,19 @@ EOF - `area`: `webapp` | `supervisor` - `type`: `feature` | `fix` | `improvement` | `breaking` -For **mixed PRs** (both packages and server): just add a changeset. No `.server-changes/` file needed. +For **mixed PRs** (both packages and server): the changeset covers it, so no `.server-changes/` file is needed. If the package change is internal and needs no changeset but the server change is user-facing, add a `.server-changes/` file for it. See `.server-changes/README.md` for full documentation. ## When to add which +Only for user-facing changes. Skip the note entirely for internal-only or admin-only changes, refactors, and chores. + | PR changes | What to add | |---|---| -| Only packages (`packages/`) | Changeset (`pnpm run changeset:add`) | -| Only server (`apps/`) | `.server-changes/` file | -| Both packages and server | Just the changeset | +| Only packages (`packages/` or `integrations/`) | Changeset (`pnpm run changeset:add`), if the package change is user-facing | +| Only server (`apps/`) | `.server-changes/` file, if the server change is user-facing | +| Both packages and server | The changeset covers it; if the package change needs no changeset but the server change is user-facing, add a `.server-changes/` file | ## Release instructions (CI) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fb41c308fd..127b1e64e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -247,7 +247,7 @@ If your change touches core infrastructure, modifies widely-used code paths, or We use [changesets](https://github.com/changesets/changesets) to manage our package versions and changelogs. If you've never used changesets before, first read [their guide here](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md). -If you are contributing a change to any packages in this monorepo (anything in either the `/packages` or `/integrations` directories), then you will need to add a changeset to your Pull Requests before they can be merged. +Changesets are user-facing release notes, not a catalog of every change. If you are contributing a **user-facing** change to a package in this monorepo (anything in `/packages` or `/integrations` that a user would notice or act on), add a changeset to your Pull Request before it can be merged. Skip the changeset for internal-only changes, refactors, chores, and packages that are not consumed independently (e.g. `@trigger.dev/redis-worker`), where a version bump means nothing to a user. To add a changeset, run the following command in the root of the repo @@ -265,7 +265,7 @@ Most of the time the changes you'll make are likely to be categorized as patch r ## Adding server changes -Changesets only track published npm packages. If your PR only changes server components (`apps/webapp/`, `apps/supervisor/`, etc.) with no package changes, add a `.server-changes/` file so the change appears in release notes. +Changesets only track published npm packages. If your PR only changes server components (`apps/webapp/`, `apps/supervisor/`, etc.) with no package changes AND the change is user-facing, add a `.server-changes/` file so the change appears in release notes. Skip it for internal-only or admin-only changes, refactors, and chores. Create a markdown file with a descriptive name: @@ -286,13 +286,13 @@ EOF The body text (below the frontmatter) is a one-line description of the change. Keep it concise — it will appear in release notes. -**When to add which:** +**When to add which** (only for user-facing changes; skip the note entirely for internal-only or admin-only changes, refactors, and chores): | PR changes | What to add | |---|---| -| Only packages (`packages/`) | Changeset | -| Only server (`apps/`) | `.server-changes/` file | -| Both packages and server | Just the changeset | +| Only packages (`packages/` or `integrations/`) | Changeset (if the package change is user-facing) | +| Only server (`apps/`) | `.server-changes/` file (if the server change is user-facing) | +| Both packages and server | The changeset covers it; if the package change needs no changeset but the server change is user-facing, add a `.server-changes/` file | See `.server-changes/README.md` for more details. diff --git a/ONBOARDING.md b/ONBOARDING.md new file mode 100644 index 0000000000..c7e1ebaf68 --- /dev/null +++ b/ONBOARDING.md @@ -0,0 +1,221 @@ +# Onboarding: taking over the hosted webhooks PR (#4344) for design + +You are picking up **PR #4344 "hosted webhooks, agent channels, and human-in-the-loop"** to own the UX and front-end. This doc gets you from a clean machine to a running dashboard with realistic webhook data you can screenshot, restyle, and iterate on. + +The feature is built and green (all backend plumbing, all four dashboard surfaces, the in-app test console). Your job is the visual and interaction design of the dashboard surfaces, not the backend. Everything below is oriented around that. + +--- + +## 1. What you are designing + +Hosted webhooks let a Trigger.dev user receive and verify a provider's webhooks (Stripe, GitHub, and so on) as a task, with no ingress or verification code of their own. A `webhook()` handler in their project gets a hosted URL; deliveries to that URL are verified, recorded, and routed to their `onEvent` handler. + +The dashboard has **four surfaces you own**, all under the "Webhooks" nav section (teal icon): + +| Surface | Route (under `/orgs/:org/projects/:project/env/:env`) | What it shows | +| --- | --- | --- | +| **Deliveries list** | `/webhooks` | Every delivery across all endpoints in the environment. Runs-style filter bar (Status, Webhook, Created, plus a More-filters menu for Delivery ID / Run ID), applied-filter pills, a Webhook column linking to the handler. This is the main screen. | +| **Delivery detail** | `/webhooks/deliveries/:deliveryParam` | One delivery. Main panel is a tabbed view (Event payload / Request headers) rendered as JSON. Sidebar property table (status badge, webhook + run links, external delivery id, idempotency key, timestamps, computed duration, error). Also has a friendly "not available / retained for N days" empty state for expired or bogus links. | +| **Handler detail + Console** | `/webhooks/:webhookParam` | The handler (the `webhook()` in the user's code). Tabs: Deliveries, Runs, Endpoints. This page also hosts the **Webhook Console / Composer** (see section 5), the tool you will lean on for data. | +| **Endpoint detail** | `/webhooks/endpoints/:endpointParam` | One endpoint. Left: scoped deliveries. Right: a **Connect** card (webhook URL, signing secret set/rotate/generate, provider setup rendered from the verifier config), Routing, Scope, Metadata. | + +The status vocabulary, badges, and colors live in `components/webhookDeliveries/v1/DeliveryStatus.tsx` and `components/webhookEndpoints/v1/EndpointStatus.tsx`. The nav accent color is a Tailwind token `--color-webhooks` (teal), used via `text-webhooks`. + +--- + +## 2. Get the code + +You need the PR branch, `feat/hosted-webhook-ingress`. + +```bash +git clone https://github.com/triggerdotdev/trigger.dev.git +cd trigger.dev +gh pr checkout 4344 # lands you on feat/hosted-webhook-ingress +``` + +If you plan to push design changes back to this branch, coordinate with Eric first: the branch is rebased and force-pushed periodically, so agree on timing or work on a child branch and open a follow-up. + +Toolchain: pnpm 10.33.2 via corepack, Node 22+. Use `corepack pnpm` (a bare `pnpm` can be an old global that wipes `node_modules`). + +```bash +corepack enable +corepack pnpm install +``` + +--- + +## 3. Bring the stack up + +Four services and the webapp. Run from the repo root. + +```bash +# 1. Core dev services: Postgres, Redis, Electric, MinIO, ClickHouse, s2-lite +corepack pnpm run docker + +# 2. Config +cp .env.example .env +``` + +Now edit `.env` and add the two webhook-delivery replication lines (they are NOT in `.env.example`, and without them the Deliveries list looks empty even after you send webhooks, see section 5): + +```bash +# webhook deliveries replication (required for the Deliveries list/detail to populate) +WEBHOOK_DELIVERIES_REPLICATION_CLICKHOUSE_URL=http://default:password@localhost:8123 +WEBHOOK_DELIVERIES_REPLICATION_ENABLED=1 +``` + +Then migrate, seed, build, and run: + +```bash +corepack pnpm run db:migrate +corepack pnpm run db:seed # creates the References org + hello-world project + +# Build the pieces you will run (do these sequentially, not with db:seed running) +corepack pnpm run build --filter webapp --filter trigger.dev --filter "@trigger.dev/sdk" + +# Run the webapp (http://localhost:3030) +corepack pnpm run dev --filter webapp +curl -s http://localhost:3030/healthcheck # verify +``` + +**Log in (dev):** open http://localhost:3030, submit the email `local@trigger.dev`. Dev auto-verifies the magic link (watch the webapp log for `/magic?token=`). That seeded user is an org admin, which matters for the next step. + +--- + +## 4. Turn the feature on + +The dashboard is gated by a feature flag, `hasWebhooksAccess` (default off). + +- The seeded dev user `local@trigger.dev` is an **admin**, and admins bypass the flag, so on a fresh seed the Webhooks nav section is already visible to you. Nothing to do. +- If you use a non-admin user, set `featureFlags.hasWebhooksAccess = true` on the `Organization` row to reveal the nav section. (A global `FeatureFlag` row with key `hasWebhooksAccess` makes the pages reachable by URL, but the left nav reads only org-level flags, so the section stays hidden for non-admins.) + +If the "Webhooks" section is missing from the left nav, this flag is why. + +--- + +## 5. Get nice data (the part that matters) + +Delivery rows are what make these screens interesting: a spread of providers, statuses, payloads, timestamps. Here is how the data flows and how to produce it. + +### The pipeline (why an empty list is usually a setup issue, not a bug) + +`ingest -> engine (verify, filter, route) -> Postgres WebhookDelivery rows -> replication -> ClickHouse`. The Deliveries **list orders and paginates from ClickHouse**, then hydrates every visible field from Postgres. So if replication is off (section 3), you can create deliveries and still see an empty list. Enable the two replication env vars and restart the webapp. + +One caveat baked into the design: replication starts streaming from the moment it is enabled, so deliveries written **before** you turned it on will not appear. Turn replication on first, then generate data. + +### Fastest path: the seed script + +There is a seed script that inserts a full, stable dataset directly into both stores (Postgres and ClickHouse), so you get realistic screens on a fresh DB with no workers, no `trigger dev`, and no signing secrets to set: + +```bash +corepack pnpm --filter webapp run db:seed:webhooks +# optional: deliveries per endpoint (default 45) +corepack pnpm --filter webapp run db:seed:webhooks -- 60 +``` + +It creates six endpoints across different providers and verifier schemes (Stripe, GitHub, Slack, Svix, Discord, and a custom shared-secret one, with a mix of active/inactive and secret-set/not-set), then a spread of deliveries over the last two weeks covering **every** delivery status (SUCCEEDED, FAILED, FILTERED, PENDING, PROCESSING), realistic per-provider payloads and headers, and a mix of test and live. It attaches to the first DEVELOPMENT environment your local user can see (set `WEBHOOK_SEED_PROJECT=""` to target a specific one), and prints the exact Deliveries URL when it finishes. Re-running clears and reseeds that environment, so you always get the same clean dataset. The script is `apps/webapp/seed-webhook-deliveries.ts`; edit the `ENDPOINTS` array or the status weights to shape the data to whatever you are designing. + +Because it writes the ClickHouse rows directly, seeded data shows up **without** the replication setup in section 3. That replication env is only needed for the live and Composer paths below. (The seed uses `WEBHOOK_DELIVERIES_REPLICATION_CLICKHOUSE_URL` if set, otherwise `CLICKHOUSE_URL`, which is already in `.env.example`.) + +This is the recommended way to get data. The interactive paths below are for exercising the live pipeline (real verification, real routed runs) or the in-app test console. + +### Interactive: create an endpoint (one-time) + +The Composer sends to an endpoint, and endpoints only exist once a Trigger project that declares a `webhook()` has been dev-run or deployed. Quickest path: a tiny demo project. + +```ts +// demo/src/trigger/demo-webhook.ts +import { webhook, webhooks } from "@trigger.dev/sdk"; + +export const demoWebhook = webhook({ + id: "demo-webhook", + source: webhooks.custom<{ message: string }>({ /* generic HMAC */ }), + onEvent: async ({ event, headers, ctx }) => { + // event is the parsed body, headers is a Web Headers object + }, +}); + +// A real provider, for realistic payloads: +export const stripeWebhook = webhook({ + id: "stripe-webhook", + source: webhooks.stripe(), + onEvent: async ({ event }) => {}, +}); +``` + +Link that demo project to your local build and run `trigger dev` (see `AGENTS.md` "Testing with the hello-world Reference Project" for linking; the `triggerdotdev/references` repo has ready-made projects). Running `trigger dev` registers the `webhook()` handlers, which creates their endpoints. Set each endpoint's signing secret from the **endpoint detail Connect card** (Generate or paste). + +### Interactive: fire deliveries with the Webhook Console + +Open the handler detail page (`/webhooks/:webhookParam`). It hosts the **Composer** (`components/webhookConsole/WebhookComposer.tsx`). It has four source tabs and four signature modes, and it injects the delivery straight through the engine in-process, so it is fast and does not consume any real rate budget: + +- **Sample tab**: pick a real provider event from the built-in catalog (`@internal/webhook-sources`, six first-class providers plus a large sample manifest). This is the fastest way to get realistic Stripe / GitHub / Svix / Square / Discord payloads with correct-looking headers. +- **Body tab**: hand-write any JSON. +- **Replay tab**: re-send a prior delivery. +- **AI tab**: generate a payload with a prompt. +- **Signature modes** `signed | unsigned | tampered | simulate`: this is how you produce a **spread of delivery statuses**. `signed` (with a secret set) verifies and routes to a SUCCEEDED delivery; `unsigned` and `tampered` produce failed/rejected deliveries. Send a mix to populate every status badge you need to design. + +To get SUCCEEDED deliveries whose **runs** also complete (nicest end-to-end data), keep the demo project's `trigger dev` running so the routed task actually executes. + +### Interactive: a real provider (most realistic) + +For genuine payloads and headers, point the Stripe CLI at an endpoint: `stripe listen --forward-to http://localhost:3030/webhooks/v1/ingest/`, set that endpoint's `whsec` via the Connect card, then `stripe trigger payment_intent.succeeded`. + +--- + +## 6. Where the front-end code lives + +| Area | Path | +| --- | --- | +| Routes (pages) | `apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks*` | +| Deliveries list / detail components | `apps/webapp/app/components/webhookDeliveries/v1/` (`DeliveriesTable`, `DeliveryStatus`, `WebhookDeliveryFilters`, `DeliveryTimeline`, `useDeliveriesLiveReload`) | +| Endpoint components | `apps/webapp/app/components/webhookEndpoints/v1/` (`EndpointsTable`, `EndpointStatus`) | +| Console / Composer | `apps/webapp/app/components/webhookConsole/` (`WebhookComposer`, `SampleSourcePicker`, `ReplaySourcePicker`) | +| Data (presenters, read-only from your side) | `apps/webapp/app/presenters/v3/WebhookDeliveriesListPresenter.server.ts`, `WebhookDeliveryDetailPresenter.server.ts`, `WebhookDetailPresenter.server.ts`, `webhookComposerEndpoints.server.ts` | +| Nav entry | `apps/webapp/app/components/navigation/SideMenu.tsx` (the `staticSections` "webhooks" push) | +| Path builders | `apps/webapp/app/utils/pathBuilder.ts` (`v3WebhooksPath`, `v3WebhookDeliveryPath`, `v3WebhookEndpointPath`, `v3WebhookTaskPath`) | +| Accent color token | `apps/webapp/app/tailwind.css` (`--color-webhooks`, used as `text-webhooks`) | +| Data seed script | `apps/webapp/seed-webhook-deliveries.ts` (run via `db:seed:webhooks`) | + +**Styling:** the webapp is on Tailwind v4 (CSS-first `@theme` in `apps/webapp/app/tailwind.css`, there is no `tailwind.config.js`). Add or change design tokens there. + +**Design language to match:** these screens deliberately reuse the Runs page primitives (the filter bar is built from `RunFilters` / `SharedFilters`, the tables mirror the Runs table cells). Match the Runs and Sessions pages, not a new visual system. + +--- + +## 7. Iterating + +- **HMR vs restart:** editing a component (`.tsx`) hot-reloads. Editing a `.server.ts` file makes the Remix dev server restart the app (a brief connection refused, then it comes back). Editing Tailwind tokens hot-reloads. +- **Screenshots:** capture from the running dashboard at http://localhost:3030. Save shots outside the repo or to a scratch folder so they do not get committed. +- **Typecheck after non-trivial changes:** `corepack pnpm run typecheck --filter webapp` (about 1 to 2 minutes). For small style tweaks, trust it and let CI catch anything. +- **One boundary gotcha that the dev server will NOT catch:** route files must not leak server-only imports into the client bundle. The dev server tolerates it, but the production build fails. If you touch a route file and import anything server-only, run `corepack pnpm --filter webapp run build:remix` before pushing. Pure component and style edits are unaffected. + +--- + +## 8. Shipping your changes + +Follow the repo PR workflow: + +- Format and lint before committing: `corepack pnpm run format` (oxfmt) and `corepack pnpm run lint:fix` (oxlint). CI enforces both. +- Commit style is Conventional Commits, for example `feat(webapp): redesign webhook deliveries table`. No emoji, no attribution footer. +- The PR is a **draft** awaiting an AI review pass, then a human review, before it flips to ready. Do not flip it to ready yourself; push your commits and let Eric coordinate the review and any rebase onto `main`. +- CI to expect: `code-quality` (oxfmt + oxlint), `typecheck`, webapp unit shards, and the Playwright `e2e-webapp` job. Style-only changes usually only risk `code-quality`. + +--- + +## 9. Quick reference + +- **Webapp:** http://localhost:3030 (port comes from `REMIX_APP_PORT`, falling back to `PORT`/3030). +- **Default docker services:** Postgres 5432, Redis 6379, ClickHouse HTTP 8123 (`default:password`), MinIO, Electric, s2-lite. +- **Feature flag:** `hasWebhooksAccess` (admins bypass). +- **Seed data:** `corepack pnpm --filter webapp run db:seed:webhooks` (append `-- ` for deliveries per endpoint). +- **Must-set env for data to show:** `WEBHOOK_DELIVERIES_REPLICATION_ENABLED=1` and `WEBHOOK_DELIVERIES_REPLICATION_CLICKHOUSE_URL=http://default:password@localhost:8123`. +- **Login:** `local@trigger.dev`, magic link auto-verifies in dev. +- **Feature docs:** `docs/webhooks/` (overview, sources, connect, deliveries, channels, human-in-the-loop). Read `overview.mdx` and `deliveries.mdx` first for the mental model behind the screens. +- **PR:** https://github.com/triggerdotdev/trigger.dev/pull/4344 + +--- + +## 10. Mental model in one paragraph + +A user writes a `webhook()` in their project. On deploy (or `trigger dev`) that handler gets one or more hosted endpoints, each with a signing secret. A provider POSTs to the endpoint's URL; the engine verifies the signature, optionally filters, records a `WebhookDelivery`, and triggers the routed task run. The dashboard reads those deliveries: the list orders them out of ClickHouse and hydrates the rest from Postgres, the detail page reads Postgres directly (it holds the only copy of the event payload and headers). Everything you design sits on top of that delivery record and the endpoint that produced it. diff --git a/apps/supervisor/package.json b/apps/supervisor/package.json index 2725fe2b72..6f70b603b4 100644 --- a/apps/supervisor/package.json +++ b/apps/supervisor/package.json @@ -21,7 +21,7 @@ "ioredis": "~5.6.0", "p-limit": "^6.2.0", "prom-client": "^15.1.0", - "socket.io": "4.7.4", + "socket.io": "4.8.3", "std-env": "^3.8.0", "zod": "3.25.76" }, diff --git a/apps/webapp/app/assets/icons/WebhookIcon.tsx b/apps/webapp/app/assets/icons/WebhookIcon.tsx index cbd6e85ddd..7841fe3907 100644 --- a/apps/webapp/app/assets/icons/WebhookIcon.tsx +++ b/apps/webapp/app/assets/icons/WebhookIcon.tsx @@ -1,11 +1,15 @@ export function WebhookIcon({ className }: { className?: string }) { return ( - + + + + ); diff --git a/apps/webapp/app/components/ListPagination.tsx b/apps/webapp/app/components/ListPagination.tsx index 32d73d6832..64e62af737 100644 --- a/apps/webapp/app/components/ListPagination.tsx +++ b/apps/webapp/app/components/ListPagination.tsx @@ -14,13 +14,31 @@ type List = { export const DirectionSchema = z.union([z.literal("forward"), z.literal("backward")]); export type Direction = z.infer; -export function ListPagination({ list, className }: { list: List; className?: string }) { +export function ListPagination({ + list, + className, + cursorParam = "cursor", + directionParam = "direction", +}: { + list: List; + className?: string; + cursorParam?: string; + directionParam?: string; +}) { const bothDisabled = !list.pagination.previous && !list.pagination.next; return (
- - + +
@@ -53,8 +79,16 @@ function PreviousButton({ cursor }: { cursor?: string }) { ); } -function NextButton({ cursor }: { cursor?: string }) { - const path = useCursorPath(cursor, "forward"); +function NextButton({ + cursor, + cursorParam, + directionParam, +}: { + cursor?: string; + cursorParam: string; + directionParam: string; +}) { + const path = useCursorPath(cursor, "forward", cursorParam, directionParam); return (
@@ -75,7 +109,12 @@ function NextButton({ cursor }: { cursor?: string }) { ); } -function useCursorPath(cursor: string | undefined, direction: Direction) { +function useCursorPath( + cursor: string | undefined, + direction: Direction, + cursorParam: string, + directionParam: string +) { const location = useLocation(); if (!cursor) { @@ -83,7 +122,7 @@ function useCursorPath(cursor: string | undefined, direction: Direction) { } const search = new URLSearchParams(location.search); - search.set("cursor", cursor); - search.set("direction", direction); + search.set(cursorParam, cursor); + search.set(directionParam, direction); return location.pathname + "?" + search.toString(); } diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index 8790e47942..465346ad15 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -14,13 +14,13 @@ import { useFeatures } from "~/hooks/useFeatures"; import { type MatchedOrganization } from "~/hooks/useOrganizations"; import { cn } from "~/utils/cn"; import { + organizationPath, organizationRolesPath, organizationSettingsPath, organizationSlackIntegrationPath, organizationSsoPath, organizationTeamPath, organizationVercelIntegrationPath, - rootPath, v3BillingLimitsPath, v3BillingPath, v3PrivateConnectionsPath, @@ -72,7 +72,7 @@ export function OrganizationSettingsSideMenu({ diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index f69d0f16bc..1a25001e18 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -12,6 +12,7 @@ import { useRevalidator, useSubmit, } from "@remix-run/react"; +import { WebhookIcon } from "~/assets/icons/WebhookIcon"; import { LayoutGroup, motion } from "framer-motion"; import { type CSSProperties, @@ -131,6 +132,7 @@ import { v3SessionsPath, v3UsagePath, v3WaitpointTokensPath, + v3WebhooksPath, } from "~/utils/pathBuilder"; import { FreePlanUsage } from "../billing/FreePlanUsage"; import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence"; @@ -1201,6 +1203,19 @@ export function SideMenu({ isCollapsed={isCollapsed} yieldActiveToFavorite /> + {(user.admin || user.isImpersonating || featureFlags.hasWebhooksAccess) && ( + } + isCollapsed={isCollapsed} + yieldActiveToFavorite + /> + )}
{orderedSectionIds.map((sectionId) => { diff --git a/apps/webapp/app/components/navigation/favoritePages.tsx b/apps/webapp/app/components/navigation/favoritePages.tsx index 4439aadedf..c07ba2d6d5 100644 --- a/apps/webapp/app/components/navigation/favoritePages.tsx +++ b/apps/webapp/app/components/navigation/favoritePages.tsx @@ -40,6 +40,7 @@ import { TasksIcon } from "~/assets/icons/TasksIcon"; import { UsageIcon } from "~/assets/icons/UsageIcon"; import { UserGroupIcon } from "~/assets/icons/UserGroupIcon"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; +import { WebhookIcon } from "~/assets/icons/WebhookIcon"; import { VercelLogo } from "~/components/integrations/VercelLogo"; import { useOptionalUser } from "~/hooks/useUser"; import { type FavoritePage } from "~/services/dashboardPreferences.server"; @@ -71,6 +72,7 @@ const FAVORITE_PAGE_ICONS: Record< "task-agent": { icon: CubeSparkleIcon, activeColor: "text-agents" }, runs: { icon: RunsIcon, activeColor: "text-runs" }, sessions: { icon: AIChatIcon, activeColor: "text-sessions" }, + webhooks: { icon: WebhookIcon, activeColor: "text-webhooks" }, prompts: { icon: AIPenIcon, activeColor: "text-aiPrompts" }, models: { icon: Box3DIcon, activeColor: "text-models" }, logs: { icon: LogsIcon, activeColor: "text-logs" }, @@ -213,6 +215,7 @@ const ENV_PAGE_META: Record = { "": { icon: "tasks", name: "Tasks", singular: "Task" }, runs: { icon: "runs", name: "Runs", singular: "Run" }, sessions: { icon: "sessions", name: "Sessions", singular: "Session" }, + webhooks: { icon: "webhooks", name: "Webhook deliveries" }, prompts: { icon: "prompts", name: "Prompts", singular: "Prompt" }, models: { icon: "models", name: "Models", singular: "Model" }, logs: { icon: "logs", name: "Logs" }, diff --git a/apps/webapp/app/components/primitives/CopyableText.tsx b/apps/webapp/app/components/primitives/CopyableText.tsx index 9634ad1ac0..bf5898390a 100644 --- a/apps/webapp/app/components/primitives/CopyableText.tsx +++ b/apps/webapp/app/components/primitives/CopyableText.tsx @@ -12,6 +12,7 @@ export function CopyableText({ asChild, variant, hideTooltip, + truncate, }: { value: string; copyValue?: string; @@ -24,6 +25,12 @@ export function CopyableText({ * fire Radix's global "one tooltip open at a time" close and dismiss the parent. */ hideTooltip?: boolean; + /** + * Ellipsise the value rather than letting it overflow its column. For unbreakable strings + * (hashes, opaque ids) that offer no wrap opportunity. The copy button moves into a reserved + * right gutter so it stays visible instead of sitting outside the column. + */ + truncate?: boolean; }) { const [isHovered, setIsHovered] = useState(false); const { copy, copied } = useCopy(copyValue ?? value); @@ -51,15 +58,26 @@ export function CopyableText({ return ( setIsHovered(false)} > - setIsHovered(true)}>{value} + setIsHovered(true)} + > + {value} + e.stopPropagation()} className={cn( - "absolute -right-6 top-0 z-10 size-6 font-sans", + "absolute top-0 z-10 size-6 font-sans", + // Truncated values reserve a right gutter, so the button sits inside it + truncate ? "right-0" : "-right-6", isHovered ? "flex" : "hidden" )} > diff --git a/apps/webapp/app/components/run/RunTimeline.tsx b/apps/webapp/app/components/run/RunTimeline.tsx index 53ddb321f6..edc576980c 100644 --- a/apps/webapp/app/components/run/RunTimeline.tsx +++ b/apps/webapp/app/components/run/RunTimeline.tsx @@ -13,11 +13,11 @@ import { LiveTimer } from "../runs/v3/LiveTimer"; // Types for the RunTimeline component export type TimelineEventState = "complete" | "error" | "inprogress" | "delayed"; -type TimelineLineVariant = "light" | "normal"; +export type TimelineLineVariant = "light" | "normal"; type TimelineStyle = "normal" | "diminished"; -type TimelineEventVariant = +export type TimelineEventVariant = | "start-cap" | "dot-hollow" | "dot-solid" @@ -323,7 +323,7 @@ function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] { export type RunTimelineEventProps = { title: ReactNode; subtitle?: ReactNode; - state?: "complete" | "error" | "inprogress"; + state?: TimelineEventState; variant?: TimelineEventVariant; helpText?: string; style?: TimelineStyle; @@ -483,6 +483,12 @@ export type RunTimelineLineProps = { state?: TimelineEventState; variant?: TimelineLineVariant; style?: TimelineStyle; + /** + * Round the top of a thick ("normal") line. Needed when the line itself starts the thick bar, + * as in the delivery timeline, where nothing above it supplies a `start-cap-thick`. The run + * timeline always precedes its thick line with that cap, so it leaves this off. + */ + roundedTop?: boolean; }; export function RunTimelineLine({ @@ -490,11 +496,12 @@ export function RunTimelineLine({ state, variant = "normal", style = "normal", + roundedTop = false, }: RunTimelineLineProps) { return (
- +
{title} @@ -507,10 +514,12 @@ function LineMarker({ state, variant, style, + roundedTop = false, }: { state?: TimelineEventState; variant: TimelineLineVariant; style?: TimelineStyle; + roundedTop?: boolean; }) { let containerClass = "bg-text-dimmed"; switch (state) { @@ -532,7 +541,7 @@ function LineMarker({ switch (variant) { case "normal": return ( -
+
{state === "inprogress" && (
)} @@ -471,6 +475,7 @@ export function TimeDropdown({ onApply, onValueChange, maxPeriodDays, + clearParams, }: { trigger: ReactNode; period?: string; @@ -484,10 +489,13 @@ export function TimeDropdown({ onValueChange?: (values: TimeFilterApplyValues) => void; /** When set an upgrade message will be shown if you select a period further back than this number of days */ maxPeriodDays?: number; + /** Extra URL params to clear on apply, alongside the default cursor/direction. */ + clearParams?: string[]; }) { const organization = useOptionalOrganization(); const [open, setOpen] = useState(); const { replace } = useSearchParams(); + const extraCleared = Object.fromEntries((clearParams ?? []).map((key) => [key, undefined])); const [fromValue, setFromValue] = useState(from); const [toValue, setToValue] = useState(to); @@ -561,6 +569,7 @@ export function TimeDropdown({ onValueChange(values); } else { replace({ + ...extraCleared, period: periodToApply, cursor: undefined, direction: undefined, @@ -620,6 +629,7 @@ export function TimeDropdown({ } else { // URL mode - navigate replace({ + ...extraCleared, period: undefined, cursor: undefined, direction: undefined, diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index 0019711785..ef0c6e25f5 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -418,13 +418,13 @@ export function TaskRunsTable({ {run.isPending ? ( "–" ) : run.startedAt ? ( - formatDuration(new Date(run.createdAt), new Date(run.startedAt), { + formatDuration(new Date(run.triggeredAt), new Date(run.startedAt), { style: "short", }) ) : run.isCancellable ? ( - + ) : ( - formatDuration(new Date(run.createdAt), new Date(run.updatedAt), { + formatDuration(new Date(run.triggeredAt), new Date(run.updatedAt), { style: "short", }) )} diff --git a/apps/webapp/app/components/runs/v3/TaskTriggerSource.tsx b/apps/webapp/app/components/runs/v3/TaskTriggerSource.tsx index d2cf90f137..e596d9f6f5 100644 --- a/apps/webapp/app/components/runs/v3/TaskTriggerSource.tsx +++ b/apps/webapp/app/components/runs/v3/TaskTriggerSource.tsx @@ -2,6 +2,7 @@ import type { TaskTriggerSource } from "@trigger.dev/database"; import { ClockIcon } from "~/assets/icons/ClockIcon"; import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon"; import { TaskIconSmall } from "~/assets/icons/TaskIcon"; +import { WebhookIcon } from "~/assets/icons/WebhookIcon"; import { cn } from "~/utils/cn"; export function TaskTriggerSourceIcon({ @@ -21,6 +22,11 @@ export function TaskTriggerSourceIcon({ case "AGENT": { return ; } + case "WEBHOOK": { + return ( + + ); + } } } @@ -35,5 +41,8 @@ export function taskTriggerSourceDescription(source: TaskTriggerSource) { case "AGENT": { return "Agent task"; } + case "WEBHOOK": { + return "Webhook task"; + } } } diff --git a/apps/webapp/app/components/schedules/ScheduleInspector.tsx b/apps/webapp/app/components/schedules/ScheduleInspector.tsx index 1d0de51dc9..b2a64450f1 100644 --- a/apps/webapp/app/components/schedules/ScheduleInspector.tsx +++ b/apps/webapp/app/components/schedules/ScheduleInspector.tsx @@ -55,13 +55,14 @@ export type ScheduleInspectorData = { cron: string; cronDescription: string; timezone: string; + window?: string; externalId: string | null; deduplicationKey: string | null; userProvidedDeduplicationKey: boolean; active: boolean; environments: EnvironmentRow[]; runs: RunRow[]; - nextRuns: Date[]; + nextRuns: Array<{ nominalAt: Date; effectiveAt: Date }>; }; type Props = { @@ -142,6 +143,10 @@ export function ScheduleInspector({ Timezone {schedule.timezone} + + Window + {schedule.window ?? "-"} + Environment @@ -210,11 +215,11 @@ export function ScheduleInspector({ {!isUtc && ( - + )} - + )) @@ -249,8 +254,8 @@ export function ScheduleInspector({ } panelClassName="max-w-full" > - You can only edit a declarative schedule by updating your schedules.task and then - running the CLI dev and deploy commands. + You can only edit a declarative schedule, including its window, by updating your + schedules.task and then running the CLI dev and deploy commands.
)} diff --git a/apps/webapp/app/components/webhookConsole/ReplaySourcePicker.tsx b/apps/webapp/app/components/webhookConsole/ReplaySourcePicker.tsx new file mode 100644 index 0000000000..4ec1855118 --- /dev/null +++ b/apps/webapp/app/components/webhookConsole/ReplaySourcePicker.tsx @@ -0,0 +1,91 @@ +import { type WebhookDeliveryStatus } from "@trigger.dev/database"; +import { useEffect, useState } from "react"; +import { useTypedFetcher } from "remix-typedjson"; +import { DateTime } from "~/components/primitives/DateTime"; +import { Spinner } from "~/components/primitives/Spinner"; +import { DeliveryStatusBadge } from "~/components/webhookDeliveries/v1/DeliveryStatus"; +import { type loader as replaySourceLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.endpoints.$endpointParam.replay-source"; + +export function ReplaySourcePicker({ + replaySourcePath, + onLoad, +}: { + replaySourcePath: string; + onLoad: (body: string, headers: Record) => void; +}) { + const listFetcher = useTypedFetcher(); + const payloadFetcher = useTypedFetcher(); + + useEffect(() => { + if (listFetcher.state === "idle" && listFetcher.data === undefined) { + listFetcher.load(replaySourcePath); + } + }, [listFetcher, replaySourcePath]); + + useEffect(() => { + const data = payloadFetcher.data; + if (data?.kind === "payload") { + onLoad(data.body, data.headers); + } + }, [payloadFetcher.data, onLoad]); + + const listLoading = + listFetcher.state === "loading" || + (listFetcher.data === undefined && listFetcher.state !== "idle"); + const list = listFetcher.data?.kind === "list" ? listFetcher.data.deliveries : []; + const [loadingDeliveryId, setLoadingDeliveryId] = useState(undefined); + + function selectDelivery(friendlyId: string) { + setLoadingDeliveryId(friendlyId); + payloadFetcher.load(`${replaySourcePath}?deliveryId=${encodeURIComponent(friendlyId)}`); + } + + return ( +
+
+ + Load a past delivery's payload into the composer + + {listLoading ? : null} +
+
+ {!listLoading && list.length === 0 ? ( +

+ No past deliveries to replay yet. +

+ ) : ( + list.map((delivery) => ( + + )) + )} +
+
+ ); +} diff --git a/apps/webapp/app/components/webhookConsole/SampleSourcePicker.tsx b/apps/webapp/app/components/webhookConsole/SampleSourcePicker.tsx new file mode 100644 index 0000000000..f705568b26 --- /dev/null +++ b/apps/webapp/app/components/webhookConsole/SampleSourcePicker.tsx @@ -0,0 +1,197 @@ +import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; +import { useEffect, useMemo, useState } from "react"; +import { useTypedFetcher } from "remix-typedjson"; +import { Input } from "~/components/primitives/Input"; +import { Spinner } from "~/components/primitives/Spinner"; +import { cn } from "~/utils/cn"; +import { + type WebhookProviderMeta, + type WebhookSampleMeta, + type loader as samplesLoader, +} from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.samples"; + +export function SampleSourcePicker({ + samplesPath, + endpointSource, + onLoad, +}: { + samplesPath: string; + /** The endpoint's provider (e.g. "github"); pre-selected in the producer list. */ + endpointSource?: string; + onLoad: (body: string, headers: Record) => void; +}) { + const listFetcher = useTypedFetcher(); + const bodyFetcher = useTypedFetcher(); + const [selectedProvider, setSelectedProvider] = useState(null); + const [producerQuery, setProducerQuery] = useState(""); + const [topicQuery, setTopicQuery] = useState(""); + + useEffect(() => { + if (listFetcher.state === "idle" && listFetcher.data === undefined) { + listFetcher.load(samplesPath); + } + }, [listFetcher, samplesPath]); + + useEffect(() => { + const data = bodyFetcher.data; + if (data?.kind === "body") { + onLoad(data.body, data.extraHeaders ?? {}); + } + }, [bodyFetcher.data, onLoad]); + + const manifest = listFetcher.data?.kind === "manifest" ? listFetcher.data : undefined; + const providers = manifest?.providers ?? []; + const samples = manifest?.samples ?? []; + const listLoading = listFetcher.data === undefined; + + useEffect(() => { + if (providers.length === 0) return; + setSelectedProvider((current) => { + if (current && providers.some((p) => p.id === current)) return current; + if (endpointSource && providers.some((p) => p.id === endpointSource)) return endpointSource; + return null; + }); + }, [providers, endpointSource]); + + const filteredProviders = useMemo(() => { + const query = producerQuery.trim().toLowerCase(); + if (!query) return providers; + return providers.filter( + (p) => p.label.toLowerCase().includes(query) || (p.category ?? "").includes(query) + ); + }, [providers, producerQuery]); + + const groupedProviders = useMemo(() => { + const groups = new Map(); + for (const provider of filteredProviders) { + const key = provider.category ?? "other"; + const group = groups.get(key) ?? []; + group.push(provider); + groups.set(key, group); + } + return [...groups.entries()]; + }, [filteredProviders]); + + const events = samples + .filter((item) => item.provider === selectedProvider) + .filter((item) => { + const query = topicQuery.trim().toLowerCase(); + return !query || item.eventType.toLowerCase().includes(query); + }); + + const [loadingEventType, setLoadingEventType] = useState(undefined); + + function selectEvent(item: WebhookSampleMeta) { + setLoadingEventType(item.eventType); + const query = new URLSearchParams({ provider: item.provider, eventType: item.eventType }); + bodyFetcher.load(`${samplesPath}?${query.toString()}`); + } + + return ( +
+
+
+ +
+ {listLoading ? ( +
+ +
+ ) : filteredProviders.length === 0 ? ( +

No providers

+ ) : ( + groupedProviders.map(([category, group]) => ( +
+

+ {group[0]?.categoryLabel ?? category} +

+ {group.map((provider) => ( + + ))} +
+ )) + )} +
+
+ +
+ +
+ {!selectedProvider ? ( +

+ Select a producer to see its events. +

+ ) : events.length === 0 ? ( +

No matching events

+ ) : ( + events.map((item) => ( + + )) + )} +
+
+
+
+ Curated event payloads. Signed with this endpoint's config at send time. +
+
+ ); +} + +function SearchInput({ + placeholder, + value, + onChange, +}: { + placeholder: string; + value: string; + onChange: (value: string) => void; +}) { + return ( +
+ onChange(event.target.value)} + /> +
+ ); +} diff --git a/apps/webapp/app/components/webhookConsole/WebhookComposer.tsx b/apps/webapp/app/components/webhookConsole/WebhookComposer.tsx new file mode 100644 index 0000000000..cee5b70fe9 --- /dev/null +++ b/apps/webapp/app/components/webhookConsole/WebhookComposer.tsx @@ -0,0 +1,595 @@ +import { PlusIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { useFetcher } from "@remix-run/react"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { CodeBlock } from "~/components/code/CodeBlock"; +import { JSONEditor } from "~/components/code/JSONEditor"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; +import { ClipboardField } from "~/components/primitives/ClipboardField"; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; +import { Hint } from "~/components/primitives/Hint"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "~/components/primitives/Resizable"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { TabButton, TabContainer } from "~/components/primitives/Tabs"; +import { cn } from "~/utils/cn"; +import type { WebhookSendResult } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.endpoints.$endpointParam.send"; +import { AIPayloadTabContent } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent"; +import { ReplaySourcePicker } from "./ReplaySourcePicker"; +import { SampleSourcePicker } from "./SampleSourcePicker"; + +type SourceTab = "body" | "sample" | "replay" | "ai"; + +export type WebhookComposerEndpoint = { + friendlyId: string; + label: string; + source: string; + ingressUrl: string; + scheme: "hmac" | "shared-secret" | "url-secret" | "asymmetric"; + hasSigningSecret: boolean; + handshake: { matchPath: string; matchValue: string; respondPath: string } | null; +}; + +export type WebhookComposerProps = { + endpoints: WebhookComposerEndpoint[]; + organizationSlug: string; + projectSlug: string; + environmentSlug: string; + isDevEnvironment: boolean; + environmentLabel: string; + defaultBody?: string; + /** When false, a successful send stays put and shows the inline result strip instead of + * redirecting to the delivery detail page (the console tab keeps its live feed alongside). */ + redirectOnSuccess?: boolean; +}; + +type SignatureMode = "signed" | "unsigned" | "tampered" | "simulate"; + +type HeaderRow = { id: string; key: string; value: string }; + +const DEFAULT_BODY = JSON.stringify({ message: "hello from the webhook console" }, null, 2); + +export function WebhookComposer({ + endpoints, + organizationSlug, + projectSlug, + environmentSlug, + isDevEnvironment, + environmentLabel, + defaultBody, + redirectOnSuccess = true, +}: WebhookComposerProps) { + const fetcher = useFetcher(); + const isSending = fetcher.state !== "idle"; + + const [endpointId, setEndpointId] = useState(endpoints[0]?.friendlyId ?? ""); + const [sourceTab, setSourceTab] = useState("body"); + const [bodyDefault, setBodyDefault] = useState(defaultBody ?? DEFAULT_BODY); + const bodyRef = useRef(bodyDefault); + const [payloadReloadKey, setPayloadReloadKey] = useState(0); + const [headerRows, setHeaderRows] = useState([]); + const headerIdRef = useRef(0); + + const newHeaderRow = useCallback( + (key = "", value = ""): HeaderRow => ({ id: String(headerIdRef.current++), key, value }), + [] + ); + + const endpoint = useMemo( + () => endpoints.find((e) => e.friendlyId === endpointId) ?? endpoints[0], + [endpoints, endpointId] + ); + + const applyPayload = useCallback( + (body: string, headers: Record) => { + setBodyDefault(body); + bodyRef.current = body; + setPayloadReloadKey((key) => key + 1); + const entries = Object.entries(headers); + setHeaderRows(entries.map(([key, value]) => newHeaderRow(key, value))); + setSourceTab("body"); + }, + [newHeaderRow] + ); + + const signedAvailable = Boolean( + endpoint && endpoint.scheme !== "asymmetric" && endpoint.hasSigningSecret + ); + const signedDisabledReason = !endpoint + ? undefined + : endpoint.scheme === "asymmetric" + ? "This endpoint uses asymmetric signatures, which cannot be produced here." + : !endpoint.hasSigningSecret + ? "Set a signing secret on this endpoint first." + : undefined; + + const [signatureMode, setSignatureMode] = useState( + signedAvailable ? "signed" : "simulate" + ); + + const endpointBasePath = endpoint + ? `/resources/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/webhooks/endpoints/${endpoint.friendlyId}` + : ""; + const sendPath = endpointBasePath ? `${endpointBasePath}/send` : ""; + const replaySourcePath = endpointBasePath ? `${endpointBasePath}/replay-source` : ""; + const samplesPath = `/resources/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/webhooks/samples`; + + function submit(override?: { + body?: string; + signatureMode?: SignatureMode; + headers?: Record; + }) { + if (!endpoint) return; + let headers = override?.headers; + if (!headers) { + headers = {}; + for (const row of headerRows) { + const key = row.key.trim(); + if (key) headers[key] = row.value; + } + } + fetcher.submit( + { + body: override?.body ?? bodyRef.current, + headers, + signatureMode: override?.signatureMode ?? signatureMode, + redirect: redirectOnSuccess, + }, + { method: "post", action: sendPath, encType: "application/json" } + ); + } + + function sendHandshake() { + if (!endpoint?.handshake) return; + const challenge = `chal_${Math.random().toString(36).slice(2, 10)}`; + const body = JSON.stringify(buildHandshakeBody(endpoint.handshake, challenge), null, 2); + applyPayload(body, {}); + submit({ body, signatureMode: "signed", headers: {} }); + } + + const result = fetcher.data; + const deliveryPath = + result?.success && result.deliveryId?.startsWith("whd_") + ? `/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/webhooks/deliveries/${result.deliveryId}` + : undefined; + + return ( +
+ + +
+
+ + setSourceTab("body")} + > + Body + + setSourceTab("sample")} + > + Library + + setSourceTab("replay")} + > + Replay + + setSourceTab("ai")} + > + AI + + +
+
+
+ { + bodyRef.current = v; + }} + height="100%" + className="h-full overflow-auto" + showClearButton={false} + additionalActions={ + Event body + } + /> +
+ {sourceTab === "sample" ? ( +
+ +
+ ) : null} + {sourceTab === "replay" ? ( +
+ +
+ ) : null} + {sourceTab === "ai" ? ( +
+ applyPayload(payload, {})} + taskIdentifier={endpoint?.source ?? "webhook"} + payloadKind="webhook" + providerSource={endpoint?.source} + generateButtonLabel="Generate event" + placeholder="e.g. a payment succeeded event with a $42.00 charge" + /> +
+ ) : null} +
+ {result ? ( +
+ +
+ ) : null} +
+
+ + + + +
+ {!isDevEnvironment ? ( + + Sends a real delivery through the {environmentLabel} endpoint and triggers a real + run. + + ) : null} + + {endpoints.length > 1 ? ( + + + + + ) : null} + + + + + {signatureMode === "signed" && signedDisabledReason ? ( + {signedDisabledReason} + ) : signatureMode === "signed" ? ( + Signed server-side with the endpoint's stored secret. + ) : signatureMode === "simulate" ? ( + + Injected via the engine, skipping signature verification. Filter, startOn, + routing, and the run all still execute. + + ) : ( + The delivery is rejected fail-closed; no delivery row is written. + )} + + + + + setHeaderRows((rows) => [...rows, newHeaderRow()])} + /> + + Optional provider routing headers (e.g. x-github-event). The signature header is + added automatically. + + + + {endpoint ? ( + + + + POST + + } + /> + + The public URL providers POST to. Test sends run the same pipeline in-process. + + + ) : null} +
+
+
+ +
+ + {isDevEnvironment + ? "Signs with the endpoint's secret and runs the full delivery pipeline." + : `Sends through the ${environmentLabel} endpoint.`} + +
+ {endpoint?.handshake && signedAvailable ? ( + + ) : null} + {isDevEnvironment ? ( + + ) : ( + submit()} + /> + )} +
+
+
+ ); +} + +function HeadersEditor({ + rows, + onChange, + onAdd, +}: { + rows: HeaderRow[]; + onChange: (rows: HeaderRow[]) => void; + onAdd: () => void; +}) { + return ( +
+ {rows.map((row) => ( +
+
+ + onChange(rows.map((r) => (r.id === row.id ? { ...r, key: event.target.value } : r))) + } + /> +
+
+ + onChange( + rows.map((r) => (r.id === row.id ? { ...r, value: event.target.value } : r)) + ) + } + /> +
+ +
+ ))} + +
+ ); +} + +const UNSAFE_PATH_KEYS = new Set(["__proto__", "constructor", "prototype"]); + +function setPath(target: Record, path: string, value: unknown) { + const parts = path.split("."); + if (parts.some((part) => UNSAFE_PATH_KEYS.has(part))) { + return; + } + let cursor = target; + for (let i = 0; i < parts.length - 1; i++) { + const key = parts[i]; + const next = cursor[key]; + if (typeof next !== "object" || next === null) { + cursor[key] = {}; + } + cursor = cursor[key] as Record; + } + cursor[parts[parts.length - 1]] = value; +} + +function buildHandshakeBody( + handshake: { matchPath: string; matchValue: string; respondPath: string }, + challenge: string +): Record { + const body: Record = {}; + setPath(body, handshake.matchPath, handshake.matchValue); + setPath(body, handshake.respondPath, challenge); + return body; +} + +function ConfirmSendDialog({ + environmentLabel, + disabled, + isSending, + onConfirm, +}: { + environmentLabel: string; + disabled: boolean; + isSending: boolean; + onConfirm: () => void; +}) { + const [open, setOpen] = useState(false); + return ( + + + + + + Send to {environmentLabel}? +
+

+ This delivers a real event to a non-development endpoint and triggers a real run. +

+
+ + +
+
+
+
+ ); +} + +function ResultStrip({ + result, + deliveryPath, +}: { + result: WebhookSendResult; + deliveryPath?: string; +}) { + const status = result.success ? result.httpStatus : undefined; + const handshake = result.success && result.handshake; + const deduplicated = result.success && result.deduplicated; + const ok = result.success && status === 200 && !deduplicated; + return ( +
+
+ + {handshake + ? "Handshake" + : deduplicated + ? "Deduplicated" + : result.success + ? `HTTP ${result.httpStatus}` + : "Failed"} + + {result.success && result.deliveryId ? ( + {result.deliveryId} + ) : null} + {deliveryPath ? ( + + {deduplicated ? "View original →" : "View delivery →"} + + ) : null} +
+ {handshake ? ( + + Challenge echoed by the endpoint. Handshakes are answered inline; no delivery is recorded. + + ) : deduplicated ? ( + + Identical payload was deduplicated to the original delivery. Vary it to send a new one. + + ) : null} + +
+ ); +} diff --git a/apps/webapp/app/components/webhookDeliveries/v1/DeliveriesTable.tsx b/apps/webapp/app/components/webhookDeliveries/v1/DeliveriesTable.tsx new file mode 100644 index 0000000000..c22abe055e --- /dev/null +++ b/apps/webapp/app/components/webhookDeliveries/v1/DeliveriesTable.tsx @@ -0,0 +1,268 @@ +import { ArrowRightIcon } from "@heroicons/react/20/solid"; +import { useLocation, useNavigation } from "@remix-run/react"; +import { AIChatIcon } from "~/assets/icons/AIChatIcon"; +import { RunsIcon } from "~/assets/icons/RunsIcon"; +import { WebhookIcon } from "~/assets/icons/WebhookIcon"; +import { Badge } from "~/components/primitives/Badge"; +import { DateTime } from "~/components/primitives/DateTime"; +import { MiddleTruncate } from "~/components/primitives/MiddleTruncate"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { PopoverMenuItem } from "~/components/primitives/Popover"; +import { Spinner } from "~/components/primitives/Spinner"; +import { + Table, + TableBlankRow, + TableBody, + TableCell, + TableCellMenu, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { type WebhookDeliveryListItem } from "~/presenters/v3/WebhookDetailPresenter.server"; +import { + v3RunPath, + v3SessionPath, + v3WebhookDeliveryPath, + v3WebhookTaskPath, +} from "~/utils/pathBuilder"; +import { cn } from "~/utils/cn"; +import { DeliveryStatusBadge } from "./DeliveryStatus"; + +export function DeliveriesTable({ + deliveries, + hasFilters, + showTopBorder = true, + stickyHeader = false, + showWebhook = false, +}: { + deliveries: WebhookDeliveryListItem[]; + hasFilters?: boolean; + showTopBorder?: boolean; + stickyHeader?: boolean; + // The top-level (cross-endpoint) deliveries page shows which webhook each delivery + // belongs to; the per-webhook detail page leaves this off. + showWebhook?: boolean; +}) { + const navigation = useNavigation(); + const location = useLocation(); + const isLoading = + navigation.state !== "idle" && navigation.location?.pathname === location.pathname; + + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + return ( + + + + {showWebhook && Webhook} + Delivery + Status + External delivery ID + Target + Created + Processed + Error + + Actions + + + + + {deliveries.length === 0 ? ( + +
+ + {hasFilters + ? "No deliveries match these filters" + : "No deliveries for this webhook yet"} + +
+
+ ) : ( + deliveries.map((delivery) => { + const runPath = delivery.run + ? v3RunPath(organization, project, environment, { + friendlyId: delivery.run.friendlyId, + }) + : undefined; + + // Session deliveries route to a session (their run is just its current turn), so the + // session is the meaningful target; task deliveries fall back to the run. + const sessionPath = delivery.session + ? v3SessionPath(organization, project, environment, { + friendlyId: delivery.session.friendlyId, + }) + : undefined; + + const webhookPath = delivery.webhook + ? v3WebhookTaskPath(organization, project, environment, delivery.webhook.slug) + : undefined; + + const deliveryPath = v3WebhookDeliveryPath( + organization, + project, + environment, + delivery.friendlyId + ); + + return ( + + {showWebhook && ( + + {delivery.webhook ? ( + + + {delivery.webhook.slug} + + ) : ( + + Unknown + + )} + + )} + + + {delivery.friendlyId} + {delivery.isTest ? Test : null} + + + + + + + {delivery.externalDeliveryId ? ( +
+ +
+ ) : ( + + None + + )} +
+ {/* Falls back to the delivery so the whole row stays clickable when there is no target */} + + {delivery.session ? ( + + + {delivery.session.friendlyId} + + ) : delivery.run ? ( + + + {delivery.run.friendlyId} + + ) : ( + + None + + )} + + + + + + {delivery.processedAt ? ( + + ) : ( + + None + + )} + + + {delivery.status === "FAILED" && delivery.errorMessage ? ( + + {delivery.errorMessage} + + } + /> + ) : ( + + None + + )} + + +
+ ); + }) + )} + {isLoading && ( + + Loading… + + )} +
+
+ ); +} + +function DeliveryActionsCell({ + deliveryPath, + runPath, + sessionPath, +}: { + deliveryPath: string; + runPath?: string; + sessionPath?: string; +}) { + return ( + + + {sessionPath ? ( + + ) : null} + {runPath ? ( + + ) : null} + + } + /> + ); +} diff --git a/apps/webapp/app/components/webhookDeliveries/v1/DeliveryStatus.tsx b/apps/webapp/app/components/webhookDeliveries/v1/DeliveryStatus.tsx new file mode 100644 index 0000000000..0fac38937a --- /dev/null +++ b/apps/webapp/app/components/webhookDeliveries/v1/DeliveryStatus.tsx @@ -0,0 +1,38 @@ +import { type WebhookDeliveryStatus } from "@trigger.dev/database"; +import { cn } from "~/utils/cn"; + +// Reuse the run-status hex palette for the four delivery statuses (matches the +// detail page activity chart and the task-list status bars). No invented colors. +export const DELIVERY_STATUS_COLOR: Record = { + SUCCEEDED: "#28BF5C", + FAILED: "#E11D48", + PROCESSING: "#3B82F6", + PENDING: "#878C99", + FILTERED: "#64748B", // received + verified, intentionally not routed; neutral, not a failure +}; + +export const DELIVERY_STATUS_LABEL: Record = { + SUCCEEDED: "Succeeded", + FAILED: "Failed", + PROCESSING: "Processing", + PENDING: "Pending", + FILTERED: "Filtered", +}; + +export function DeliveryStatusBadge({ + status, + className, +}: { + status: WebhookDeliveryStatus; + className?: string; +}) { + return ( + + + {DELIVERY_STATUS_LABEL[status]} + + ); +} diff --git a/apps/webapp/app/components/webhookDeliveries/v1/DeliveryTimeline.tsx b/apps/webapp/app/components/webhookDeliveries/v1/DeliveryTimeline.tsx new file mode 100644 index 0000000000..ae3386f82d --- /dev/null +++ b/apps/webapp/app/components/webhookDeliveries/v1/DeliveryTimeline.tsx @@ -0,0 +1,114 @@ +import { formatDuration } from "@trigger.dev/core/v3/utils/durations"; +import { Fragment } from "react"; +import { AIChatIcon } from "~/assets/icons/AIChatIcon"; +import { RunsIcon } from "~/assets/icons/RunsIcon"; +import { DateTimeAccurate } from "~/components/primitives/DateTime"; +import { TextLink } from "~/components/primitives/TextLink"; +import { RunTimelineEvent, RunTimelineLine } from "~/components/run/RunTimeline"; +import { LiveTimer } from "~/components/runs/v3/LiveTimer"; +import { + buildDeliveryTimelineItems, + type BuildDeliveryTimelineInput, + type DeliveryTimelineEventItem, +} from "./buildDeliveryTimelineItems"; + +type DeliveryTimelineProps = { + delivery: BuildDeliveryTimelineInput; + runPath?: string; + sessionPath?: string; +}; + +export function DeliveryTimeline({ delivery, runPath, sessionPath }: DeliveryTimelineProps) { + const items = buildDeliveryTimelineItems(delivery); + + return ( +
+ {items.map((item) => { + if (item.type === "line") { + return ( + + {item.to ? ( + formatDuration(item.from, item.to) + ) : ( + + )} + {item.label ? ( + ({item.label.toLowerCase()}) + ) : null} + + } + /> + ); + } + + return ( + + + ) : null + } + /> + + + ); + })} +
+ ); +} + +function TimelineEventExtras({ + item, + runPath, + sessionPath, +}: { + item: DeliveryTimelineEventItem; + runPath?: string; + sessionPath?: string; +}) { + const showSession = Boolean(item.target?.session && sessionPath); + const showRun = Boolean(item.target?.run && runPath); + const hasTarget = showSession || showRun; + + if (!item.note && !hasTarget) { + return null; + } + + return ( +
+
+
+ {item.note ? ( + + {item.note} + + ) : null} + {showSession && item.target?.session ? ( + + + {item.target.session.friendlyId} + + ) : showRun && item.target?.run ? ( + + + {item.target.run.friendlyId} + + ) : null} +
+
+ ); +} diff --git a/apps/webapp/app/components/webhookDeliveries/v1/WebhookDeliveryFilters.tsx b/apps/webapp/app/components/webhookDeliveries/v1/WebhookDeliveryFilters.tsx new file mode 100644 index 0000000000..20a2a4c94e --- /dev/null +++ b/apps/webapp/app/components/webhookDeliveries/v1/WebhookDeliveryFilters.tsx @@ -0,0 +1,638 @@ +import * as Ariakit from "@ariakit/react"; +import { BeakerIcon, FingerPrintIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { Form } from "@remix-run/react"; +import { type WebhookDeliveryStatus } from "@trigger.dev/database"; +import { type ReactNode, useMemo, useRef, useState } from "react"; +import { StatusIcon } from "~/assets/icons/StatusIcon"; +import { WebhookIcon } from "~/assets/icons/WebhookIcon"; +import { AppliedFilter } from "~/components/primitives/AppliedFilter"; +import { + ComboBox, + SelectButtonItem, + SelectItem, + SelectList, + SelectPopover, + SelectProvider, + SelectTrigger, + shortcutFromIndex, +} from "~/components/primitives/Select"; +import { ShortcutKey } from "~/components/primitives/ShortcutKey"; +import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; +import { useSearchParams } from "~/hooks/useSearchParam"; +import { useShortcutKeys } from "~/hooks/useShortcutKeys"; +import { Button } from "../../primitives/Buttons"; +import { + appliedSummary, + FilterMenuProvider, + IdFilterDropdown, + type IdFilterDropdownProps, + TimeFilter, +} from "~/components/runs/v3/SharedFilters"; + +// Match DeliveriesTable's DELIVERY_STATUS_COLOR / DELIVERY_STATUS_LABEL. +const deliveryStatuses: { value: WebhookDeliveryStatus; title: string; color: string }[] = [ + { value: "PENDING", title: "Pending", color: "#878C99" }, + { value: "PROCESSING", title: "Processing", color: "#3B82F6" }, + { value: "SUCCEEDED", title: "Succeeded", color: "#28BF5C" }, + { value: "FAILED", title: "Failed", color: "#E11D48" }, + { value: "FILTERED", title: "Filtered", color: "#64748B" }, +]; + +const statusTitleByValue = new Map(deliveryStatuses.map((s) => [s.value, s.title])); + +function StatusDot({ color }: { color: string }) { + return ; +} + +export type PossibleWebhook = { slug: string; source: string }; + +type WebhookDeliveryFiltersProps = { + possibleWebhooks: PossibleWebhook[]; + /** Custom default period for the time filter (e.g., "1h", "7d") */ + defaultPeriod?: string; +}; + +export function WebhookDeliveryFilters(props: WebhookDeliveryFiltersProps) { + const location = useOptimisticLocation(); + const searchParams = new URLSearchParams(location.search); + const hasFilters = + searchParams.has("statuses") || + searchParams.has("webhooks") || + searchParams.has("deliveryId") || + searchParams.has("runId") || + searchParams.has("test"); + + return ( +
+ + + + + + + {hasFilters && ( +
+
+ ); +} + +const filterTypes = [ + { name: "deliveryId", title: "Delivery ID", icon: }, + { name: "runId", title: "Run ID", icon: }, +] as const; + +type FilterType = (typeof filterTypes)[number]["name"]; + +const moreFiltersShortcut = { key: "f" }; + +function FilterMenu() { + const [filterType, setFilterType] = useState(); + + const filterTrigger = ( + + +
+ } + variant={"secondary/small"} + shortcut={moreFiltersShortcut} + tooltipTitle={"More filters"} + className="pl-1 pr-2" + > + More filters + + ); + + return ( + setFilterType(undefined)}> + {(search, setSearch) => ( + setSearch("")} + trigger={filterTrigger} + filterType={filterType} + setFilterType={setFilterType} + /> + )} + + ); +} + +function AppliedFilters() { + return ( + <> + + + + ); +} + +type MenuProps = { + searchValue: string; + clearSearchValue: () => void; + trigger: ReactNode; + filterType: FilterType | undefined; + setFilterType: (filterType: FilterType | undefined) => void; +}; + +function Menu(props: MenuProps) { + switch (props.filterType) { + case undefined: + return ; + case "deliveryId": + return props.setFilterType(undefined)} {...props} />; + case "runId": + return props.setFilterType(undefined)} {...props} />; + } +} + +function MainMenu({ trigger, clearSearchValue, setFilterType }: MenuProps) { + return ( + + {trigger} + + + {filterTypes.map((type, index) => ( + { + clearSearchValue(); + setFilterType(type.name); + }} + icon={type.icon} + shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })} + > + {type.title} + + ))} + + + + ); +} + +function StatusDropdown({ + trigger, + clearSearchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + onClose?: () => void; +}) { + const { values, replace } = useSearchParams(); + + const handleChange = (values: string[]) => { + clearSearchValue(); + replace({ statuses: values, cursor: undefined, direction: undefined }); + }; + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + > + + {deliveryStatuses.map((item, index) => ( + + + + {item.title} + + + ))} + + + + ); +} + +const statusShortcut = { key: "s" }; + +function PermanentStatusFilter() { + const { values, del } = useSearchParams(); + const statuses = values("statuses"); + const hasStatuses = statuses.length > 0 && !statuses.every((v) => v === ""); + const triggerRef = useRef(null); + + useShortcutKeys({ + shortcut: statusShortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + triggerRef.current?.click(); + }, + }); + + return ( + + {(_search, setSearch) => ( + + } + /> + } + > + {hasStatuses ? ( + } + value={appliedSummary( + statuses.map((v) => statusTitleByValue.get(v as WebhookDeliveryStatus) ?? v) + )} + onRemove={() => del(["statuses", "cursor", "direction"])} + variant="secondary/small" + className="pl-1" + /> + ) : ( +
+
+
+
+ Status +
+ )} + + +
+ Filter by status + +
+
+ + } + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +function WebhookDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, + possibleWebhooks, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; + possibleWebhooks: PossibleWebhook[]; +}) { + const { values, replace } = useSearchParams(); + + const handleChange = (newValues: string[]) => { + clearSearchValue(); + replace({ + webhooks: newValues.length > 0 ? newValues : undefined, + cursor: undefined, + direction: undefined, + }); + }; + + const filtered = useMemo(() => { + return possibleWebhooks.filter((item) => + item.slug.toLowerCase().includes(searchValue.toLowerCase()) + ); + }, [searchValue, possibleWebhooks]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + > + + + {filtered.length > 0 ? ( + filtered.map((item) => ( + } + className="text-text-bright" + > + {item.slug} + + )) + ) : ( + No webhooks found + )} + + + + ); +} + +const webhookShortcut = { key: "w" }; + +function PermanentWebhookFilter({ possibleWebhooks }: { possibleWebhooks: PossibleWebhook[] }) { + const { values, del } = useSearchParams(); + const webhooks = values("webhooks"); + const hasWebhooks = webhooks.length > 0 && !webhooks.every((v) => v === ""); + const triggerRef = useRef(null); + + useShortcutKeys({ + shortcut: webhookShortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + triggerRef.current?.click(); + }, + }); + + return ( + + {(search, setSearch) => ( + + } + /> + } + > + {hasWebhooks ? ( + } + value={appliedSummary(webhooks)} + onRemove={() => del(["webhooks", "cursor", "direction"])} + variant="secondary/small" + className="pl-1" + /> + ) : ( +
+ + Webhook +
+ )} +
+ +
+ Filter by webhook + +
+
+ + } + searchValue={search} + clearSearchValue={() => setSearch("")} + possibleWebhooks={possibleWebhooks} + /> + )} +
+ ); +} + +const testModes = [ + { value: "all", title: "All deliveries" }, + { value: "hide", title: "Hide test sends" }, + { value: "only", title: "Test sends only" }, +] as const; + +const testShortcut = { key: "t" }; + +function TestDropdown({ trigger, onClose }: { trigger: ReactNode; onClose?: () => void }) { + const { value, replace } = useSearchParams(); + const current = value("test") ?? "all"; + + const handleChange = (next: string) => { + replace({ test: next === "all" ? undefined : next, cursor: undefined, direction: undefined }); + }; + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + return true; + }} + > + + {testModes.map((mode, index) => ( + + {mode.title} + + ))} + + + + ); +} + +function PermanentTestFilter() { + const { value, del } = useSearchParams(); + const current = value("test"); + const active = current === "hide" || current === "only"; + const triggerRef = useRef(null); + + useShortcutKeys({ + shortcut: testShortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + triggerRef.current?.click(); + }, + }); + + return ( + + } + /> + } + > + {active ? ( + } + value={current === "only" ? "Test sends only" : "Hidden"} + onRemove={() => del(["test", "cursor", "direction"])} + variant="secondary/small" + className="pl-1" + /> + ) : ( +
+ + Test +
+ )} +
+ +
+ Filter test sends + +
+
+ + } + /> + ); +} + +function DeliveryIdDropdown( + props: Omit +) { + return ( + + ); +} + +function AppliedDeliveryIdFilter() { + const { value, del } = useSearchParams(); + + if (value("deliveryId") === undefined) { + return null; + } + + const deliveryId = value("deliveryId"); + + return ( + + {(search, setSearch) => ( + }> + } + value={deliveryId} + onRemove={() => del(["deliveryId", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +function validateRunId(value: string): string | undefined { + if (!value.startsWith("run_")) return "Run IDs start with 'run_'"; + if (value.length !== 25 && value.length !== 29) return "Run IDs are 25 or 29 characters long"; +} + +function RunIdDropdown( + props: Omit< + IdFilterDropdownProps, + "label" | "placeholder" | "paramKey" | "validate" | "inputWidth" + > +) { + return ( + + ); +} + +function AppliedRunIdFilter() { + const { value, del } = useSearchParams(); + + if (value("runId") === undefined) { + return null; + } + + const runId = value("runId"); + + return ( + + {(search, setSearch) => ( + }> + } + value={runId} + onRemove={() => del(["runId", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} diff --git a/apps/webapp/app/components/webhookDeliveries/v1/buildDeliveryTimelineItems.ts b/apps/webapp/app/components/webhookDeliveries/v1/buildDeliveryTimelineItems.ts new file mode 100644 index 0000000000..f5cc0af596 --- /dev/null +++ b/apps/webapp/app/components/webhookDeliveries/v1/buildDeliveryTimelineItems.ts @@ -0,0 +1,125 @@ +import { type WebhookDeliveryStatus } from "@trigger.dev/database"; +import { + type TimelineEventState, + type TimelineEventVariant, + type TimelineLineVariant, +} from "~/components/run/RunTimeline"; + +export type DeliveryRunTarget = { + run: { friendlyId: string } | null; + session: { friendlyId: string; externalId: string | null } | null; +}; + +export type DeliveryTimelineEventItem = { + type: "event"; + id: string; + title: string; + date: Date | null; + previousDate?: Date; + state: TimelineEventState; + variant: TimelineEventVariant; + note?: string | null; + target?: DeliveryRunTarget; +}; + +export type DeliveryTimelineLineItem = { + type: "line"; + id: string; + from: Date; + to: Date | null; + state: TimelineEventState; + variant: TimelineLineVariant; + label?: string; +}; + +export type DeliveryTimelineItem = DeliveryTimelineEventItem | DeliveryTimelineLineItem; + +export type BuildDeliveryTimelineInput = { + status: WebhookDeliveryStatus; + createdAt: Date; + processedAt: Date | null; + errorMessage: string | null; + filterReason: string | null; + run: { friendlyId: string } | null; + session: { friendlyId: string; externalId: string | null } | null; +}; + +export function buildDeliveryTimelineItems( + delivery: BuildDeliveryTimelineInput +): DeliveryTimelineItem[] { + const { status, createdAt, processedAt } = delivery; + const inFlight = status === "PENDING" || status === "PROCESSING"; + + const items: DeliveryTimelineItem[] = [ + { + type: "event", + id: "received", + title: "Received", + date: createdAt, + state: "complete", + variant: "start-cap", + }, + ]; + + if (status === "FILTERED") { + items.push({ + type: "line", + id: "routing", + from: createdAt, + to: processedAt ?? createdAt, + state: "delayed", + variant: "light", + }); + items.push({ + type: "event", + id: "filtered", + title: "Filtered", + date: processedAt ?? createdAt, + previousDate: createdAt, + state: "delayed", + variant: "dot-solid", + note: delivery.filterReason, + }); + return items; + } + + items.push({ + type: "line", + id: "routing", + from: createdAt, + to: inFlight ? null : processedAt, + state: inFlight ? "inprogress" : status === "FAILED" ? "error" : "complete", + variant: "normal", + label: "Routing", + }); + + if (inFlight) { + return items; + } + + if (status === "SUCCEEDED") { + items.push({ + type: "event", + id: "delivered", + title: "Delivered", + date: processedAt, + previousDate: createdAt, + state: "complete", + variant: "end-cap-thick", + target: { run: delivery.run, session: delivery.session }, + }); + } else if (status === "FAILED") { + items.push({ + type: "event", + id: "failed", + title: "Failed", + date: processedAt, + previousDate: createdAt, + state: "error", + variant: "end-cap-thick", + note: delivery.errorMessage, + }); + } + + return items; +} diff --git a/apps/webapp/app/components/webhookDeliveries/v1/useDeliveriesLiveReload.ts b/apps/webapp/app/components/webhookDeliveries/v1/useDeliveriesLiveReload.ts new file mode 100644 index 0000000000..3905a8aea8 --- /dev/null +++ b/apps/webapp/app/components/webhookDeliveries/v1/useDeliveriesLiveReload.ts @@ -0,0 +1,240 @@ +import { useLocation } from "@remix-run/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTypedFetcher } from "remix-typedjson"; +import { useInterval } from "~/hooks/useInterval"; +import { type WebhookDeliveryListItem } from "~/presenters/v3/WebhookDetailPresenter.server"; +import { + type LiveDeliveryFields, + type loader as liveDeliveriesLoader, +} from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.deliveries.live"; + +const DELIVERIES_POLL_INTERVAL_MS = 3000; +const NEW_DELIVERIES_EVERY_N_POLL_TICKS = 2; + +const IN_FLIGHT_STATUSES = new Set(["PENDING", "PROCESSING"]); + +type ListedDelivery = WebhookDeliveryListItem; +type LivePollFetcherData = + | { deliveries: LiveDeliveryFields[] } + | { deliveries: LiveDeliveryFields[]; count: number; since: number } + | undefined; + +function hasNewDeliveriesCountFields( + data: LivePollFetcherData +): data is NonNullable & { count: number; since: number } { + return data !== undefined && "count" in data && "since" in data; +} + +function maxCreatedAtMs(deliveries: ListedDelivery[]): number | undefined { + if (deliveries.length === 0) return undefined; + + return deliveries.reduce((maxTimestamp, delivery) => { + return Math.max(maxTimestamp, new Date(delivery.createdAt).getTime()); + }, 0); +} + +function patchVisibleDeliveriesWithLiveUpdates( + currentDeliveries: ListedDelivery[], + liveDeliveries: LiveDeliveryFields[] +) { + const updatesById = new Map(liveDeliveries.map((delivery) => [delivery.friendlyId, delivery])); + + return currentDeliveries.map((delivery) => { + const update = updatesById.get(delivery.friendlyId); + if (!update) return delivery; + + return { + ...delivery, + status: update.status, + runId: update.runId, + run: update.run, + session: update.session, + errorMessage: update.errorMessage, + processedAt: update.processedAt, + }; + }); +} + +function isNewDeliveriesCheckTick(tick: number) { + return tick === 1 || tick % NEW_DELIVERIES_EVERY_N_POLL_TICKS === 0; +} + +function useNewDeliveriesDetection({ + deliveries, + isLoading, +}: { + deliveries: ListedDelivery[]; + isLoading: boolean; +}) { + const pollTickRef = useRef(0); + const [knownNewestDeliveryMs, setKnownNewestDeliveryMs] = useState( + () => maxCreatedAtMs(deliveries) ?? Date.now() + ); + const [newDeliveriesCount, setNewDeliveriesCount] = useState(0); + + const shouldPollForNewDeliveries = !isLoading && newDeliveriesCount < 100; + + const resetNewDeliveriesTracking = useCallback(() => { + setKnownNewestDeliveryMs(maxCreatedAtMs(deliveries) ?? Date.now()); + setNewDeliveriesCount(0); + pollTickRef.current = 0; + }, [deliveries]); + + const dismissNewDeliveries = useCallback(() => { + setNewDeliveriesCount(0); + setKnownNewestDeliveryMs(Date.now()); + pollTickRef.current = 0; + }, []); + + const checkNewDeliveriesOnTick = useCallback(() => { + pollTickRef.current += 1; + return shouldPollForNewDeliveries && isNewDeliveriesCheckTick(pollTickRef.current); + }, [shouldPollForNewDeliveries]); + + const showNewDeliveriesBanner = newDeliveriesCount > 0; + + return { + knownNewestDeliveryMs, + newDeliveriesCount, + setNewDeliveriesCount, + shouldPollForNewDeliveries, + showNewDeliveriesBanner, + dismissNewDeliveries, + checkNewDeliveriesOnTick, + resetNewDeliveriesTracking, + }; +} + +export function useDeliveriesLiveReload({ + deliveries, + isLoading, + webhookEndpointId, + organizationSlug, + projectSlug, + environmentSlug, +}: { + deliveries: ListedDelivery[]; + isLoading: boolean; + /** Omit to poll across every endpoint in the environment (the cross-endpoint deliveries list). */ + webhookEndpointId?: string; + organizationSlug: string; + projectSlug: string; + environmentSlug: string; +}) { + const location = useLocation(); + const deliveriesPollFetcher = useTypedFetcher(); + const deliveriesPollFetcherStateRef = useRef(deliveriesPollFetcher.state); + deliveriesPollFetcherStateRef.current = deliveriesPollFetcher.state; + + const [visibleDeliveries, setVisibleDeliveries] = useState(deliveries); + + const { + knownNewestDeliveryMs, + newDeliveriesCount, + setNewDeliveriesCount, + shouldPollForNewDeliveries, + showNewDeliveriesBanner, + dismissNewDeliveries, + checkNewDeliveriesOnTick, + resetNewDeliveriesTracking, + } = useNewDeliveriesDetection({ deliveries, isLoading }); + + useEffect(() => { + setVisibleDeliveries(deliveries); + resetNewDeliveriesTracking(); + }, [deliveries, location.search, resetNewDeliveriesTracking]); + + useEffect(() => { + const data = deliveriesPollFetcher.data; + if (!data?.deliveries.length) return; + + setVisibleDeliveries((current) => + patchVisibleDeliveriesWithLiveUpdates(current, data.deliveries) + ); + }, [deliveriesPollFetcher.data]); + + useEffect(() => { + const data = deliveriesPollFetcher.data; + if (!hasNewDeliveriesCountFields(data)) return; + + if (data.since === knownNewestDeliveryMs) { + setNewDeliveriesCount(data.count); + } + }, [deliveriesPollFetcher.data, knownNewestDeliveryMs, setNewDeliveriesCount]); + + const activeDeliveryIdsParam = useMemo( + () => + visibleDeliveries + .filter((delivery) => IN_FLIGHT_STATUSES.has(delivery.status)) + .map((delivery) => delivery.friendlyId) + .join(","), + [visibleDeliveries] + ); + const hasActiveDeliveries = activeDeliveryIdsParam.length > 0; + + const deliveriesResourcesBasePath = useMemo( + () => + `/resources/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/webhooks/deliveries`, + [organizationSlug, projectSlug, environmentSlug] + ); + + const loadDeliveriesPoll = useCallback( + (checkForNewDeliveries: boolean) => { + if (deliveriesPollFetcherStateRef.current !== "idle") return; + + if (!hasActiveDeliveries && !checkForNewDeliveries) return; + + const searchParams = new URLSearchParams(); + if (webhookEndpointId) { + searchParams.set("webhookEndpointId", webhookEndpointId); + } + if (hasActiveDeliveries) { + searchParams.set("deliveryIds", activeDeliveryIdsParam); + } + + if (checkForNewDeliveries) { + searchParams.set("includeNewDeliveries", "true"); + searchParams.set("since", String(knownNewestDeliveryMs)); + const current = new URLSearchParams(location.search); + const to = current.get("to"); + if (to) searchParams.set("to", to); + for (const status of current.getAll("statuses")) searchParams.append("statuses", status); + for (const webhook of current.getAll("webhooks")) searchParams.append("webhooks", webhook); + for (const key of ["deliveryId", "runId", "test"] as const) { + const value = current.get(key); + if (value) searchParams.set(key, value); + } + } + + deliveriesPollFetcher.load(`${deliveriesResourcesBasePath}/live?${searchParams.toString()}`); + }, + [ + activeDeliveryIdsParam, + hasActiveDeliveries, + location.search, + knownNewestDeliveryMs, + webhookEndpointId, + deliveriesPollFetcher, + deliveriesResourcesBasePath, + ] + ); + + const shouldPoll = !isLoading && (hasActiveDeliveries || shouldPollForNewDeliveries); + + useInterval({ + interval: DELIVERIES_POLL_INTERVAL_MS, + onLoad: true, + pauseWhenHidden: true, + disabled: !shouldPoll, + callback: () => { + loadDeliveriesPoll(checkNewDeliveriesOnTick()); + }, + }); + + return { + visibleDeliveries, + showNewDeliveriesBanner, + newDeliveriesCount, + dismissNewDeliveries, + }; +} diff --git a/apps/webapp/app/components/webhookEndpoints/v1/EndpointStatus.tsx b/apps/webapp/app/components/webhookEndpoints/v1/EndpointStatus.tsx new file mode 100644 index 0000000000..277950968a --- /dev/null +++ b/apps/webapp/app/components/webhookEndpoints/v1/EndpointStatus.tsx @@ -0,0 +1,25 @@ +import { type WebhookEndpointStatus } from "@trigger.dev/database"; + +const ENDPOINT_STATUS_COLOR: Record = { + ACTIVE: "#28BF5C", + INACTIVE: "#878C99", + DELETING: "#F59E0B", +}; + +const ENDPOINT_STATUS_LABEL: Record = { + ACTIVE: "Active", + INACTIVE: "Inactive", + DELETING: "Deleting", +}; + +export function EndpointStatusBadge({ status }: { status: WebhookEndpointStatus }) { + return ( + + + {ENDPOINT_STATUS_LABEL[status]} + + ); +} diff --git a/apps/webapp/app/components/webhookEndpoints/v1/EndpointsTable.tsx b/apps/webapp/app/components/webhookEndpoints/v1/EndpointsTable.tsx new file mode 100644 index 0000000000..556a64fa74 --- /dev/null +++ b/apps/webapp/app/components/webhookEndpoints/v1/EndpointsTable.tsx @@ -0,0 +1,117 @@ +import { ArrowRightIcon } from "@heroicons/react/20/solid"; +import { Badge } from "~/components/primitives/Badge"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { PopoverMenuItem } from "~/components/primitives/Popover"; +import { + Table, + TableBlankRow, + TableBody, + TableCell, + TableCellMenu, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { type WebhookEndpointListItem } from "~/presenters/v3/WebhookDetailPresenter.server"; +import { v3WebhookEndpointPath } from "~/utils/pathBuilder"; +import { EndpointStatusBadge } from "./EndpointStatus"; + +export function EndpointsTable({ + endpoints, + stickyHeader = false, + showTopBorder = true, +}: { + endpoints: WebhookEndpointListItem[]; + stickyHeader?: boolean; + showTopBorder?: boolean; +}) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + return ( + + + + Endpoint + Tenant + External ref + Status + Secret + Deliveries (7d) + + Actions + + + + + {endpoints.length === 0 ? ( + +
+ No endpoints for this webhook yet +
+
+ ) : ( + endpoints.map((endpoint) => { + const endpointPath = v3WebhookEndpointPath( + organization, + project, + environment, + endpoint.friendlyId + ); + + return ( + + + + {endpoint.friendlyId} + {endpoint.isDefault ? default : null} + + + + {endpoint.tenantId ?? default} + + + {endpoint.externalRef ?? None} + + + + + + {endpoint.hasSigningSecret ? ( + + + Set + + ) : ( + + + Not set + + )} + + + {endpoint.deliveryCount.toLocaleString()} + + + } + /> + + ); + }) + )} +
+
+ ); +} diff --git a/apps/webapp/app/db.server.ts b/apps/webapp/app/db.server.ts index a06321e167..077730cc33 100644 --- a/apps/webapp/app/db.server.ts +++ b/apps/webapp/app/db.server.ts @@ -7,6 +7,8 @@ import { type PrismaReplicaClient, type PrismaTransactionClient, type PrismaTransactionOptions, + type WebhookDatabase, + type WebhookReplicaDatabase, } from "@trigger.dev/database"; import { RunOpsPrismaClient } from "@internal/run-ops-database"; import { markReadReplicaClient } from "@internal/run-store"; @@ -32,6 +34,13 @@ import { import { computeRunOpsSplitReadEnabled } from "./v3/runOpsMigration/runOpsSplitReadGate"; import { assertControlPlaneCoresidencyAdvisory } from "./v3/runOpsMigration/controlPlaneCoresidencySentinel.server"; import { DATASOURCE_CONTEXT_KEY, startActiveSpan } from "./v3/tracer.server"; +import { + controlPlaneTransactionResilience, + registerTransactionResilience, + resilienceForClient, + runOpsLegacyTransactionResilience, + runOpsTransactionResilience, +} from "./v3/transactionResilience.server"; import type { Span } from "@opentelemetry/api"; import { context, trace } from "@opentelemetry/api"; import { queryPerformanceMonitor } from "./utils/queryPerformanceMonitor.server"; @@ -41,6 +50,8 @@ export type { PrismaClientOrTransaction, PrismaTransactionOptions, PrismaReplicaClient, + WebhookDatabase, + WebhookReplicaDatabase, }; // Boundary logger for transac(): skips an error the client extension already @@ -59,6 +70,18 @@ function logTransactionPrismaError(error: Prisma.PrismaClientKnownRequestError) }); } +function withTransactionDefaults( + client: PrismaClientOrTransaction, + options?: PrismaTransactionOptions +): PrismaTransactionOptions { + const resilience = resilienceForClient(client as object); + return { + maxWait: resilience.maxWait, + ...options, + startRetry: options?.startRetry ?? resilience.startRetry, + }; +} + export async function $transaction( prisma: PrismaClientOrTransaction, name: string, @@ -93,35 +116,41 @@ async function $transactionInner( options?: PrismaTransactionOptions ): Promise { if (typeof fnOrName === "string") { + const effectiveOptions = withTransactionDefaults(prisma, options); return await startActiveSpan(fnOrName, async (span) => { span.setAttribute("$transaction", true); - if (options?.isolationLevel) { - span.setAttribute("isolation_level", options.isolationLevel); + if (effectiveOptions.isolationLevel) { + span.setAttribute("isolation_level", effectiveOptions.isolationLevel); } - if (options?.timeout) { - span.setAttribute("timeout", options.timeout); + if (effectiveOptions.timeout) { + span.setAttribute("timeout", effectiveOptions.timeout); } - if (options?.maxWait) { - span.setAttribute("max_wait", options.maxWait); + if (effectiveOptions.maxWait) { + span.setAttribute("max_wait", effectiveOptions.maxWait); } - if (options?.swallowPrismaErrors) { - span.setAttribute("swallow_prisma_errors", options.swallowPrismaErrors); + if (effectiveOptions.swallowPrismaErrors) { + span.setAttribute("swallow_prisma_errors", effectiveOptions.swallowPrismaErrors); } const fn = fnOrOptions as (prisma: PrismaTransactionClient, span: Span) => Promise; - return transac(prisma, (client) => fn(client, span), logTransactionPrismaError, options); + return transac( + prisma, + (client) => fn(client, span), + logTransactionPrismaError, + effectiveOptions + ); }); } else { return transac( prisma, fnOrName, logTransactionPrismaError, - typeof fnOrOptions === "function" ? undefined : fnOrOptions + withTransactionDefaults(prisma, typeof fnOrOptions === "function" ? undefined : fnOrOptions) ); } } @@ -134,7 +163,9 @@ type DatasourceLabel = | "legacy-run-ops-writer" | "legacy-run-ops-replica" | "run-ops-writer" - | "run-ops-replica"; + | "run-ops-replica" + | "webhook-writer" + | "webhook-replica"; function tagDatasource(datasource: DatasourceLabel, client: T): T { return client.$extends({ @@ -180,7 +211,10 @@ function captureInfraErrorsRunOps(client: RunOpsPrismaClient): RunOpsPrismaClien } export const prisma = singleton("prisma", () => - captureInfrastructureErrors(tagDatasource("control-plane-writer", getClient())) + registerTransactionResilience( + captureInfrastructureErrors(tagDatasource("control-plane-writer", getClient())), + controlPlaneTransactionResilience + ) ); export const $replica: PrismaReplicaClient = singleton("replica", () => { @@ -194,6 +228,52 @@ export const $replica: PrismaReplicaClient = singleton("replica", () => { : prisma; }); +/** + * Webhook feature data-plane seam. The whole webhook feature (WebhookEndpoint + WebhookDelivery) + * can run on a dedicated Postgres via WEBHOOK_DATABASE_URL; unset reuses the main prisma instance, + * so single-DB installs open no extra pool. + */ +export const webhookPrisma: WebhookDatabase = singleton("webhookPrisma", () => { + if (!env.WEBHOOK_DATABASE_URL) { + return prisma; + } + return captureInfrastructureErrors( + tagDatasource( + "webhook-writer", + buildWriterClient({ + url: env.WEBHOOK_DATABASE_URL, + clientType: "webhook-writer", + connectionLimit: env.WEBHOOK_DATABASE_CONNECTION_LIMIT ?? env.DATABASE_CONNECTION_LIMIT, + }) + ) + ); +}); + +/** + * Webhook reader chain: an explicit webhook replica, else the webhook writer once split (no + * separate replica yet), else the main $replica when the feature is not split. + */ +export const webhookReplica: WebhookReplicaDatabase = singleton("webhookReplica", () => { + if (env.WEBHOOK_DATABASE_READ_REPLICA_URL) { + return markReadReplicaClient( + captureInfrastructureErrors( + tagDatasource( + "webhook-replica", + buildReplicaClient({ + url: env.WEBHOOK_DATABASE_READ_REPLICA_URL, + clientType: "webhook-reader", + connectionLimit: env.WEBHOOK_DATABASE_CONNECTION_LIMIT ?? env.DATABASE_CONNECTION_LIMIT, + }) + ) + ) + ); + } + if (env.WEBHOOK_DATABASE_URL) { + return webhookPrisma; + } + return $replica; +}); + export type RunOpsClients = { writer: PrismaClient; replica: PrismaReplicaClient }; export type NewRunOpsClients = { writer: RunOpsPrismaClient; replica: RunOpsPrismaClient }; export type RunOpsTopology = { @@ -309,15 +389,18 @@ const runOpsTopology: RunOpsTopology = singleton("runOpsTopology", () => { { controlPlane: { writer: prisma, replica: $replica }, buildNewWriter: (url, clientType) => - captureInfraErrorsRunOps( - tagDatasourceRunOps( - "run-ops-writer", - buildRunOpsWriterClient({ - url, - clientType, - useDriverAdapter: env.RUN_OPS_DATABASE_WRITER_DRIVER_ADAPTER === "1", - }) - ) + registerTransactionResilience( + captureInfraErrorsRunOps( + tagDatasourceRunOps( + "run-ops-writer", + buildRunOpsWriterClient({ + url, + clientType, + useDriverAdapter: env.RUN_OPS_DATABASE_WRITER_DRIVER_ADAPTER === "1", + }) + ) + ), + runOpsTransactionResilience ), // Brand the run-ops replica (only built for a real replica URL) so routed replica reads stay // off the primary. When no replica URL is set, selectRunOpsTopology reuses the writer here — @@ -338,17 +421,20 @@ const runOpsTopology: RunOpsTopology = singleton("runOpsTopology", () => { // Legacy client shares the exact control-plane wrapper stack (the legacy DB carries the full // control-plane schema); markReadReplicaClient only on a real replica URL, as with the NEW replica. buildLegacyWriter: (url, clientType) => - captureInfrastructureErrors( - tagDatasource( - "legacy-run-ops-writer", - buildWriterClient({ - url, - clientType, - poolTimeout: env.RUN_OPS_LEGACY_DATABASE_WRITER_POOL_TIMEOUT, - connectTimeout: env.RUN_OPS_LEGACY_DATABASE_WRITER_CONNECTION_TIMEOUT, - useDriverAdapter: env.RUN_OPS_LEGACY_DATABASE_WRITER_DRIVER_ADAPTER === "1", - }) - ) + registerTransactionResilience( + captureInfrastructureErrors( + tagDatasource( + "legacy-run-ops-writer", + buildWriterClient({ + url, + clientType, + poolTimeout: env.RUN_OPS_LEGACY_DATABASE_WRITER_POOL_TIMEOUT, + connectTimeout: env.RUN_OPS_LEGACY_DATABASE_WRITER_CONNECTION_TIMEOUT, + useDriverAdapter: env.RUN_OPS_LEGACY_DATABASE_WRITER_DRIVER_ADAPTER === "1", + }) + ) + ), + runOpsLegacyTransactionResilience ), buildLegacyReplica: (url, clientType) => markReadReplicaClient( @@ -493,18 +579,20 @@ function buildDriverAdapterPool( export function buildWriterClient({ url, clientType, + connectionLimit = env.DATABASE_CONNECTION_LIMIT, poolTimeout, connectTimeout, useDriverAdapter = false, }: { url: string; clientType: string; + connectionLimit?: number; poolTimeout?: number; connectTimeout?: number; useDriverAdapter?: boolean; }): PrismaClient { const databaseUrl = buildPrismaConnectionUrl(url, { - connectionLimit: env.DATABASE_CONNECTION_LIMIT.toString(), + connectionLimit: connectionLimit.toString(), poolTimeout: (poolTimeout ?? env.DATABASE_POOL_TIMEOUT).toString(), connectTimeout: (connectTimeout ?? env.DATABASE_CONNECTION_TIMEOUT).toString(), applicationName: env.SERVICE_NAME, @@ -677,18 +765,20 @@ function getReplicaClient() { export function buildReplicaClient({ url, clientType, + connectionLimit = env.DATABASE_CONNECTION_LIMIT, poolTimeout, connectTimeout, useDriverAdapter = false, }: { url: string; clientType: string; + connectionLimit?: number; poolTimeout?: number; connectTimeout?: number; useDriverAdapter?: boolean; }): PrismaClient { const replicaUrl = buildPrismaConnectionUrl(url, { - connectionLimit: env.DATABASE_CONNECTION_LIMIT.toString(), + connectionLimit: connectionLimit.toString(), poolTimeout: (poolTimeout ?? env.DATABASE_POOL_TIMEOUT).toString(), connectTimeout: (connectTimeout ?? env.DATABASE_CONNECTION_TIMEOUT).toString(), applicationName: env.SERVICE_NAME, diff --git a/apps/webapp/app/entry.server.tsx b/apps/webapp/app/entry.server.tsx index c2cc31e6f2..719ae50aa3 100644 --- a/apps/webapp/app/entry.server.tsx +++ b/apps/webapp/app/entry.server.tsx @@ -41,6 +41,18 @@ import { registerRunChangeNotifierHandlers } from "./services/realtime/runChange // TRI-9864 for the incident write-up. import { sessionsReplicationInstance } from "./services/sessionsReplicationInstance.server"; (globalThis as Record).__sessionsReplicationInstance = sessionsReplicationInstance; +// Touch the webhook deliveries replication singleton at entry so it boots +// deterministically alongside the sessions replicator. Same `sideEffects: false` +// tree-shaking constraint applies — assign to globalThis, do NOT use `void`. +import { webhookDeliveriesReplicationInstance } from "./services/webhookDeliveriesReplicationInstance.server"; +(globalThis as Record).__webhookDeliveriesReplicationInstance = + webhookDeliveriesReplicationInstance; +// Touch the webhook engine singleton at entry so its redis-worker boots +// deterministically on webapp startup (the constructor calls worker.start()). +// Same `sideEffects: false` tree-shaking constraint applies: assign to +// globalThis, do NOT use `void`. +import { webhookEngine } from "./v3/webhookEngine.server"; +(globalThis as Record).__webhookEngine = webhookEngine; import { globalFlagsRegistry } from "./v3/globalFlagsRegistry.server"; (globalThis as Record).__globalFlagsRegistry = globalFlagsRegistry; import { workerRegionRegistry } from "./v3/workerRegions.server"; @@ -338,6 +350,7 @@ export { engineRateLimiter } from "./services/engineRateLimit.server"; export { otlpRateLimiter } from "./services/otlpRateLimit.server"; export { runWithHttpContext } from "./services/httpAsyncStorage.server"; export { tenantContextMiddleware } from "./services/tenantContextResolver.server"; +export { webhookIngressIpRateLimiter } from "./services/webhookIngressIpRateLimit.server"; export { socketIo } from "./v3/handleSocketIo.server"; export { wss } from "./v3/handleWebsockets.server"; diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 3ce98dd521..7a6c4c8aea 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -114,6 +114,26 @@ const OptionalIntEnv = z.preprocess( z.coerce.number().int().optional() ); +/** Optional boolean env var; blank/whitespace/unset normalises to undefined (so it falls back). */ +const OptionalBoolEnv = z.preprocess((v) => { + if (typeof v !== "string" || v.trim() === "") return undefined; + return ["true", "1"].includes(v.toLowerCase().trim()); +}, z.boolean().optional()); + +/** Boolean env var with a default where blank/whitespace falls back to the default instead of parsing as false. */ +const BoolEnvWithDefault = (defaultValue: boolean) => + z.preprocess((v) => { + if (typeof v !== "string" || v.trim() === "") return undefined; + return ["true", "1"].includes(v.toLowerCase().trim()); + }, z.boolean().default(defaultValue)); + +/** Int env var with a default where a blank/whitespace value falls back to the default instead of coercing to 0. */ +const IntEnvWithDefault = (defaultValue: number) => + z.preprocess( + (v) => (typeof v === "string" && v.trim() === "" ? undefined : v), + z.coerce.number().int().default(defaultValue) + ); + /** * Optional int env var for a limit that can be switched off. Blank, whitespace and `0` all mean * "no limit" and normalise to undefined; anything else that is set must be greater than zero. @@ -142,6 +162,27 @@ const EnvironmentSchema = z DATABASE_WRITER_CONNECTION_TIMEOUT: OptionalIntEnv, DATABASE_READ_REPLICA_POOL_TIMEOUT: OptionalIntEnv, DATABASE_READ_REPLICA_CONNECTION_TIMEOUT: OptionalIntEnv, + DATABASE_TRANSACTION_MAX_WAIT_MS: IntEnvWithDefault(10000), + DATABASE_TRANSACTION_START_RETRY_ENABLED: BoolEnvWithDefault(true), + DATABASE_TRANSACTION_START_RETRY_MAX_ATTEMPTS: IntEnvWithDefault(3), + DATABASE_TRANSACTION_START_RETRY_BACKOFF_MIN_MS: IntEnvWithDefault(50), + DATABASE_TRANSACTION_START_RETRY_BACKOFF_MAX_MS: IntEnvWithDefault(250), + DATABASE_TRANSACTION_START_RETRY_BUDGET_PER_SEC: IntEnvWithDefault(50), + DATABASE_TRANSACTION_START_RETRY_BUDGET_BURST: IntEnvWithDefault(100), + RUN_OPS_DATABASE_TRANSACTION_MAX_WAIT_MS: OptionalIntEnv, + RUN_OPS_DATABASE_TRANSACTION_START_RETRY_ENABLED: OptionalBoolEnv, + RUN_OPS_DATABASE_TRANSACTION_START_RETRY_MAX_ATTEMPTS: OptionalIntEnv, + RUN_OPS_DATABASE_TRANSACTION_START_RETRY_BACKOFF_MIN_MS: OptionalIntEnv, + RUN_OPS_DATABASE_TRANSACTION_START_RETRY_BACKOFF_MAX_MS: OptionalIntEnv, + RUN_OPS_DATABASE_TRANSACTION_START_RETRY_BUDGET_PER_SEC: OptionalIntEnv, + RUN_OPS_DATABASE_TRANSACTION_START_RETRY_BUDGET_BURST: OptionalIntEnv, + RUN_OPS_LEGACY_DATABASE_TRANSACTION_MAX_WAIT_MS: OptionalIntEnv, + RUN_OPS_LEGACY_DATABASE_TRANSACTION_START_RETRY_ENABLED: OptionalBoolEnv, + RUN_OPS_LEGACY_DATABASE_TRANSACTION_START_RETRY_MAX_ATTEMPTS: OptionalIntEnv, + RUN_OPS_LEGACY_DATABASE_TRANSACTION_START_RETRY_BACKOFF_MIN_MS: OptionalIntEnv, + RUN_OPS_LEGACY_DATABASE_TRANSACTION_START_RETRY_BACKOFF_MAX_MS: OptionalIntEnv, + RUN_OPS_LEGACY_DATABASE_TRANSACTION_START_RETRY_BUDGET_PER_SEC: OptionalIntEnv, + RUN_OPS_LEGACY_DATABASE_TRANSACTION_START_RETRY_BUDGET_BURST: OptionalIntEnv, // Dashboard-agent conversation store. Cloud points this at a dedicated // database; when unset it falls back to DATABASE_URL (OSS), where // the tables live in the isolated `trigger_dashboard_agent` schema. @@ -179,6 +220,7 @@ const EnvironmentSchema = z // Explicit positive opt-in. Split behavior is unreachable unless this is true // AND the distinct-DB sentinel confirms the two URLs are physically distinct DBs. RUN_OPS_SPLIT_ENABLED: BoolEnv.default(false), + RUN_OPS_WORKER_VERSION_FRESH_READ_ENABLED: BoolEnv.default(true), // Canonical connection URL for the dedicated NEW run-ops DB — drives the runtime pool, the split // decision, replication, and migrations. Optional so single-DB installs never set it. RUN_OPS_DATABASE_URL: z @@ -248,6 +290,11 @@ const EnvironmentSchema = z // Control-plane cache relax knobs. Unset -> defaults (DEFAULT_CP_CACHE_TTL_MS / _MAX_ENTRIES). CONTROL_PLANE_CACHE_TTL_MS: z.coerce.number().int().optional(), CONTROL_PLANE_CACHE_MAX_ENTRIES: z.coerce.number().int().optional(), + // Webhook feature data-plane DB (WebhookEndpoint + WebhookDelivery). Unset -> the webhook + // clients reuse the main prisma / $replica, so this is connection-neutral until you split. + WEBHOOK_DATABASE_URL: z.string().optional(), + WEBHOOK_DATABASE_READ_REPLICA_URL: z.string().optional(), + WEBHOOK_DATABASE_CONNECTION_LIMIT: z.coerce.number().int().optional(), SESSION_SECRET: z.string().min(1).refine(isNotInsecureSecret, INSECURE_SECRET_MESSAGE), MAGIC_LINK_SECRET: z.string().min(1).refine(isNotInsecureSecret, INSECURE_SECRET_MESSAGE), ENCRYPTION_KEY: z @@ -1724,6 +1771,65 @@ const EnvironmentSchema = z .default(process.env.REDIS_TLS_DISABLED ?? "false"), SCHEDULE_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), + WEBHOOK_ENGINE_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"), + WEBHOOK_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"), + WEBHOOK_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(50), + WEBHOOK_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2), + WEBHOOK_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10), + WEBHOOK_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), + WEBHOOK_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(30_000), + WEBHOOK_ENABLED: z.string().default("0"), + + WEBHOOK_WORKER_REDIS_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_HOST), + WEBHOOK_WORKER_REDIS_PORT: z.coerce + .number() + .optional() + .transform( + (v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined) + ), + WEBHOOK_WORKER_REDIS_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_USERNAME), + WEBHOOK_WORKER_REDIS_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_PASSWORD), + WEBHOOK_WORKER_REDIS_TLS_DISABLED: z + .string() + .default(process.env.REDIS_TLS_DISABLED ?? "false"), + + WEBHOOK_PARTITION_ENSURE_SCHEDULE: z.string().optional(), + WEBHOOK_PARTITION_ENSURE_JITTER_MS: z.coerce.number().int().optional(), + WEBHOOK_PARTITION_LOOKAHEAD_DAYS: z.coerce.number().int().default(10), + WEBHOOK_PARTITION_RETENTION_DAYS: z.coerce.number().int().default(60), + + // Ingest hot-path cache for the endpoint + resolved signing secret (keyed by opaqueId). 0 disables. + WEBHOOK_ENDPOINT_CACHE_TTL_MS: z.coerce.number().int().default(30_000), + WEBHOOK_ENDPOINT_CACHE_MAX_SIZE: z.coerce.number().int().default(10_000), + + WEBHOOK_INGRESS_ENABLED: z.string().default("1"), + + // Public origin for the webhook ingress URL shown to users / returned by the API. Defaults to the + // API origin; set to a dedicated host (e.g. https://webhook.trigger.dev) when one is fronted. + WEBHOOK_INGRESS_ORIGIN: z.string().optional(), + WEBHOOK_INGRESS_BODY_SIZE_LIMIT_MB: z.coerce.number().int().default(1), + WEBHOOK_INGRESS_RATE_LIMIT_WINDOW: z.string().default("10s"), + WEBHOOK_INGRESS_RATE_LIMIT_TOKENS: z.coerce.number().int().default(100), + WEBHOOK_INGRESS_IP_RATE_LIMIT_WINDOW: z.string().default("10s"), + WEBHOOK_INGRESS_IP_RATE_LIMIT_TOKENS: z.coerce.number().int().default(300), + WEBHOOK_FRONT_GATE_DEFAULT_TTL_SECONDS: z.coerce + .number() + .int() + .default(6 * 60 * 60), + WEBHOOK_FRONT_GATE_MAX_TTL_SECONDS: z.coerce + .number() + .int() + .default(6 * 60 * 60), + TASK_EVENT_PARTITIONING_ENABLED: z.string().default("0"), TASK_EVENT_PARTITIONED_WINDOW_IN_SECONDS: z.coerce.number().int().default(60), // 1 minute @@ -1881,6 +1987,47 @@ const EnvironmentSchema = z SESSION_REPLICATION_INSERT_BASE_DELAY_MS: z.coerce.number().int().default(100), SESSION_REPLICATION_INSERT_MAX_DELAY_MS: z.coerce.number().int().default(2000), + // Webhook deliveries replication (Postgres → ClickHouse webhook_deliveries_v1). + // Shares Redis with the runs replicator for leader locking but has its own + // slot and publication so the two consume independently. The source table is + // a partitioned parent, so the publication is created with + // publish_via_partition_root. + WEBHOOK_DELIVERIES_REPLICATION_CLICKHOUSE_URL: z.string().optional(), + WEBHOOK_DELIVERIES_REPLICATION_ENABLED: z.string().default("0"), + WEBHOOK_DELIVERIES_REPLICATION_SLOT_NAME: z + .string() + .default("webhook_deliveries_to_clickhouse_v1"), + WEBHOOK_DELIVERIES_REPLICATION_PUBLICATION_NAME: z + .string() + .default("webhook_deliveries_to_clickhouse_v1_publication"), + WEBHOOK_DELIVERIES_REPLICATION_MAX_FLUSH_CONCURRENCY: z.coerce.number().int().default(1), + WEBHOOK_DELIVERIES_REPLICATION_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000), + WEBHOOK_DELIVERIES_REPLICATION_FLUSH_BATCH_SIZE: z.coerce.number().int().default(100), + WEBHOOK_DELIVERIES_REPLICATION_LEADER_LOCK_TIMEOUT_MS: z.coerce.number().int().default(30_000), + WEBHOOK_DELIVERIES_REPLICATION_LEADER_LOCK_EXTEND_INTERVAL_MS: z.coerce + .number() + .int() + .default(10_000), + WEBHOOK_DELIVERIES_REPLICATION_LEADER_LOCK_ADDITIONAL_TIME_MS: z.coerce + .number() + .int() + .default(10_000), + WEBHOOK_DELIVERIES_REPLICATION_LEADER_LOCK_RETRY_INTERVAL_MS: z.coerce + .number() + .int() + .default(500), + WEBHOOK_DELIVERIES_REPLICATION_ACK_INTERVAL_SECONDS: z.coerce.number().int().default(10), + WEBHOOK_DELIVERIES_REPLICATION_LOG_LEVEL: z + .enum(["log", "error", "warn", "info", "debug"]) + .default("info"), + WEBHOOK_DELIVERIES_REPLICATION_WAIT_FOR_ASYNC_INSERT: z.string().default("0"), + WEBHOOK_DELIVERIES_REPLICATION_INSERT_STRATEGY: z + .enum(["insert", "insert_async"]) + .default("insert"), + WEBHOOK_DELIVERIES_REPLICATION_INSERT_MAX_RETRIES: z.coerce.number().int().default(3), + WEBHOOK_DELIVERIES_REPLICATION_INSERT_BASE_DELAY_MS: z.coerce.number().int().default(100), + WEBHOOK_DELIVERIES_REPLICATION_INSERT_MAX_DELAY_MS: z.coerce.number().int().default(2000), + // Clickhouse CLICKHOUSE_URL: z.string(), // Optional read replica endpoint. Read-only clients (logs, query, admin, runsList, diff --git a/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts index 0c0c6d90e7..1b348a2e8d 100644 --- a/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts @@ -71,7 +71,6 @@ export class ApiKeysPresenter { id: true, apiKey: true, type: true, - createdAt: true, apiKeys: { where: showRevoked ? undefined : { revokedAt: null }, orderBy: { createdAt: "desc" }, @@ -133,7 +132,6 @@ export class ApiKeysPresenter { name: "Root API key", value: keyEnvironment.apiKey, obfuscated: obfuscateApiKey(keyEnvironment.type, keyEnvironment.apiKey.slice(-4)), - createdAt: keyEnvironment.createdAt, }, apiKeys: keyEnvironment.apiKeys.map((apiKey, index) => { const { presetId, scopes, ...apiKeyData } = apiKey; diff --git a/apps/webapp/app/presenters/v3/ApiWebhookDeliveryPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiWebhookDeliveryPresenter.server.ts new file mode 100644 index 0000000000..d1c247939d --- /dev/null +++ b/apps/webapp/app/presenters/v3/ApiWebhookDeliveryPresenter.server.ts @@ -0,0 +1,162 @@ +import { + type WebhookDeliveryListItem as ApiWebhookDeliveryListItem, + type WebhookDeliveryObject, +} from "@trigger.dev/core/v3"; +import { type WebhookDeliveryStatus } from "@trigger.dev/database"; +import { z } from "zod"; +import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; +import { type ApiAuthenticationResultSuccess } from "~/services/apiAuth.server"; +import { CoercedDate } from "~/utils/zod"; +import { BasePresenter } from "./basePresenter.server"; +import { WebhookDeliveriesListPresenter } from "./WebhookDeliveriesListPresenter.server"; +import { WebhookDeliveryDetailPresenter } from "./WebhookDeliveryDetailPresenter.server"; +import { type WebhookDeliveryListItem } from "./WebhookDetailPresenter.server"; + +const DB_STATUS_TO_API: Record = { + PENDING: "pending", + PROCESSING: "processing", + SUCCEEDED: "succeeded", + FAILED: "failed", + FILTERED: "filtered", +}; + +// API status -> DB status (for the filter). +const API_STATUS_TO_DB: Record = { + pending: "PENDING", + processing: "PROCESSING", + succeeded: "SUCCEEDED", + failed: "FAILED", + filtered: "FILTERED", +}; + +function toApiListItem(d: WebhookDeliveryListItem): ApiWebhookDeliveryListItem { + return { + id: d.friendlyId, + webhook: d.webhook?.slug ?? null, + status: DB_STATUS_TO_API[d.status], + externalDeliveryId: d.externalDeliveryId, + runId: d.run?.friendlyId ?? null, + createdAt: d.createdAt, + processedAt: d.processedAt, + }; +} + +export const ApiWebhookDeliveryListSearchParams = z.object({ + "page[size]": z.coerce.number().int().positive().min(1).max(100).optional(), + "page[after]": z.string().optional(), + "page[before]": z.string().optional(), + "filter[webhook]": z + .string() + .optional() + .transform((value) => (value ? value.split(",") : undefined)), + "filter[status]": z + .string() + .optional() + .transform((value, ctx) => { + if (!value) return undefined; + const statuses = value.split(","); + const invalid = statuses.filter( + (s) => !Object.prototype.hasOwnProperty.call(API_STATUS_TO_DB, s) + ); + if (invalid.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid status values: ${invalid.join( + ", " + )}. Allowed: pending, processing, succeeded, failed.`, + }); + return z.NEVER; + } + return Array.from(new Set(statuses.map((s) => API_STATUS_TO_DB[s]))); + }), + "filter[period]": z.string().optional(), + "filter[from]": CoercedDate, + "filter[to]": CoercedDate, +}); +export type ApiWebhookDeliveryListSearchParams = z.infer; + +export class ApiWebhookDeliveryListPresenter extends BasePresenter { + public async call( + environment: { id: string; projectId: string; organizationId: string }, + searchParams: ApiWebhookDeliveryListSearchParams + ): Promise<{ + data: ApiWebhookDeliveryListItem[]; + pagination: { next?: string; previous?: string }; + }> { + return this.trace("call", async () => { + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + environment.organizationId, + "standard" + ); + + const presenter = new WebhookDeliveriesListPresenter(this._replica, clickhouse); + const result = await presenter.call({ + organizationId: environment.organizationId, + projectId: environment.projectId, + environmentId: environment.id, + webhooks: searchParams["filter[webhook]"], + statuses: searchParams["filter[status]"], + period: searchParams["filter[period]"], + from: searchParams["filter[from]"]?.getTime(), + to: searchParams["filter[to]"]?.getTime(), + cursor: searchParams["page[after]"] ?? searchParams["page[before]"], + direction: searchParams["page[before]"] ? "backward" : "forward", + pageSize: searchParams["page[size]"], + }); + + return { data: result.deliveries.map(toApiListItem), pagination: result.pagination }; + }); + } +} + +export class ApiWebhookDeliveryPresenter extends BasePresenter { + public async call( + environment: { id: string; projectId: string; organizationId: string }, + deliveryFriendlyId: string + ): Promise { + return this.trace("call", async () => { + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + environment.organizationId, + "standard" + ); + + const presenter = new WebhookDeliveryDetailPresenter(this._replica, clickhouse); + const d = await presenter.call({ + organizationId: environment.organizationId, + projectId: environment.projectId, + environmentId: environment.id, + deliveryFriendlyId, + }); + + if (!d) return undefined; + + return { + id: d.friendlyId, + webhook: d.webhook?.slug ?? null, + status: DB_STATUS_TO_API[d.status], + externalDeliveryId: d.externalDeliveryId, + runId: d.run?.friendlyId ?? null, + createdAt: d.createdAt, + processedAt: d.processedAt, + idempotencyKey: d.idempotencyKey, + event: d.parsedEvent ?? null, + headers: (d.headers as Record | null) ?? null, + rawBodyHash: d.rawBodyHash, + error: d.errorMessage, + filterReason: d.filterReason, + updatedAt: d.updatedAt, + }; + }); + } +} + +export function findWebhookDeliveryResource( + authentication: ApiAuthenticationResultSuccess, + deliveryId: string +): Promise { + const env = authentication.environment; + return new ApiWebhookDeliveryPresenter().call( + { id: env.id, projectId: env.projectId, organizationId: env.organizationId }, + deliveryId + ); +} diff --git a/apps/webapp/app/presenters/v3/ApiWebhookEndpointPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiWebhookEndpointPresenter.server.ts new file mode 100644 index 0000000000..07881b018e --- /dev/null +++ b/apps/webapp/app/presenters/v3/ApiWebhookEndpointPresenter.server.ts @@ -0,0 +1,105 @@ +import { type WebhookEndpointObject } from "@trigger.dev/core/v3"; +import { + type Prisma, + type RuntimeEnvironment, + type WebhookEndpointStatus, +} from "@trigger.dev/database"; +import { z } from "zod"; +import { boundedIn, webhookReplica } from "~/db.server"; +import { type ApiAuthenticationResultSuccess } from "~/services/apiAuth.server"; +import { webhookIngressUrl } from "~/utils/webhookIngressUrl.server"; +import { BasePresenter } from "./basePresenter.server"; + +const DB_STATUS_TO_API: Record = { + ACTIVE: "active", + INACTIVE: "inactive", + DELETING: "deleting", +}; + +// The columns needed to build the public API object. +const endpointSelect = { + friendlyId: true, + opaqueId: true, + handlerWebhookId: true, + source: true, + status: true, + secretProvisioning: true, + signingSecretKey: true, + endpointTenantId: true, + endpointExternalRef: true, + createdAt: true, + updatedAt: true, +} satisfies Prisma.WebhookEndpointSelect; + +type EndpointRow = Prisma.WebhookEndpointGetPayload<{ select: typeof endpointSelect }>; + +function toApiEndpoint(endpoint: EndpointRow): WebhookEndpointObject { + return { + id: endpoint.friendlyId, + webhook: endpoint.handlerWebhookId, + source: endpoint.source, + status: DB_STATUS_TO_API[endpoint.status], + secretProvisioning: + (endpoint.secretProvisioning as WebhookEndpointObject["secretProvisioning"]) ?? "either", + secretSet: endpoint.signingSecretKey != null && endpoint.signingSecretKey !== "", + tenantId: endpoint.endpointTenantId === "" ? null : endpoint.endpointTenantId, + externalRef: endpoint.endpointExternalRef === "" ? null : endpoint.endpointExternalRef, + url: webhookIngressUrl(endpoint.opaqueId), + createdAt: endpoint.createdAt, + updatedAt: endpoint.updatedAt, + }; +} + +export const ApiWebhookEndpointListSearchParams = z.object({ + "filter[webhook]": z + .string() + .optional() + .transform((value) => (value ? value.split(",") : undefined)), +}); +export type ApiWebhookEndpointListSearchParams = z.infer; + +export class ApiWebhookEndpointListPresenter extends BasePresenter { + public async call( + environment: Pick, + searchParams: ApiWebhookEndpointListSearchParams + ): Promise<{ data: WebhookEndpointObject[] }> { + return this.trace("call", async () => { + const endpoints = await webhookReplica.webhookEndpoint.findMany({ + where: { + runtimeEnvironmentId: environment.id, + ...(searchParams["filter[webhook]"] + ? { handlerWebhookId: { in: boundedIn(searchParams["filter[webhook]"]) } } + : {}), + }, + select: endpointSelect, + orderBy: [{ handlerWebhookId: "asc" }, { createdAt: "desc" }], + }); + + return { data: endpoints.map(toApiEndpoint) }; + }); + } +} + +export class ApiWebhookEndpointPresenter extends BasePresenter { + public async call( + environmentId: string, + endpointFriendlyId: string + ): Promise { + return this.trace("call", async () => { + const endpoint = await webhookReplica.webhookEndpoint.findFirst({ + // friendlyId is globally unique; scope to the env so a foreign id 404s. + where: { friendlyId: endpointFriendlyId, runtimeEnvironmentId: environmentId }, + select: endpointSelect, + }); + + return endpoint ? toApiEndpoint(endpoint) : undefined; + }); + } +} + +export function findWebhookEndpointResource( + authentication: ApiAuthenticationResultSuccess, + endpointId: string +): Promise { + return new ApiWebhookEndpointPresenter().call(authentication.environment.id, endpointId); +} diff --git a/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts b/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts index 610ebb24d2..3fd7fec8a6 100644 --- a/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts @@ -6,6 +6,7 @@ import { filterOrphanedEnvironments } from "~/utils/environmentSort"; import { getTimezones } from "~/utils/timezones.server"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; +import { formatScheduleWindow } from "~/v3/scheduleWindow.server"; type EditScheduleOptions = { userId: string; @@ -124,6 +125,8 @@ export class EditSchedulePresenter { deduplicationKey: true, userProvidedDeduplicationKey: true, timezone: true, + windowDurationSeconds: true, + windowPercentage: true, taskIdentifier: true, instances: { select: { @@ -144,6 +147,7 @@ export class EditSchedulePresenter { return { ...schedule, cron: schedule.generatorExpression, + window: formatScheduleWindow(schedule), environments: schedule.instances.flatMap((instance) => { const environment = possibleEnvironments.find((env) => env.id === instance.environmentId); if (!environment) { diff --git a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts index fe6499b8e1..c9a120334f 100644 --- a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts @@ -24,6 +24,7 @@ import { regionForDisplay } from "~/runEngine/concerns/workerQueueSplit.server"; import { machinePresetFromRun } from "~/v3/machinePresets.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { isCancellableRunStatus, isFinalRunStatus, isPendingRunStatus } from "~/v3/taskStatus"; +import { runTriggeredAt } from "~/v3/runTimestamps"; // Positive-only cache: only envs known to have runs are stored (empty envs are re-checked), // so "has runs" is monotonic and the TTL can be very long. Tiered memory + Redis. @@ -299,12 +300,14 @@ export class NextRunListPresenter { const hasFinished = isFinalRunStatus(run.status); const startedAt = run.startedAt ?? run.lockedAt; + const triggeredAt = runTriggeredAt(run); return { id: run.id, number: 1, friendlyId: run.friendlyId, createdAt: run.createdAt.toISOString(), + triggeredAt: triggeredAt.toISOString(), updatedAt: run.updatedAt.toISOString(), startedAt: startedAt ? startedAt.toISOString() : undefined, delayUntil: run.delayUntil ? run.delayUntil.toISOString() : undefined, diff --git a/apps/webapp/app/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts index 18557e5173..8a99f3e60f 100644 --- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts @@ -11,6 +11,7 @@ import { env } from "~/env.server"; import { getEventRepositoryForStore } from "~/v3/eventRepository/index.server"; import { runStore } from "~/v3/runStore.server"; import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; +import { runTriggeredAt } from "~/v3/runTimestamps"; type Result = Awaited>; export type Run = Result["run"]; @@ -92,6 +93,8 @@ export class RunPresenter { friendlyId: true, status: true, startedAt: true, + queueTimestamp: true, + scheduleId: true, completedAt: true, logsDeletedAt: true, annotations: true, @@ -151,6 +154,7 @@ export class RunPresenter { } const showLogs = showDeletedLogs || !run.logsDeletedAt; + const triggeredAt = runTriggeredAt(run); const runData = { id: run.id, @@ -240,7 +244,7 @@ export class RunPresenter { message: run.taskIdentifier, style: { icon: "task", variant: "primary" }, events: [], - startTime: run.createdAt, + startTime: triggeredAt, duration: 0, isError: run.status === "COMPLETED_WITH_ERRORS" || @@ -364,7 +368,7 @@ export class RunPresenter { rootStartedAt: tree?.data.startTime, startedAt: run.startedAt, queuedDuration: run.startedAt - ? millisecondsToNanoseconds(run.startedAt.getTime() - run.createdAt.getTime()) + ? millisecondsToNanoseconds(run.startedAt.getTime() - triggeredAt.getTime()) : undefined, overridesBySpanId: traceSummary.overridesBySpanId, linkedRunIdBySpanId, diff --git a/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts b/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts index 22b9821bab..c0eb6fc7a3 100644 --- a/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts @@ -5,12 +5,10 @@ import { getTaskIdentifiers } from "~/models/task.server"; import { getCurrentPlan, getPlans } from "~/services/platform.v3.server"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; -import { formatScheduleWindow } from "~/v3/scheduleWindow.server"; +import { calculateNextScheduleRunTimes, formatScheduleWindow } from "~/v3/scheduleWindow.server"; import { CheckScheduleService } from "~/v3/services/checkSchedule.server"; -import { - calculateNextScheduledTimestampFromNow, - previousScheduledTimestamp, -} from "~/v3/utils/calculateNextSchedule.server"; +import { previousScheduledTimestamp } from "~/v3/utils/calculateNextSchedule.server"; +import { env } from "~/env.server"; import { BasePresenter } from "./basePresenter.server"; type ScheduleListOptions = { @@ -35,6 +33,7 @@ export type ScheduleListItem = { window?: string; externalId: string | null; nextRun: Date; + nextRunEffectiveAt: Date; lastRun: Date | undefined; active: boolean; environments: { @@ -223,6 +222,7 @@ export class ScheduleListPresenter extends BasePresenter { instances: { select: { environmentId: true, + schedulePhase: true, }, }, active: true, @@ -300,6 +300,23 @@ export class ScheduleListPresenter extends BasePresenter { } } + const instance = schedule.instances.find( + (instance) => instance.environmentId === environmentId + ); + if (!instance) { + throw new Error(`Schedule instance not found for environment: ${environmentId}`); + } + const [nextRun] = calculateNextScheduleRunTimes({ + cron: schedule.generatorExpression, + timezone: schedule.timezone, + deduplicationKey: schedule.deduplicationKey, + environmentId, + schedulePhase: instance.schedulePhase, + phaseSecret: env.ENCRYPTION_KEY, + windowDurationSeconds: schedule.windowDurationSeconds, + windowPercentage: schedule.windowPercentage, + }); + return { id: schedule.id, type: schedule.type, @@ -314,10 +331,8 @@ export class ScheduleListPresenter extends BasePresenter { active: schedule.active, externalId: schedule.externalId, lastRun, - nextRun: calculateNextScheduledTimestampFromNow( - schedule.generatorExpression, - schedule.timezone - ), + nextRun: nextRun.nominalAt, + nextRunEffectiveAt: nextRun.effectiveAt, environments: schedule.instances.map((instance) => { const environment = project.environments.find((env) => env.id === instance.environmentId); if (!environment) { diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index b680a1c6a8..11b923566c 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -33,6 +33,7 @@ import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.se import { buildSyntheticSpanRun } from "~/v3/mollifier/syntheticSpanRun.server"; import { engine } from "~/v3/runEngine.server"; import { runStore } from "~/v3/runStore.server"; +import { runTriggeredAt } from "~/v3/runTimestamps"; import { getTaskEventStoreTableForRun, type TaskEventStoreTable } from "~/v3/taskEventStore.server"; import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus"; import { BasePresenter } from "./basePresenter.server"; @@ -387,7 +388,7 @@ export class SpanPresenter extends BasePresenter { friendlyId: run.friendlyId, status: run.status, statusReason: run.statusReason ?? undefined, - createdAt: run.createdAt, + createdAt: runTriggeredAt(run), startedAt: run.startedAt, executedAt: run.executedAt, updatedAt: run.updatedAt, @@ -559,6 +560,7 @@ export class SpanPresenter extends BasePresenter { startedAt: true, executedAt: true, createdAt: true, + queueTimestamp: true, updatedAt: true, queuedAt: true, completedAt: true, diff --git a/apps/webapp/app/presenters/v3/TestPresenter.server.ts b/apps/webapp/app/presenters/v3/TestPresenter.server.ts index 8555b8117e..22cac2c384 100644 --- a/apps/webapp/app/presenters/v3/TestPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TestPresenter.server.ts @@ -52,11 +52,13 @@ export class TestPresenter extends BasePresenter { SELECT bwt.id, version, slug, "filePath", bwt."friendlyId", bwt."triggerSource" FROM latest_workers JOIN ${sqlDatabaseSchema}."BackgroundWorkerTask" bwt ON bwt."workerId" = latest_workers.id - WHERE bwt."triggerSource" != 'AGENT' + WHERE bwt."triggerSource" NOT IN ('AGENT', 'WEBHOOK') ORDER BY slug ASC;`; } else { const currentDeployment = await findCurrentWorkerDeployment({ environmentId: envId }); - return (currentDeployment?.worker?.tasks ?? []).filter((t) => t.triggerSource !== "AGENT"); + return (currentDeployment?.worker?.tasks ?? []).filter( + (t) => t.triggerSource !== "AGENT" && t.triggerSource !== "WEBHOOK" + ); } } } diff --git a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts index 6f60b4c3eb..fc8885c6cd 100644 --- a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts @@ -108,6 +108,10 @@ export type TestTaskResult = allowArbitraryQueues: boolean; taskRunTemplates: TaskRunTemplate[]; } + | { + foundTask: true; + triggerSource: "WEBHOOK"; + } | { foundTask: false; }; @@ -120,7 +124,6 @@ export type ScheduledTaskResult = Extract< TestTaskResult, { foundTask: true; triggerSource: "SCHEDULED" } >; - type RawRun = { id: string; queue: string; @@ -386,6 +389,9 @@ export class TestTaskPresenter { // AGENT tasks are filtered out by TestPresenter and shouldn't reach here return { foundTask: false }; } + case "WEBHOOK": { + return { foundTask: true, triggerSource: "WEBHOOK" }; + } default: { return task.triggerSource satisfies never; } diff --git a/apps/webapp/app/presenters/v3/UnifiedTaskListPresenter.server.ts b/apps/webapp/app/presenters/v3/UnifiedTaskListPresenter.server.ts index 6f98e15447..f0508d9eed 100644 --- a/apps/webapp/app/presenters/v3/UnifiedTaskListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/UnifiedTaskListPresenter.server.ts @@ -13,7 +13,7 @@ import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.s import { agentListPresenter, type AgentActiveState } from "./AgentListPresenter.server"; import { taskListPresenter, type TaskListItem } from "./TaskListPresenter.server"; -export type UnifiedTaskKind = "STANDARD" | "SCHEDULED" | "AGENT"; +export type UnifiedTaskKind = "STANDARD" | "SCHEDULED" | "AGENT" | "WEBHOOK"; export type UnifiedTaskListItem = { kind: UnifiedTaskKind; @@ -215,7 +215,12 @@ function toUnifiedItems( for (const task of tasks) { items.push({ - kind: task.triggerSource === "SCHEDULED" ? "SCHEDULED" : "STANDARD", + kind: + task.triggerSource === "SCHEDULED" + ? "SCHEDULED" + : task.triggerSource === "WEBHOOK" + ? "WEBHOOK" + : "STANDARD", slug: task.slug, filePath: task.filePath, triggerSource: task.triggerSource, diff --git a/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts b/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts index bc7d0388b0..61716b7364 100644 --- a/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts @@ -3,10 +3,10 @@ import type { PrismaClient } from "~/db.server"; import { prisma } from "~/db.server"; import { displayableEnvironment } from "~/models/runtimeEnvironment.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; -import { nextScheduledTimestamps } from "~/v3/utils/calculateNextSchedule.server"; import { NextRunListPresenter } from "./NextRunListPresenter.server"; import { scheduleWhereClause } from "~/models/schedules.server"; -import { formatScheduleWindow } from "~/v3/scheduleWindow.server"; +import { calculateNextScheduleRunTimes, formatScheduleWindow } from "~/v3/scheduleWindow.server"; +import { env } from "~/env.server"; type ViewScheduleOptions = { userId?: string; @@ -52,6 +52,8 @@ export class ViewSchedulePresenter { }, instances: { select: { + environmentId: true, + schedulePhase: true, environment: { select: { id: true, @@ -82,8 +84,25 @@ export class ViewSchedulePresenter { return; } + const instance = schedule.instances.find( + (instance) => instance.environmentId === environmentId + ); + if (!instance && schedule.instances.length > 0) { + return; + } + const nextRuns = schedule.active - ? nextScheduledTimestamps(schedule.generatorExpression, schedule.timezone, new Date(), 5) + ? calculateNextScheduleRunTimes({ + cron: schedule.generatorExpression, + timezone: schedule.timezone, + deduplicationKey: schedule.deduplicationKey, + environmentId, + schedulePhase: instance?.schedulePhase ?? null, + phaseSecret: env.ENCRYPTION_KEY, + windowDurationSeconds: schedule.windowDurationSeconds, + windowPercentage: schedule.windowPercentage, + count: 5, + }) : []; const runs = includeRunHistory @@ -101,6 +120,7 @@ export class ViewSchedulePresenter { timezone: schedule.timezone, cron: schedule.generatorExpression, cronDescription: schedule.generatorDescription, + window: formatScheduleWindow(schedule), nextRuns, runs, environments: schedule.instances.map((instance) => { @@ -146,14 +166,15 @@ export class ViewSchedulePresenter { type: result.schedule.type, task: result.schedule.taskIdentifier, active: result.schedule.active, - nextRun: result.schedule.nextRuns[0], + nextRun: result.schedule.nextRuns[0]?.nominalAt ?? null, + nextRunEffectiveAt: result.schedule.nextRuns[0]?.effectiveAt ?? null, generator: { type: "CRON", expression: result.schedule.cron, description: result.schedule.cronDescription, }, timezone: result.schedule.timezone, - window: formatScheduleWindow(result.schedule), + window: result.schedule.window, externalId: result.schedule.externalId ?? undefined, deduplicationKey: result.schedule.userProvidedDeduplicationKey ? (result.schedule.deduplicationKey ?? undefined) diff --git a/apps/webapp/app/presenters/v3/WebhookDeliveriesListPresenter.server.ts b/apps/webapp/app/presenters/v3/WebhookDeliveriesListPresenter.server.ts new file mode 100644 index 0000000000..31d13820e1 --- /dev/null +++ b/apps/webapp/app/presenters/v3/WebhookDeliveriesListPresenter.server.ts @@ -0,0 +1,236 @@ +import { type ClickHouse } from "@internal/clickhouse"; +import { type PrismaClientOrTransaction, type WebhookDeliveryStatus } from "@trigger.dev/database"; +import parseDuration from "parse-duration"; +import { boundedIn, webhookReplica } from "~/db.server"; +import { runStore } from "~/v3/runStore.server"; +import { webhookDeliveriesRepository } from "~/services/webhookDeliveriesRepository/webhookDeliveriesRepository.server"; +import { + resolveDeliveryRunTargets, + type WebhookDeliveryListItem, +} from "./WebhookDetailPresenter.server"; + +const DELIVERIES_PAGE_SIZE = 60; +type Direction = "forward" | "backward"; + +export type WebhookDeliveriesListResult = { + deliveries: WebhookDeliveryListItem[]; + pagination: { next?: string; previous?: string }; +}; + +/** + * The top-level (cross-endpoint) deliveries list: every delivery in the environment, + * across all webhook endpoints/handlers. Mirrors WebhookDetailPresenter.listDeliveries but + * with no webhookEndpointId filter, plus it hydrates which webhook each delivery belongs to + * for the table's Webhook column. + */ +export class WebhookDeliveriesListPresenter { + constructor( + private readonly replica: PrismaClientOrTransaction, + private readonly clickhouse: ClickHouse + ) {} + + async call({ + organizationId, + projectId, + environmentId, + webhooks, + statuses, + deliveryId, + runId, + isTest, + period, + from, + to, + cursor, + direction, + pageSize, + }: { + organizationId: string; + projectId: string; + environmentId: string; + webhooks?: string[]; + statuses?: WebhookDeliveryStatus[]; + deliveryId?: string; + runId?: string; + isTest?: boolean; + period?: string; + from?: number; + to?: number; + cursor?: string; + direction?: Direction; + pageSize?: number; + }): Promise { + const periodMs = period ? (parseDuration(period) ?? undefined) : undefined; + + const { webhookEndpointIds, internalRunId } = await this.#resolveFilterScope( + environmentId, + webhooks, + runId + ); + + // Built per request (factory, NOT a singleton), matching every RunsRepository consumer. + const repository = webhookDeliveriesRepository({ + clickhouse: this.clickhouse, + prisma: webhookReplica, + }); + + // No webhookEndpointId: all endpoints in the environment. + const { deliveries, pagination } = await repository.listDeliveries({ + organizationId, + projectId, + environmentId, + webhookEndpointIds, + deliveryId, + runId: internalRunId, + statuses, + isTest, + period: periodMs, + from, + to, + page: { size: pageSize ?? DELIVERIES_PAGE_SIZE, cursor, direction }, + }); + + // Resolve run friendlyIds (the runId is the INTERNAL id; the table links by friendlyId) and, for + // session deliveries, the session the run belongs to. + const { runFriendlyIdById, sessionByRunId } = await resolveDeliveryRunTargets( + this.replica, + deliveries + ); + + // Resolve which webhook (handler) each delivery belongs to for the Webhook column. + const endpointIds = Array.from(new Set(deliveries.map((d) => d.webhookEndpointId))); + const endpointById = new Map(); + if (endpointIds.length > 0) { + const endpoints = await webhookReplica.webhookEndpoint.findMany({ + where: { id: { in: boundedIn(endpointIds) } }, + select: { id: true, handlerWebhookId: true, source: true }, + }); + for (const e of endpoints) { + endpointById.set(e.id, { slug: e.handlerWebhookId, source: e.source }); + } + } + + const items: WebhookDeliveryListItem[] = deliveries.map((d) => { + const friendlyId = d.runId ? runFriendlyIdById.get(d.runId) : undefined; + return { + id: d.id, + friendlyId: d.friendlyId, + externalDeliveryId: d.externalDeliveryId, + status: d.status, + isTest: d.isTest, + runId: d.runId, + run: friendlyId ? { friendlyId } : null, + session: d.runId ? (sessionByRunId.get(d.runId) ?? null) : null, + errorMessage: d.errorMessage, + createdAt: d.createdAt, + processedAt: d.processedAt, + webhook: endpointById.get(d.webhookEndpointId) ?? null, + }; + }); + + return { + deliveries: items, + pagination: { + next: pagination.nextCursor ?? undefined, + previous: pagination.previousCursor ?? undefined, + }, + }; + } + + /** + * Count deliveries newer than `since` that match the same filters the list is showing, for the + * live "N new deliveries" badge. Applies the same filter resolution as {@link call} so the badge + * never counts events the filtered list would exclude. `webhookEndpointId` scopes to a single + * endpoint (the per-webhook page); `webhooks` is the cross-endpoint handler filter. + */ + async countNewDeliveries({ + organizationId, + projectId, + environmentId, + webhookEndpointId, + webhooks, + statuses, + deliveryId, + runId, + isTest, + since, + to, + }: { + organizationId: string; + projectId: string; + environmentId: string; + webhookEndpointId?: string; + webhooks?: string[]; + statuses?: WebhookDeliveryStatus[]; + deliveryId?: string; + runId?: string; + isTest?: boolean; + since: number; + to?: number; + }): Promise { + if (to !== undefined && to <= since) return 0; + + const { webhookEndpointIds, internalRunId } = await this.#resolveFilterScope( + environmentId, + webhooks, + runId + ); + + const repository = webhookDeliveriesRepository({ + clickhouse: this.clickhouse, + prisma: webhookReplica, + }); + + const { deliveryIds } = await repository.listDeliveryIds({ + organizationId, + projectId, + environmentId, + webhookEndpointId, + webhookEndpointIds, + deliveryId, + runId: internalRunId, + statuses, + isTest, + from: since + 1, + to, + page: { size: 100 }, + }); + + return deliveryIds.length; + } + + /** + * Resolve the handler-slug webhook filter to endpoint ids and the friendly runId to the internal + * id. A non-empty filter that matches nothing resolves to a sentinel that can never match, so the + * filter returns nothing rather than being dropped. + */ + async #resolveFilterScope( + environmentId: string, + webhooks?: string[], + runId?: string + ): Promise<{ webhookEndpointIds?: string[]; internalRunId?: string }> { + let webhookEndpointIds: string[] | undefined; + if (webhooks && webhooks.length > 0) { + const endpoints = await webhookReplica.webhookEndpoint.findMany({ + where: { + runtimeEnvironmentId: environmentId, + handlerWebhookId: { in: boundedIn(webhooks) }, + }, + select: { id: true }, + }); + webhookEndpointIds = endpoints.length > 0 ? endpoints.map((e) => e.id) : ["__none__"]; + } + + let internalRunId: string | undefined; + if (runId) { + const run = await runStore.findRun( + { friendlyId: runId }, + { select: { id: true } }, + this.replica + ); + internalRunId = run?.id ?? "__none__"; + } + + return { webhookEndpointIds, internalRunId }; + } +} diff --git a/apps/webapp/app/presenters/v3/WebhookDeliveryDetailPresenter.server.ts b/apps/webapp/app/presenters/v3/WebhookDeliveryDetailPresenter.server.ts new file mode 100644 index 0000000000..feb8ac8109 --- /dev/null +++ b/apps/webapp/app/presenters/v3/WebhookDeliveryDetailPresenter.server.ts @@ -0,0 +1,120 @@ +import { type ClickHouse } from "@internal/clickhouse"; +import { + type Prisma, + type PrismaClientOrTransaction, + type RuntimeEnvironmentType, + type WebhookDeliveryStatus, +} from "@trigger.dev/database"; +import { webhookReplica } from "~/db.server"; +import { runStore } from "~/v3/runStore.server"; +import { webhookDeliveriesRepository } from "~/services/webhookDeliveriesRepository/webhookDeliveriesRepository.server"; + +export type WebhookDeliveryDetail = { + id: string; + friendlyId: string; + status: WebhookDeliveryStatus; + externalDeliveryId: string; + idempotencyKey: string; + rawBodyHash: string | null; + parsedEvent: Prisma.JsonValue | null; + headers: Prisma.JsonValue | null; + errorMessage: string | null; + filterReason: string | null; + environmentType: RuntimeEnvironmentType; + createdAt: Date; + updatedAt: Date; + processedAt: Date | null; + // Resolved from the run id (deliveries store the INTERNAL id; the UI links by friendlyId). + run: { friendlyId: string } | null; + // Set when the run belongs to a chat.agent session (the delivery routed to a session); the session + // is the meaningful target, so the UI links it instead of the incidental run. + session: { friendlyId: string; externalId: string | null } | null; + // The handler webhook this delivery routed to. + webhook: { slug: string; source: string } | null; +}; + +/** + * A single webhook delivery, by its friendlyId. ClickHouse resolves the friendlyId + * to (id, createdAt) for a partition-pruned Postgres point lookup, then this hydrates + * the run friendlyId and the handler webhook for the detail view. + */ +export class WebhookDeliveryDetailPresenter { + constructor( + private readonly replica: PrismaClientOrTransaction, + private readonly clickhouse: ClickHouse + ) {} + + async call({ + organizationId, + projectId, + environmentId, + deliveryFriendlyId, + }: { + organizationId: string; + projectId: string; + environmentId: string; + deliveryFriendlyId: string; + }): Promise { + const repository = webhookDeliveriesRepository({ + clickhouse: this.clickhouse, + prisma: webhookReplica, + }); + + const delivery = await repository.getDelivery({ + organizationId, + projectId, + environmentId, + friendlyId: deliveryFriendlyId, + }); + + if (!delivery) { + return null; + } + + // Defense in depth: getDelivery is already env-scoped via ClickHouse, but the + // Postgres row is the source of truth, so confirm it belongs to this environment. + if (delivery.runtimeEnvironmentId !== environmentId) { + return null; + } + + let run: { friendlyId: string } | null = null; + let session: { friendlyId: string; externalId: string | null } | null = null; + if (delivery.runId) { + const [taskRun, sessionRun] = await Promise.all([ + runStore.findRun({ id: delivery.runId }, { select: { friendlyId: true } }, this.replica), + this.replica.sessionRun.findFirst({ + where: { runId: delivery.runId }, + select: { session: { select: { friendlyId: true, externalId: true } } }, + }), + ]); + run = taskRun ? { friendlyId: taskRun.friendlyId } : null; + session = sessionRun?.session ?? null; + } + + const endpoint = await webhookReplica.webhookEndpoint.findFirst({ + where: { id: delivery.webhookEndpointId }, + select: { handlerWebhookId: true, source: true }, + }); + const webhook = endpoint ? { slug: endpoint.handlerWebhookId, source: endpoint.source } : null; + + return { + id: delivery.id, + friendlyId: delivery.friendlyId, + status: delivery.status, + externalDeliveryId: delivery.externalDeliveryId, + idempotencyKey: delivery.idempotencyKey, + rawBodyHash: delivery.rawBodyHash, + parsedEvent: delivery.parsedEvent, + headers: delivery.headers, + errorMessage: delivery.errorMessage, + filterReason: delivery.filterReason, + environmentType: delivery.environmentType, + createdAt: delivery.createdAt, + updatedAt: delivery.updatedAt, + processedAt: delivery.processedAt, + run, + session, + webhook, + }; + } +} diff --git a/apps/webapp/app/presenters/v3/WebhookDetailPresenter.server.ts b/apps/webapp/app/presenters/v3/WebhookDetailPresenter.server.ts new file mode 100644 index 0000000000..b91b2f104c --- /dev/null +++ b/apps/webapp/app/presenters/v3/WebhookDetailPresenter.server.ts @@ -0,0 +1,648 @@ +import { type ClickHouse } from "@internal/clickhouse"; +import { + type Prisma, + type PrismaClientOrTransaction, + type RuntimeEnvironmentType, + type WebhookDeliveryStatus, + type WebhookEndpointStatus, +} from "@trigger.dev/database"; +import parseDuration from "parse-duration"; +import { z } from "zod"; +import { type Direction } from "~/components/ListPagination"; +import { boundedIn, webhookReplica } from "~/db.server"; +import { runStore } from "~/v3/runStore.server"; +import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; +import { webhookDeliveriesRepository } from "~/services/webhookDeliveriesRepository/webhookDeliveriesRepository.server"; +import { + buildWebhookComposerEndpoints, + type WebhookComposerEndpointData, +} from "./webhookComposerEndpoints.server"; + +export type WebhookEndpointSummary = { + id: string; + opaqueId: string; + status: string; + hasSigningSecret: boolean; +}; + +export type WebhookDetail = { + slug: string; + filePath: string; + triggerSource: "WEBHOOK"; + source: string; + metadata: unknown; + createdAt: Date; + endpoint: WebhookEndpointSummary; +}; + +export type WebhookActivityPoint = { + bucket: number; // epoch ms +} & Record; + +export type WebhookActivity = { + data: WebhookActivityPoint[]; + statuses: string[]; +}; + +export type WebhookDeliveryListItem = { + id: string; + friendlyId: string; + externalDeliveryId: string; + status: WebhookDeliveryStatus; + isTest: boolean; + runId: string | null; + run: { friendlyId: string } | null; + // Set when the delivery routed to a chat.agent session (the run belongs to a session). The session + // is the meaningful target here, so the table links it instead of the incidental run. + session: { friendlyId: string; externalId: string | null } | null; + errorMessage: string | null; + createdAt: Date; + processedAt: Date | null; + // Only populated by the cross-endpoint (top-level) deliveries list, where the + // table shows which webhook each delivery belongs to. Undefined on the scoped + // per-webhook detail page. + webhook?: { slug: string; source: string } | null; +}; + +export type WebhookDeliveriesList = { + deliveries: WebhookDeliveryListItem[]; + pagination: { next?: string; previous?: string }; + filters: { from?: number; to?: number }; + hasFilters: boolean; +}; + +/** + * Resolve delivery run ids (INTERNAL, no FK) to their run friendlyId and, when the run belongs to a + * chat.agent session, the session it targeted. Shared by the per-endpoint and cross-endpoint lists. + */ +export async function resolveDeliveryRunTargets( + replica: PrismaClientOrTransaction, + deliveries: { runId: string | null }[] +): Promise<{ + runFriendlyIdById: Map; + sessionByRunId: Map; +}> { + const runIds = Array.from( + new Set(deliveries.map((d) => d.runId).filter((id): id is string => Boolean(id))) + ); + const runFriendlyIdById = new Map(); + const sessionByRunId = new Map(); + if (runIds.length === 0) return { runFriendlyIdById, sessionByRunId }; + + const [runs, sessionRuns] = await Promise.all([ + runStore.findRuns( + { where: { id: { in: boundedIn(runIds) } }, select: { id: true, friendlyId: true } }, + replica + ), + replica.sessionRun.findMany({ + where: { runId: { in: boundedIn(runIds) } }, + select: { runId: true, session: { select: { friendlyId: true, externalId: true } } }, + }), + ]); + for (const run of runs) runFriendlyIdById.set(run.id, run.friendlyId); + for (const sr of sessionRuns) sessionByRunId.set(sr.runId, sr.session); + return { runFriendlyIdById, sessionByRunId }; +} + +export type WebhookEndpointListItem = { + friendlyId: string; + // The declared default endpoint (no tenant/externalRef scope). + isDefault: boolean; + tenantId: string | null; + externalRef: string | null; + status: WebhookEndpointStatus; + hasSigningSecret: boolean; + deliveryCount: number; +}; + +export type WebhookEndpointDetail = { + id: string; + friendlyId: string; + opaqueId: string; + handlerWebhookId: string; + source: string; + status: WebhookEndpointStatus; + isDefault: boolean; + tenantId: string; + externalRef: string; + hasSigningSecret: boolean; + // "provider" | "integrator" | "either" — drives the Connect UI (paste vs generate). + secretProvisioning: string; + // Tagged-union JSON parsed by the route with the @trigger.dev/core schemas. + routingTarget: Prisma.JsonValue; + verifierArtifact: Prisma.JsonValue; + metadata: Prisma.JsonValue; + createdAt: Date; + updatedAt: Date; +}; + +// 7-day rolling window for the per-endpoint delivery counts on the Endpoints tab. +const ENDPOINT_DELIVERY_COUNT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +// Run-status group order, shared with getRunActivity. Mirrors AgentDetailPresenter. +const TERMINAL_GROUPS = { + COMPLETED: ["COMPLETED_SUCCESSFULLY"], + FAILED: ["COMPLETED_WITH_ERRORS", "SYSTEM_FAILURE", "CRASHED", "INTERRUPTED", "TIMED_OUT"], + CANCELED: ["CANCELED", "EXPIRED"], + RUNNING: [ + "EXECUTING", + "DEQUEUED", + "PENDING_EXECUTING", + "WAITING_TO_RESUME", + "QUEUED_EXECUTING", + "PENDING", + "PENDING_VERSION", + "DELAYED", + "WAITING_FOR_DEPLOY", + ], +} as const; + +const GROUP_LABEL = ["COMPLETED", "FAILED", "CANCELED", "RUNNING"] as const; +type GroupLabel = (typeof GROUP_LABEL)[number]; + +function groupForStatus(status: string): GroupLabel | undefined { + for (const label of GROUP_LABEL) { + if ((TERMINAL_GROUPS[label] as readonly string[]).includes(status)) return label; + } + return undefined; +} + +// Stable legend order for the deliveries activity chart. +const DELIVERY_STATUSES = ["PENDING", "PROCESSING", "SUCCEEDED", "FAILED", "FILTERED"] as const; + +const DELIVERIES_PAGE_SIZE = 25; + +export class WebhookDetailPresenter { + constructor( + private readonly replica: PrismaClientOrTransaction, + private readonly clickhouse: ClickHouse + ) {} + + async findWebhook({ + environmentId, + environmentType, + webhookSlug, + }: { + environmentId: string; + environmentType: RuntimeEnvironmentType; + webhookSlug: string; + }): Promise { + const currentWorker = await findCurrentWorkerFromEnvironment( + { id: environmentId, type: environmentType }, + this.replica + ); + + if (!currentWorker) return null; + + const task = await this.replica.backgroundWorkerTask.findFirst({ + where: { + workerId: currentWorker.id, + slug: webhookSlug, + triggerSource: "WEBHOOK", + }, + select: { + slug: true, + filePath: true, + triggerSource: true, + createdAt: true, + }, + }); + + if (!task) return null; + + const endpoint = await webhookReplica.webhookEndpoint.findFirst({ + where: { + runtimeEnvironmentId: environmentId, + handlerWebhookId: webhookSlug, + endpointTenantId: "", + endpointExternalRef: "", + }, + select: { + id: true, + opaqueId: true, + status: true, + source: true, + metadata: true, + // signingSecretKey is selected ONLY to derive hasSigningSecret below. + // The secret value never leaves this method. + signingSecretKey: true, + }, + }); + + if (!endpoint) return null; + + return { + slug: task.slug, + filePath: task.filePath, + triggerSource: "WEBHOOK", + source: endpoint.source, + metadata: endpoint.metadata, + createdAt: task.createdAt, + endpoint: { + id: endpoint.id, + opaqueId: endpoint.opaqueId, + status: endpoint.status, + hasSigningSecret: endpoint.signingSecretKey != null && endpoint.signingSecretKey !== "", + }, + }; + } + + async listEndpoints({ + organizationId, + projectId, + environmentId, + handlerWebhookId, + }: { + organizationId: string; + projectId: string; + environmentId: string; + handlerWebhookId: string; + }): Promise { + const endpoints = await webhookReplica.webhookEndpoint.findMany({ + where: { runtimeEnvironmentId: environmentId, handlerWebhookId }, + select: { + id: true, + friendlyId: true, + endpointTenantId: true, + endpointExternalRef: true, + status: true, + signingSecretKey: true, + createdAt: true, + }, + // Default endpoint (empty scope) first, then most recent. + orderBy: [{ endpointTenantId: "asc" }, { createdAt: "desc" }], + }); + + const repository = webhookDeliveriesRepository({ + clickhouse: this.clickhouse, + prisma: webhookReplica, + }); + + // Per-endpoint 7d delivery counts in ONE grouped CH query (not an N+1 of count queries). + // Degrade to empty (0 per endpoint) on error rather than failing the whole tab. + const deliveryCounts = await repository + .countDeliveriesByEndpoint({ + organizationId, + projectId, + environmentId, + webhookEndpointIds: endpoints.map((endpoint) => endpoint.id), + period: ENDPOINT_DELIVERY_COUNT_WINDOW_MS, + }) + .catch(() => new Map()); + + return endpoints.map((endpoint) => { + const isDefault = endpoint.endpointTenantId === "" && endpoint.endpointExternalRef === ""; + + return { + friendlyId: endpoint.friendlyId, + isDefault, + tenantId: endpoint.endpointTenantId === "" ? null : endpoint.endpointTenantId, + externalRef: endpoint.endpointExternalRef === "" ? null : endpoint.endpointExternalRef, + status: endpoint.status, + hasSigningSecret: endpoint.signingSecretKey != null && endpoint.signingSecretKey !== "", + deliveryCount: deliveryCounts.get(endpoint.id) ?? 0, + } satisfies WebhookEndpointListItem; + }); + } + + async listComposerEndpoints({ + environmentId, + handlerWebhookId, + }: { + environmentId: string; + handlerWebhookId: string; + }): Promise { + const endpoints = await webhookReplica.webhookEndpoint.findMany({ + where: { runtimeEnvironmentId: environmentId, handlerWebhookId }, + select: { + friendlyId: true, + opaqueId: true, + source: true, + endpointTenantId: true, + endpointExternalRef: true, + verifierArtifact: true, + signingSecretKey: true, + }, + orderBy: [{ endpointTenantId: "asc" }, { createdAt: "desc" }], + }); + return buildWebhookComposerEndpoints(endpoints); + } + + async findEndpoint({ + environmentId, + endpointFriendlyId, + }: { + environmentId: string; + endpointFriendlyId: string; + }): Promise { + const endpoint = await webhookReplica.webhookEndpoint.findFirst({ + // friendlyId is globally unique, but scope to the env so a foreign id 404s. + where: { friendlyId: endpointFriendlyId, runtimeEnvironmentId: environmentId }, + select: { + id: true, + friendlyId: true, + opaqueId: true, + handlerWebhookId: true, + source: true, + status: true, + endpointTenantId: true, + endpointExternalRef: true, + signingSecretKey: true, + secretProvisioning: true, + routingTarget: true, + verifierArtifact: true, + metadata: true, + createdAt: true, + updatedAt: true, + }, + }); + + if (!endpoint) return null; + + return { + id: endpoint.id, + friendlyId: endpoint.friendlyId, + opaqueId: endpoint.opaqueId, + handlerWebhookId: endpoint.handlerWebhookId, + source: endpoint.source, + status: endpoint.status, + isDefault: endpoint.endpointTenantId === "" && endpoint.endpointExternalRef === "", + tenantId: endpoint.endpointTenantId, + externalRef: endpoint.endpointExternalRef, + hasSigningSecret: endpoint.signingSecretKey != null && endpoint.signingSecretKey !== "", + secretProvisioning: endpoint.secretProvisioning, + routingTarget: endpoint.routingTarget, + verifierArtifact: endpoint.verifierArtifact, + metadata: endpoint.metadata, + createdAt: endpoint.createdAt, + updatedAt: endpoint.updatedAt, + }; + } + + async getRunActivity({ + organizationId, + projectId, + environmentId, + webhookSlug, + from, + to, + }: { + organizationId: string; + projectId: string; + environmentId: string; + webhookSlug: string; + from: Date; + to: Date; + }): Promise { + const rangeMs = Math.max(1, to.getTime() - from.getTime()); + const oneHour = 60 * 60 * 1000; + const oneDay = 24 * oneHour; + + const bucketSeconds = + rangeMs <= oneDay ? 60 * 60 : rangeMs <= 7 * oneDay ? 6 * 60 * 60 : 24 * 60 * 60; + + // FINAL + _is_deleted = 0 because task_runs_v2 is a ReplacingMergeTree; + // org/project filters engage the sort-key prefix for partition pruning. + const queryFn = this.clickhouse.reader.query({ + name: "webhookRunStatusActivity", + query: `SELECT + toUnixTimestamp(toStartOfInterval(created_at, INTERVAL {bucketSeconds: UInt32} SECOND)) AS bucket, + status, + count() AS val + FROM trigger_dev.task_runs_v2 FINAL + WHERE organization_id = {organizationId: String} + AND project_id = {projectId: String} + AND environment_id = {environmentId: String} + AND task_identifier = {webhookSlug: String} + AND created_at >= {fromTime: DateTime64(3, 'UTC')} + AND created_at < {toTime: DateTime64(3, 'UTC')} + AND _is_deleted = 0 + GROUP BY bucket, status + ORDER BY bucket`, + params: z.object({ + organizationId: z.string(), + projectId: z.string(), + environmentId: z.string(), + webhookSlug: z.string(), + bucketSeconds: z.number(), + fromTime: z.string(), + toTime: z.string(), + }), + schema: z.object({ + bucket: z.coerce.number(), + status: z.string(), + val: z.coerce.number(), + }), + }); + + const [error, rows] = await queryFn({ + organizationId, + projectId, + environmentId, + webhookSlug, + bucketSeconds, + // ClickHouse's DateTime64(3, 'UTC') parser rejects the trailing `Z` from + // JS toISOString(). Strip it. + fromTime: from.toISOString().slice(0, -1), + toTime: to.toISOString().slice(0, -1), + }); + + if (error) { + console.error("Webhook run activity query failed:", error); + return { data: [], statuses: [] }; + } + + const bucketMap = new Map>(); + for (const row of rows) { + const group = groupForStatus(row.status) ?? "RUNNING"; + const ts = row.bucket * 1000; + const existing = bucketMap.get(ts) ?? {}; + existing[group] = (existing[group] ?? 0) + row.val; + bucketMap.set(ts, existing); + } + + const bucketMs = bucketSeconds * 1000; + const start = Math.floor(from.getTime() / bucketMs) * bucketMs; + const end = Math.ceil(to.getTime() / bucketMs) * bucketMs; + const points: WebhookActivityPoint[] = []; + const orderedStatuses = [...GROUP_LABEL]; + for (let ts = start; ts < end; ts += bucketMs) { + const existing = bucketMap.get(ts) ?? {}; + const point: WebhookActivityPoint = { bucket: ts }; + for (const g of orderedStatuses) { + point[g] = existing[g] ?? 0; + } + points.push(point); + } + + return { data: points, statuses: orderedStatuses }; + } + + async getDeliveryActivity({ + organizationId, + projectId, + environmentId, + webhookEndpointId, + from, + to, + }: { + organizationId: string; + projectId: string; + environmentId: string; + webhookEndpointId: string; + from: Date; + to: Date; + }): Promise { + const rangeMs = Math.max(1, to.getTime() - from.getTime()); + const oneHour = 60 * 60 * 1000; + const oneDay = 24 * oneHour; + const bucketSeconds = rangeMs <= oneDay ? 3600 : rangeMs <= 7 * oneDay ? 6 * 3600 : 24 * 3600; + + const queryFn = this.clickhouse.reader.query({ + name: "webhookDeliveryStatusActivity", + query: `SELECT + toUnixTimestamp(toStartOfInterval(created_at, INTERVAL {bucketSeconds: UInt32} SECOND)) AS bucket, + status, count() AS val + FROM trigger_dev.webhook_deliveries_v1 FINAL + WHERE organization_id = {organizationId: String} + AND project_id = {projectId: String} + AND environment_id = {environmentId: String} + AND webhook_endpoint_id = {webhookEndpointId: String} + AND created_at >= {fromTime: DateTime64(3, 'UTC')} + AND created_at < {toTime: DateTime64(3, 'UTC')} + AND _is_deleted = 0 + GROUP BY bucket, status + ORDER BY bucket`, + params: z.object({ + organizationId: z.string(), + projectId: z.string(), + environmentId: z.string(), + webhookEndpointId: z.string(), + bucketSeconds: z.number(), + fromTime: z.string(), + toTime: z.string(), + }), + schema: z.object({ + bucket: z.coerce.number(), + status: z.string(), + val: z.coerce.number(), + }), + }); + + const [error, rows] = await queryFn({ + organizationId, + projectId, + environmentId, + webhookEndpointId, + bucketSeconds, + fromTime: from.toISOString().slice(0, -1), + toTime: to.toISOString().slice(0, -1), + }); + + if (error) { + console.error("Webhook delivery activity query failed:", error); + return { data: [], statuses: [] }; + } + + const bucketMap = new Map>(); + for (const row of rows) { + const ts = row.bucket * 1000; + const existing = bucketMap.get(ts) ?? {}; + existing[row.status] = (existing[row.status] ?? 0) + row.val; + bucketMap.set(ts, existing); + } + + const bucketMs = bucketSeconds * 1000; + const start = Math.floor(from.getTime() / bucketMs) * bucketMs; + const end = Math.ceil(to.getTime() / bucketMs) * bucketMs; + const points: WebhookActivityPoint[] = []; + const orderedStatuses = [...DELIVERY_STATUSES]; + for (let ts = start; ts < end; ts += bucketMs) { + const existing = bucketMap.get(ts) ?? {}; + const point: WebhookActivityPoint = { bucket: ts }; + for (const s of orderedStatuses) { + point[s] = existing[s] ?? 0; + } + points.push(point); + } + + return { data: points, statuses: orderedStatuses }; + } + + async listDeliveries({ + organizationId, + projectId, + environmentId, + webhookEndpointId, + period, + from, + to, + hasExplicitWindow, + cursor, + direction, + }: { + organizationId: string; + projectId: string; + environmentId: string; + webhookEndpointId: string; + period?: string; + from?: number; + to?: number; + hasExplicitWindow?: boolean; + cursor?: string; + direction?: Direction; + }): Promise { + const periodMs = period ? (parseDuration(period) ?? undefined) : undefined; + + // Built per request (factory, NOT a singleton), matching every RunsRepository consumer. + const repository = webhookDeliveriesRepository({ + clickhouse: this.clickhouse, + prisma: webhookReplica, + }); + + const { deliveries, pagination } = await repository.listDeliveries({ + organizationId, + projectId, + environmentId, + webhookEndpointId, + period: periodMs, + from, + to, + page: { size: DELIVERIES_PAGE_SIZE, cursor, direction }, + }); + + // A delivery's runId is the INTERNAL run id (no FK); resolve friendlyIds and, for session + // deliveries, the session the run belongs to, with a small keyed lookup. + const { runFriendlyIdById, sessionByRunId } = await resolveDeliveryRunTargets( + this.replica, + deliveries + ); + + const items: WebhookDeliveryListItem[] = deliveries.map((d) => { + const friendlyId = d.runId ? runFriendlyIdById.get(d.runId) : undefined; + return { + id: d.id, + friendlyId: d.friendlyId, + externalDeliveryId: d.externalDeliveryId, + status: d.status, + isTest: d.isTest, + runId: d.runId, + run: friendlyId ? { friendlyId } : null, + session: d.runId ? (sessionByRunId.get(d.runId) ?? null) : null, + errorMessage: d.errorMessage, + createdAt: d.createdAt, + processedAt: d.processedAt, + }; + }); + + return { + deliveries: items, + pagination: { + next: pagination.nextCursor ?? undefined, + previous: pagination.previousCursor ?? undefined, + }, + filters: { from, to }, + hasFilters: hasExplicitWindow ?? Boolean(from || to), + }; + } +} diff --git a/apps/webapp/app/presenters/v3/webhookComposerEndpoints.server.ts b/apps/webapp/app/presenters/v3/webhookComposerEndpoints.server.ts new file mode 100644 index 0000000000..b406849eff --- /dev/null +++ b/apps/webapp/app/presenters/v3/webhookComposerEndpoints.server.ts @@ -0,0 +1,53 @@ +import { type WebhookHandshakeConfig, WebhookVerifierArtifact } from "@trigger.dev/core/v3"; +import { webhookIngressUrl } from "~/utils/webhookIngressUrl.server"; + +export type WebhookComposerEndpointData = { + friendlyId: string; + label: string; + source: string; + ingressUrl: string; + scheme: "hmac" | "shared-secret" | "url-secret" | "asymmetric"; + hasSigningSecret: boolean; + /** Present only when the endpoint declares a provider handshake (unlocks the handshake test). */ + handshake: WebhookHandshakeConfig | null; +}; + +type EndpointRow = { + friendlyId: string; + opaqueId: string; + source: string; + endpointTenantId: string; + endpointExternalRef: string; + verifierArtifact: unknown; + signingSecretKey: string | null; +}; + +/** + * Map raw WebhookEndpoint rows to the shape the composer consumes (label + scheme + ingress URL + + * handshake). Shared by the /test WEBHOOK arm and the console tab so the two stay in lockstep. + */ +export function buildWebhookComposerEndpoints(rows: EndpointRow[]): WebhookComposerEndpointData[] { + return rows.map((endpoint) => { + const parsed = WebhookVerifierArtifact.safeParse(endpoint.verifierArtifact); + const scheme = + parsed.success && parsed.data.kind !== "bundle" ? parsed.data.config.scheme : "hmac"; + const handshake = + parsed.success && parsed.data.kind !== "bundle" ? (parsed.data.handshake ?? null) : null; + + const isDefault = endpoint.endpointTenantId === "" && endpoint.endpointExternalRef === ""; + + return { + friendlyId: endpoint.friendlyId, + label: isDefault + ? "default" + : endpoint.endpointExternalRef + ? `${endpoint.endpointTenantId}: ${endpoint.endpointExternalRef}` + : endpoint.endpointTenantId, + source: endpoint.source, + ingressUrl: webhookIngressUrl(endpoint.opaqueId), + scheme, + hasSigningSecret: endpoint.signingSecretKey != null && endpoint.signingSecretKey !== "", + handshake, + } satisfies WebhookComposerEndpointData; + }); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx index 7703b54fef..cee7e34f44 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx @@ -17,6 +17,7 @@ import { PlusIcon } from "~/assets/icons/PlusIcon"; import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon"; import { RunsIcon } from "~/assets/icons/RunsIcon"; import { TaskIcon } from "~/assets/icons/TaskIcon"; +import { WebhookIcon } from "~/assets/icons/WebhookIcon"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { CodeBlock } from "~/components/code/CodeBlock"; import { InlineCode } from "~/components/code/InlineCode"; @@ -101,6 +102,7 @@ import { v3StandardTaskPath, v3TasksStreamingPath, v3TestTaskPath, + v3WebhookTaskPath, } from "~/utils/pathBuilder"; import { sectionAgentPageContext } from "~/components/dashboard-agent/suggested-prompts"; import { WhenAgentUnavailable } from "~/components/dashboard-agent/WhenAgentUnavailable"; @@ -178,6 +180,7 @@ const KIND_OPTIONS: { value: UnifiedTaskKind; label: string }[] = [ { value: "AGENT", label: "Agent" }, { value: "STANDARD", label: "Standard" }, { value: "SCHEDULED", label: "Scheduled" }, + { value: "WEBHOOK", label: "Webhook" }, ]; const VALID_KINDS = new Set(KIND_OPTIONS.map((o) => o.value)); @@ -207,6 +210,7 @@ const TASK_TYPE_SEGMENTS: { { value: "AGENT", tooltip: "Agent tasks", source: "AGENT" }, { value: "STANDARD", tooltip: "Standard tasks", source: "STANDARD" }, { value: "SCHEDULED", tooltip: "Scheduled tasks", source: "SCHEDULED" }, + { value: "WEBHOOK", tooltip: "Webhook tasks", source: "WEBHOOK" }, ]; const PAGE_SIZE = 25; @@ -437,12 +441,18 @@ function TaskRow({ ? v3AgentTaskPath(organization, project, environment, item.slug) : item.kind === "SCHEDULED" ? v3ScheduledTaskPath(organization, project, environment, item.slug) - : v3StandardTaskPath(organization, project, environment, item.slug); + : item.kind === "WEBHOOK" + ? v3WebhookTaskPath(organization, project, environment, item.slug) + : v3StandardTaskPath(organization, project, environment, item.slug); + // A webhook task runs from a verified inbound event, not a hand-built + // payload, so it has no Test page. const testPath = - item.kind === "AGENT" - ? v3PlaygroundAgentPath(organization, project, environment, item.slug) - : v3TestTaskPath(organization, project, environment, { taskIdentifier: item.slug }); + item.kind === "WEBHOOK" + ? undefined + : item.kind === "AGENT" + ? v3PlaygroundAgentPath(organization, project, environment, item.slug) + : v3TestTaskPath(organization, project, environment, { taskIdentifier: item.slug }); const runsPath = v3RunsPath(organization, project, environment, { tasks: [item.slug] }); @@ -461,7 +471,13 @@ function TaskRow({
- {item.kind === "AGENT" ? "Agent" : item.kind === "SCHEDULED" ? "Scheduled" : "Standard"} + {item.kind === "AGENT" + ? "Agent" + : item.kind === "SCHEDULED" + ? "Scheduled" + : item.kind === "WEBHOOK" + ? "Webhook" + : "Standard"} {item.kind === "AGENT" && item.agentType && ( {formatAgentType(item.agentType)} @@ -516,23 +532,27 @@ function TaskRow({ title="View runs" leadingIconClassName="-mx-1 text-runs" /> - + {testPath && ( + + )} } hiddenButtons={ - - Test - + testPath ? ( + + Test + + ) : undefined } /> @@ -777,6 +797,16 @@ function TaskTypeBreakdown() { you need.
+
+
+ + Webhook task +
+ + Runs from a verified inbound event sent to a hosted endpoint. Each delivery is logged, and + a successful one starts a run. + +
); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx index c27eb15497..0102343d13 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx @@ -404,9 +404,7 @@ export default function Page() { - - - + - CRON + Cron {firstSchedule ? (
@@ -889,7 +889,7 @@ function ScheduledTaskDetailSidebar({ Next run {firstSchedule ? ( - + ) : ( )} @@ -972,8 +972,9 @@ type ScheduleRow = { type: "DECLARATIVE" | "IMPERATIVE"; cron: string; cronDescription: string; + window?: string; externalId: string | null; - nextRun: Date; + nextRunEffectiveAt: Date; lastRun: Date | undefined; active: boolean; }; @@ -993,7 +994,7 @@ function SchedulesMiniTable({ return ( - + No schedules attached to this task yet. @@ -1010,6 +1011,7 @@ function SchedulesMiniTable({ Schedule ID Type Cron + Window External ID Next run Last run @@ -1036,6 +1038,9 @@ function SchedulesMiniTable({ {schedule.cron} + + {schedule.window} + {schedule.externalId ? ( {schedule.externalId} @@ -1044,7 +1049,7 @@ function SchedulesMiniTable({ )} - + {schedule.lastRun ? ( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx index d48a85f00f..9c1499ec41 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx @@ -28,6 +28,8 @@ export function AIPayloadTabContent({ placeholder, examplePromptsOverride, isAgent = false, + payloadKind, + providerSource, showExamplePromptsHeader = true, }: { onPayloadGenerated: (payload: string) => void; @@ -38,6 +40,8 @@ export function AIPayloadTabContent({ placeholder?: string; examplePromptsOverride?: string[]; isAgent?: boolean; + payloadKind?: "standard" | "agent" | "webhook"; + providerSource?: string; showExamplePromptsHeader?: boolean; }) { const [prompt, setPrompt] = useState(""); @@ -79,6 +83,12 @@ export function AIPayloadTabContent({ formData.append("prompt", queryPrompt); formData.append("taskIdentifier", taskIdentifier); formData.append("isAgent", isAgent ? "true" : "false"); + if (payloadKind) { + formData.append("payloadKind", payloadKind); + } + if (providerSource) { + formData.append("providerSource", providerSource); + } if (payloadSchema) { formData.append("payloadSchema", JSON.stringify(payloadSchema)); } @@ -150,7 +160,15 @@ export function AIPayloadTabContent({ setIsLoading(false); } }, - [resourcePath, taskIdentifier, payloadSchema, getCurrentPayload, isAgent] + [ + resourcePath, + taskIdentifier, + payloadSchema, + getCurrentPayload, + isAgent, + payloadKind, + providerSource, + ] ); const processStreamEvent = useCallback( @@ -200,17 +218,23 @@ export function AIPayloadTabContent({ const examplePrompts = examplePromptsOverride ?? - (payloadSchema + (payloadKind === "webhook" ? [ - "Generate a valid payload", - "Generate a payload with edge cases", - "Generate a minimal payload with only required fields", + "Generate a realistic event body", + "Generate a failure/refund style event", + "Generate an event with nested data", ] - : [ - "Generate a simple JSON payload", - "Generate a payload with nested objects", - "Generate a payload with an array of items", - ]); + : payloadSchema + ? [ + "Generate a valid payload", + "Generate a payload with edge cases", + "Generate a minimal payload with only required fields", + ] + : [ + "Generate a simple JSON payload", + "Generate a payload with nested objects", + "Generate a payload with an array of items", + ]); return (
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx index 00e5116d09..a647468d77 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx @@ -8,7 +8,12 @@ import { } from "@heroicons/react/20/solid"; import { DialogClose, DialogDescription } from "@radix-ui/react-dialog"; import { Form, useActionData, useFetcher, useParams, useSubmit } from "@remix-run/react"; -import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { + type ActionFunction, + type LoaderFunctionArgs, + json, + redirect, +} from "@remix-run/server-runtime"; import { MachinePresetName } from "@trigger.dev/core/v3"; import { AnimatePresence, motion } from "framer-motion"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -132,8 +137,16 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }), ]); + if (result.foundTask && result.triggerSource === "WEBHOOK") { + throw redirect( + `/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/webhooks/${taskParam}?tab=console` + ); + } + return typedjson({ ...result, regions: regionsResult.regions }); } catch (error) { + if (error instanceof Response) throw error; + logger.error("Failed to load test page", { taskParam, error: error instanceof Error ? error.message : error, @@ -303,7 +316,7 @@ export default function Page() { } }, [params.organizationSlug, params.projectParam, params.envParam]); - const defaultTaskQueue = result.queue; + const defaultTaskQueue = "queue" in result ? result.queue : undefined; const queues = useMemo(() => { const customQueues = queueFetcher.data?.queues ?? []; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.$webhookParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.$webhookParam/route.tsx new file mode 100644 index 0000000000..77bc9a3cdc --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.$webhookParam/route.tsx @@ -0,0 +1,874 @@ +import { BookOpenIcon } from "@heroicons/react/24/solid"; +import { + Link, + type MetaFunction, + useNavigation, + useRevalidator, + useSearchParams, +} from "@remix-run/react"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { type ReactNode, Suspense, useMemo, useState } from "react"; +import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { WebhookIcon } from "~/assets/icons/WebhookIcon"; +import { PageBody } from "~/components/layout/AppLayout"; +import { DirectionSchema, ListPagination } from "~/components/ListPagination"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Card } from "~/components/primitives/charts/Card"; +import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound"; +import { CopyableText } from "~/components/primitives/CopyableText"; +import { DateTime } from "~/components/primitives/DateTime"; +import { Header2 } from "~/components/primitives/Headers"; +import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import * as Property from "~/components/primitives/PropertyTable"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "~/components/primitives/Resizable"; +import { PulsingDot } from "~/components/primitives/PulsingDot"; +import { Spinner } from "~/components/primitives/Spinner"; +import { TabButton, TabContainer } from "~/components/primitives/Tabs"; +import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters"; +import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; +import { DeliveriesTable } from "~/components/webhookDeliveries/v1/DeliveriesTable"; +import { DeliveryStatusBadge } from "~/components/webhookDeliveries/v1/DeliveryStatus"; +import { EndpointsTable } from "~/components/webhookEndpoints/v1/EndpointsTable"; +import { WebhookComposer } from "~/components/webhookConsole/WebhookComposer"; +import { AIChatIcon } from "~/assets/icons/AIChatIcon"; +import { RunsIcon } from "~/assets/icons/RunsIcon"; +import { $replica } from "~/db.server"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; +import { + WebhookDetailPresenter, + type WebhookActivity, + type WebhookDeliveriesList, + type WebhookDetail, +} from "~/presenters/v3/WebhookDetailPresenter.server"; +import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; +import { requireUser } from "~/services/session.server"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; +import { flag } from "~/v3/featureFlags.server"; +import { + docsPath, + EnvironmentParamSchema, + v3EnvironmentPath, + v3WebhookDeliveryPath, +} from "~/utils/pathBuilder"; +import { parseFiniteInt } from "~/utils/searchParams"; +import { useDeliveriesLiveReload } from "~/components/webhookDeliveries/v1/useDeliveriesLiveReload"; + +export const meta: MetaFunction = ({ data }) => { + const slug = (data as { webhook?: WebhookDetail | null } | undefined)?.webhook?.slug; + return [{ title: slug ? `${slug} | Webhooks | Trigger.dev` : "Webhook | Trigger.dev" }]; +}; + +const WebhookParamSchema = EnvironmentParamSchema.extend({ + webhookParam: z.string(), +}); + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const user = await requireUser(request); + const userId = user.id; + const { organizationSlug, projectParam, envParam, webhookParam } = + WebhookParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Project not found", { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Environment not found", { status: 404 }); + } + + if (!user.admin && !user.isImpersonating) { + const org = await $replica.organization.findFirst({ + where: { id: project.organizationId }, + select: { featureFlags: true }, + }); + const enabled = await flag({ + key: FEATURE_FLAG.hasWebhooksAccess, + defaultValue: false, + overrides: (org?.featureFlags as Record) ?? {}, + }); + if (!enabled) throw new Response("Not found", { status: 404 }); + } + + const url = new URL(request.url); + const periodParam = url.searchParams.get("period") ?? undefined; + const from = parseFiniteInt(url.searchParams.get("from")); + const to = parseFiniteInt(url.searchParams.get("to")); + const hasExplicitWindow = Boolean(periodParam || from || to); + const period = periodParam ?? (hasExplicitWindow ? undefined : "7d"); + const deliveriesCursor = url.searchParams.get("deliveriesCursor") ?? undefined; + const deliveriesDirectionRaw = url.searchParams.get("deliveriesDirection") ?? undefined; + const deliveriesDirection = deliveriesDirectionRaw + ? DirectionSchema.parse(deliveriesDirectionRaw) + : undefined; + const runsCursor = url.searchParams.get("runsCursor") ?? undefined; + const runsDirectionRaw = url.searchParams.get("runsDirection") ?? undefined; + const runsDirection = runsDirectionRaw ? DirectionSchema.parse(runsDirectionRaw) : undefined; + + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + project.organizationId, + "standard" + ); + + const presenter = new WebhookDetailPresenter($replica, clickhouse); + const webhook = await presenter.findWebhook({ + environmentId: environment.id, + environmentType: environment.type, + webhookSlug: webhookParam, + }); + + if (!webhook) { + throw new Response("Webhook not found", { status: 404 }); + } + + const time = timeFilterFromTo({ period, from, to, defaultPeriod: "7d" }); + + const runActivity = presenter + .getRunActivity({ + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + webhookSlug: webhook.slug, + from: time.from, + to: time.to, + }) + .catch(() => ({ data: [], statuses: [] }) satisfies WebhookActivity); + + const deliveryActivity = presenter + .getDeliveryActivity({ + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + webhookEndpointId: webhook.endpoint.id, + from: time.from, + to: time.to, + }) + .catch(() => ({ data: [], statuses: [] }) satisfies WebhookActivity); + + const runList = new NextRunListPresenter($replica, clickhouse) + .call(project.organizationId, environment.id, { + userId, + projectId: project.id, + tasks: [webhook.slug], + period, + from, + to, + cursor: runsCursor, + direction: runsDirection, + }) + .catch(() => null); + + const deliveriesList = presenter + .listDeliveries({ + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + webhookEndpointId: webhook.endpoint.id, + period, + from, + to, + hasExplicitWindow, + cursor: deliveriesCursor, + direction: deliveriesDirection, + }) + .catch(() => null); + + const endpointsList = presenter + .listEndpoints({ + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + handlerWebhookId: webhook.slug, + }) + .catch(() => [] as Awaited>); + + const composerEndpoints = presenter + .listComposerEndpoints({ environmentId: environment.id, handlerWebhookId: webhook.slug }) + .catch(() => [] as Awaited>); + + return typeddefer({ + webhook, + runActivity, + deliveryActivity, + runList, + deliveriesList, + endpointsList, + composerEndpoints, + }); +}; + +type WebhookTab = "runs" | "deliveries" | "endpoints" | "console"; + +export default function Page() { + const { + webhook, + runActivity, + deliveryActivity, + runList, + deliveriesList, + endpointsList, + composerEndpoints, + } = useTypedLoaderData(); + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + const tasksPath = v3EnvironmentPath(organization, project, environment); + + const [searchParams] = useSearchParams(); + const [tab, setTab] = useState(() => { + const requested = searchParams.get("tab"); + return requested === "console" || requested === "runs" || requested === "endpoints" + ? requested + : "deliveries"; + }); + const tabLabel = + tab === "deliveries" + ? "Deliveries" + : tab === "runs" + ? "Runs" + : tab === "console" + ? "Console" + : "Endpoints"; + + return ( + <> + + + + {webhook.slug} + + } + /> + + + Webhooks docs + + + + + + +
+ {/* Top bar: tabs on the left, TimeFilter + pagination on the right. + h-10 matches the right-hand sidebar header height. */} +
+ + setTab("deliveries")} + > + Deliveries + + setTab("runs")} + > + Runs + + setTab("endpoints")} + > + Endpoints + + setTab("console")} + > + Console + + + {tab !== "endpoints" && tab !== "console" && ( +
+ + {tab === "deliveries" ? ( + + + {(list) => + list ? ( + + ) : null + } + + + ) : ( + + + {(list) => + list ? ( + + ) : null + } + + + )} +
+ )} +
+ + {tab === "endpoints" ? ( + // Endpoints aren't a time series, so no activity chart or time filter. +
+ }> + }> + {(endpoints) => ( + + )} + + +
+ ) : tab === "console" ? ( +
+ }> + }> + {(endpoints) => + endpoints.length === 0 ? ( +
+ This webhook has no synced endpoints to send to yet. +
+ ) : ( + + ) + } +
+
+
+ ) : ( + + {/* Activity chart (one status-bucket chart per tab). */} + +
+
+ + {tab === "deliveries" ? ( + }> + } + > + {(result) => } + + + ) : ( + }> + } + > + {(result) => } + + + )} + +
+
+
+ + + + {/* Table */} + + + +
+ )} +
+
+ + + + {tab === "console" ? ( + + ) : ( + setTab("endpoints")} /> + )} + +
+
+ + ); +} + +type LoaderData = ReturnType>; + +function WebhookContentArea({ + tab, + deliveriesList, + runList, + webhookEndpointId, +}: { + tab: WebhookTab; + webhookEndpointId: string; +} & Pick) { + return ( +
+ {tab === "deliveries" ? ( + }> + }> + {(list) => + list ? ( +
+ +
+ ) : ( + + ) + } +
+
+ ) : ( + }> + }> + {(list) => + list ? ( +
+ +
+ ) : ( + + ) + } +
+
+ )} +
+ ); +} + +function LiveDeliveriesTable({ + list, + webhookEndpointId, +}: { + list: WebhookDeliveriesList; + webhookEndpointId: string; +}) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const navigation = useNavigation(); + const revalidator = useRevalidator(); + const [searchParams, setSearchParams] = useSearchParams(); + + const { visibleDeliveries, showNewDeliveriesBanner, newDeliveriesCount, dismissNewDeliveries } = + useDeliveriesLiveReload({ + deliveries: list.deliveries, + isLoading: navigation.state !== "idle", + webhookEndpointId, + organizationSlug: organization.slug, + projectSlug: project.slug, + environmentSlug: environment.slug, + }); + + const onClickShowNewDeliveries = () => { + dismissNewDeliveries(); + if (searchParams.has("deliveriesCursor") || searchParams.has("deliveriesDirection")) { + setSearchParams((prev) => { + prev.delete("deliveriesCursor"); + prev.delete("deliveriesDirection"); + return prev; + }); + return; + } + revalidator.revalidate(); + }; + + return ( + <> + {showNewDeliveriesBanner ? ( +
+ + + +
+ ) : null} + + + ); +} + +function ConsoleLiveFeed({ + deliveriesList, + webhookEndpointId, +}: { + webhookEndpointId: string; +} & Pick) { + return ( +
+
+ Live deliveries + + + Live + +
+
+ }> + }> + {(list) => + list ? ( + + ) : ( + + ) + } + + +
+
+ ); +} + +function ConsoleFeedList({ + list, + webhookEndpointId, +}: { + list: WebhookDeliveriesList; + webhookEndpointId: string; +}) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const navigation = useNavigation(); + const revalidator = useRevalidator(); + + const { visibleDeliveries, showNewDeliveriesBanner, newDeliveriesCount, dismissNewDeliveries } = + useDeliveriesLiveReload({ + deliveries: list.deliveries, + isLoading: navigation.state !== "idle", + webhookEndpointId, + organizationSlug: organization.slug, + projectSlug: project.slug, + environmentSlug: environment.slug, + }); + + const onShowNew = () => { + dismissNewDeliveries(); + revalidator.revalidate(); + }; + + if (visibleDeliveries.length === 0) { + return ( +

+ No deliveries yet. Send an event to watch it arrive here. +

+ ); + } + + return ( +
+ {showNewDeliveriesBanner ? ( + + ) : null} + {visibleDeliveries.map((delivery) => ( + +
+ + {delivery.friendlyId} + {delivery.isTest ? ( + + Test + + ) : null} + + +
+ {delivery.session ? ( + + + {delivery.session.friendlyId} + + ) : delivery.run ? ( + + + {delivery.run.friendlyId} + + ) : null} + + ))} +
+ ); +} + +function WebhookDetailSidebar({ + webhook, + onViewEndpoints, +}: { + webhook: WebhookDetail; + onViewEndpoints: () => void; +}) { + return ( +
+
+ + + {webhook.slug} + +
+
+ + + Source + + {webhook.source} + + + + Endpoints + + {/* The connect flow (ingress URL, secret, provider setup) lives on each + endpoint, so this handler view points there instead of holding it. */} + + + + + File path + + + + + + Created + + + + + +
+
+ ); +} + +const STATUS_COLOR: Record = { + // Run statuses + COMPLETED: "#28BF5C", + RUNNING: "#3B82F6", + FAILED: "#E11D48", + CANCELED: "#878C99", + // Delivery statuses + SUCCEEDED: "#28BF5C", + PROCESSING: "#3B82F6", + PENDING: "#878C99", +}; + +function ActivityChart({ activity }: { activity: WebhookActivity }) { + const chartConfig: ChartConfig = useMemo(() => { + const cfg: ChartConfig = {}; + for (const status of activity.statuses) { + cfg[status] = { + label: status.charAt(0) + status.slice(1).toLowerCase(), + color: STATUS_COLOR[status] ?? "#9CA3AF", + }; + } + return cfg; + }, [activity.statuses]); + + const { xAxisFormatter, xAxisTicks, tooltipLabelFormatter } = useMemo( + () => buildTimeAxis(activity.data), + [activity.data] + ); + + return ( + + + + ); +} + +function ActivityChartSkeleton() { + return ( +
+ {Array.from({ length: 42 }).map((_, i) => ( +
+ ))} +
+ ); +} + +function TableLoading() { + return ( +
+ +
+ ); +} + +function ChartCard({ title, children }: { title: string; children: ReactNode }) { + return ( + + {title} +
{children}
+
+ ); +} + +function buildTimeAxis(data: WebhookActivity["data"]) { + const range = data.length >= 2 ? data[data.length - 1].bucket - data[0].bucket : 0; + const oneDay = 24 * 60 * 60 * 1000; + const showTime = range <= oneDay; + + const xAxisFormatter = (value: number) => { + const date = new Date(value); + return showTime + ? date.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "UTC", + }) + : date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + timeZone: "UTC", + }); + }; + + const xAxisTicks = showTime + ? undefined + : data.filter((d) => new Date(d.bucket).getUTCHours() === 0).map((d) => d.bucket); + + const bucketMs = data.length >= 2 ? data[1].bucket - data[0].bucket : 0; + const isSubDayBucket = bucketMs > 0 && bucketMs < oneDay; + + const tooltipLabelFormatter = (_label: string, payload: { payload?: { bucket?: number } }[]) => { + const ts = payload?.[0]?.payload?.bucket; + if (typeof ts !== "number" || !Number.isFinite(ts)) return _label; + const date = new Date(ts); + return isSubDayBucket + ? date.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "UTC", + }) + : date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + timeZone: "UTC", + }); + }; + + return { xAxisFormatter, xAxisTicks, tooltipLabelFormatter }; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks._index/route.tsx new file mode 100644 index 0000000000..91bdb1de0e --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks._index/route.tsx @@ -0,0 +1,234 @@ +import { + type MetaFunction, + useNavigation, + useRevalidator, + useSearchParams, +} from "@remix-run/react"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import type { WebhookDeliveryStatus } from "@trigger.dev/database"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { PageBody } from "~/components/layout/AppLayout"; +import { DirectionSchema, ListPagination } from "~/components/ListPagination"; +import { Button } from "~/components/primitives/Buttons"; +import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; +import { PulsingDot } from "~/components/primitives/PulsingDot"; +import { DeliveriesTable } from "~/components/webhookDeliveries/v1/DeliveriesTable"; +import { useDeliveriesLiveReload } from "~/components/webhookDeliveries/v1/useDeliveriesLiveReload"; +import { + type PossibleWebhook, + WebhookDeliveryFilters, +} from "~/components/webhookDeliveries/v1/WebhookDeliveryFilters"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { type WebhookDeliveryListItem } from "~/presenters/v3/WebhookDetailPresenter.server"; +import { $replica, webhookReplica } from "~/db.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { WebhookDeliveriesListPresenter } from "~/presenters/v3/WebhookDeliveriesListPresenter.server"; +import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; +import { requireUser } from "~/services/session.server"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { parseFiniteInt } from "~/utils/searchParams"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; +import { flag } from "~/v3/featureFlags.server"; + +const VALID_DELIVERY_STATUSES = new Set([ + "PENDING", + "PROCESSING", + "SUCCEEDED", + "FAILED", + "FILTERED", +]); + +// Accepts repeated `statuses` params or a single CSV value; drops anything that +// isn't one of the four WebhookDeliveryStatus values. +function parseStatuses(searchParams: URLSearchParams): WebhookDeliveryStatus[] | undefined { + const raw = searchParams + .getAll("statuses") + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter((value) => VALID_DELIVERY_STATUSES.has(value)) as WebhookDeliveryStatus[]; + + return raw.length > 0 ? Array.from(new Set(raw)) : undefined; +} + +function parseRepeated(searchParams: URLSearchParams, key: string): string[] | undefined { + const values = searchParams.getAll(key).filter((value) => value.length > 0); + return values.length > 0 ? values : undefined; +} + +export const meta: MetaFunction = () => [{ title: "Deliveries | Webhooks | Trigger.dev" }]; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const user = await requireUser(request); + const userId = user.id; + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) throw new Response("Project not found", { status: 404 }); + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) throw new Response("Environment not found", { status: 404 }); + + // Feature gate: enabled by a global FeatureFlag OR a per-org override; admins/impersonators + // always pass. flag() resolves org override -> global -> default(false). + if (!user.admin && !user.isImpersonating) { + const org = await $replica.organization.findFirst({ + where: { id: project.organizationId }, + select: { featureFlags: true }, + }); + const enabled = await flag({ + key: FEATURE_FLAG.hasWebhooksAccess, + defaultValue: false, + overrides: (org?.featureFlags as Record) ?? {}, + }); + if (!enabled) throw new Response("Not found", { status: 404 }); + } + + const url = new URL(request.url); + const periodParam = url.searchParams.get("period") ?? undefined; + const from = parseFiniteInt(url.searchParams.get("from")); + const to = parseFiniteInt(url.searchParams.get("to")); + const cursor = url.searchParams.get("cursor") ?? undefined; + const directionRaw = url.searchParams.get("direction") ?? undefined; + const direction = directionRaw ? DirectionSchema.parse(directionRaw) : undefined; + + const statuses = parseStatuses(url.searchParams); + const webhooks = parseRepeated(url.searchParams, "webhooks"); + const deliveryId = url.searchParams.get("deliveryId") ?? undefined; + const runId = url.searchParams.get("runId") ?? undefined; + const testParam = url.searchParams.get("test"); + const isTest = testParam === "only" ? true : testParam === "hide" ? false : undefined; + + // Default to the last 7 days when no explicit window is set, matching the TimeFilter default. + const hasExplicitWindow = Boolean(periodParam || from || to); + const period = periodParam ?? (hasExplicitWindow ? undefined : "7d"); + + const hasFilters = Boolean( + statuses || webhooks || deliveryId || runId || testParam || hasExplicitWindow + ); + + // Distinct handler slugs (one row per handlerWebhookId) for the Webhook picker. + const endpoints = await webhookReplica.webhookEndpoint.findMany({ + where: { runtimeEnvironmentId: environment.id }, + select: { handlerWebhookId: true, source: true }, + distinct: ["handlerWebhookId"], + }); + const possibleWebhooks: PossibleWebhook[] = endpoints.map((e) => ({ + slug: e.handlerWebhookId, + source: e.source, + })); + + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + project.organizationId, + "standard" + ); + + const presenter = new WebhookDeliveriesListPresenter($replica, clickhouse); + const list = await presenter + .call({ + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + webhooks, + statuses, + deliveryId, + runId, + isTest, + period, + from, + to, + cursor, + direction, + }) + .catch(() => ({ deliveries: [], pagination: {} })); + + return typedjson({ + deliveries: list.deliveries, + pagination: list.pagination, + possibleWebhooks, + hasFilters, + }); +}; + +export default function Page() { + const { deliveries, pagination, possibleWebhooks, hasFilters } = + useTypedLoaderData(); + + const { visibleDeliveries, newDeliveriesButton } = useLiveDeliveries(deliveries); + + return ( + <> + + + + +
+
+ + {/* The new-deliveries button sits inline, immediately left of the pager */} +
+ {newDeliveriesButton} + +
+
+ {/* Sits directly in the 1fr row, like the runs, sessions and batches lists. No + stickyHeader: that switches Table's container to overflow-visible, which stops it + being the scroll container. The header is sticky either way (TableHeader always sets + sticky top-0), and the other webhook tables only pass it because an ancestor scrolls. */} + +
+
+ + ); +} + +function useLiveDeliveries(deliveries: WebhookDeliveryListItem[]) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const navigation = useNavigation(); + const revalidator = useRevalidator(); + const [searchParams, setSearchParams] = useSearchParams(); + + const { visibleDeliveries, showNewDeliveriesBanner, newDeliveriesCount, dismissNewDeliveries } = + useDeliveriesLiveReload({ + deliveries, + isLoading: navigation.state !== "idle", + organizationSlug: organization.slug, + projectSlug: project.slug, + environmentSlug: environment.slug, + }); + + const onClickShowNewDeliveries = () => { + dismissNewDeliveries(); + if (searchParams.has("cursor") || searchParams.has("direction")) { + setSearchParams((prev) => { + prev.delete("cursor"); + prev.delete("direction"); + return prev; + }); + return; + } + revalidator.revalidate(); + }; + + const newDeliveriesButton = showNewDeliveriesBanner ? ( + + + + ) : null; + + return { visibleDeliveries, newDeliveriesButton }; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.deliveries.$deliveryParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.deliveries.$deliveryParam/route.tsx new file mode 100644 index 0000000000..28cbce92f7 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.deliveries.$deliveryParam/route.tsx @@ -0,0 +1,451 @@ +import { BookOpenIcon } from "@heroicons/react/24/solid"; +import { type MetaFunction, useRevalidator } from "@remix-run/react"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { type ReactNode, useState } from "react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { AIChatIcon } from "~/assets/icons/AIChatIcon"; +import { RunsIcon } from "~/assets/icons/RunsIcon"; +import { WebhookIcon } from "~/assets/icons/WebhookIcon"; +import { CodeBlock } from "~/components/code/CodeBlock"; +import { PageBody } from "~/components/layout/AppLayout"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; +import { CopyableText } from "~/components/primitives/CopyableText"; +import { TextLink } from "~/components/primitives/TextLink"; +import { DateTime } from "~/components/primitives/DateTime"; +import { Header2 } from "~/components/primitives/Headers"; +import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import * as Property from "~/components/primitives/PropertyTable"; +import { TabButton, TabContainer } from "~/components/primitives/Tabs"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "~/components/primitives/Resizable"; +import { DeliveryStatusBadge } from "~/components/webhookDeliveries/v1/DeliveryStatus"; +import { DeliveryTimeline } from "~/components/webhookDeliveries/v1/DeliveryTimeline"; +import { $replica } from "~/db.server"; +import { env } from "~/env.server"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useInterval } from "~/hooks/useInterval"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { + WebhookDeliveryDetailPresenter, + type WebhookDeliveryDetail, +} from "~/presenters/v3/WebhookDeliveryDetailPresenter.server"; +import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; +import { requireUser } from "~/services/session.server"; +import { formatBytes } from "~/utils/columnFormat"; +import { + docsPath, + EnvironmentParamSchema, + v3RunPath, + v3SessionPath, + v3WebhooksPath, + v3WebhookTaskPath, +} from "~/utils/pathBuilder"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; +import { flag } from "~/v3/featureFlags.server"; + +const DeliveryParamSchema = EnvironmentParamSchema.extend({ + deliveryParam: z.string(), +}); + +const EVENT_DISPLAY_CAP = 128 * 1024; + +export const meta: MetaFunction = ({ data }) => { + const friendlyId = (data as { delivery?: WebhookDeliveryDetail } | undefined)?.delivery + ?.friendlyId; + return [ + { title: friendlyId ? `${friendlyId} | Deliveries | Trigger.dev` : "Delivery | Trigger.dev" }, + ]; +}; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const user = await requireUser(request); + const userId = user.id; + const { organizationSlug, projectParam, envParam, deliveryParam } = + DeliveryParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) throw new Response("Project not found", { status: 404 }); + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) throw new Response("Environment not found", { status: 404 }); + + // Feature gate: same as the deliveries list. Global FeatureFlag OR per-org override; + // admins/impersonators always pass. + if (!user.admin && !user.isImpersonating) { + const org = await $replica.organization.findFirst({ + where: { id: project.organizationId }, + select: { featureFlags: true }, + }); + const enabled = await flag({ + key: FEATURE_FLAG.hasWebhooksAccess, + defaultValue: false, + overrides: (org?.featureFlags as Record) ?? {}, + }); + if (!enabled) throw new Response("Not found", { status: 404 }); + } + + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + project.organizationId, + "standard" + ); + + const presenter = new WebhookDeliveryDetailPresenter($replica, clickhouse); + const delivery = await presenter.call({ + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + deliveryFriendlyId: deliveryParam, + }); + + const rawEventJson = + delivery?.parsedEvent != null ? JSON.stringify(delivery.parsedEvent, null, 2) : null; + const eventBytes = rawEventJson != null ? Buffer.byteLength(rawEventJson, "utf8") : 0; + const eventTruncatedForDisplay = rawEventJson != null && rawEventJson.length > EVENT_DISPLAY_CAP; + const eventJson = eventTruncatedForDisplay + ? rawEventJson!.slice(0, EVENT_DISPLAY_CAP) + : rawEventJson; + + // A missing delivery is most often one that aged out of retention (a bookmarked or shared link), + // so render a friendly retention-aware state rather than a hard 404. + return typedjson({ + delivery: delivery ? { ...delivery, parsedEvent: null } : delivery, + eventJson, + eventBytes, + eventTruncatedForDisplay, + retentionDays: env.WEBHOOK_PARTITION_RETENTION_DAYS, + }); +}; + +/** Centred placeholder for a tab whose content was never captured. */ +function EmptyTabMessage({ children }: { children: ReactNode }) { + return ( +
+ + {children} + +
+ ); +} + +function formatDuration(createdAt: Date, processedAt: Date | null): string | null { + if (!processedAt) return null; + const ms = processedAt.getTime() - createdAt.getTime(); + if (ms < 0) return null; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(2)}s`; +} + +export default function Page() { + const { delivery, eventJson, eventBytes, eventTruncatedForDisplay, retentionDays } = + useTypedLoaderData(); + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const revalidator = useRevalidator(); + + const inFlight = delivery?.status === "PENDING" || delivery?.status === "PROCESSING"; + useInterval({ + interval: 3000, + pauseWhenHidden: true, + disabled: !inFlight, + callback: () => revalidator.revalidate(), + }); + + const deliveriesPath = v3WebhooksPath(organization, project, environment); + const [tab, setTab] = useState<"event" | "headers">("event"); + + if (!delivery) { + return ( + <> + + + + +
+ + Delivery not available + + This delivery couldn't be found. Deliveries are retained for {retentionDays} days, so + it may have aged out. + + + Back to deliveries + +
+
+ + ); + } + + const runPath = delivery.run + ? v3RunPath(organization, project, environment, { friendlyId: delivery.run.friendlyId }) + : undefined; + const sessionPath = delivery.session + ? v3SessionPath(organization, project, environment, { friendlyId: delivery.session.friendlyId }) + : undefined; + const webhookPath = delivery.webhook + ? v3WebhookTaskPath(organization, project, environment, delivery.webhook.slug) + : undefined; + + const headersJson = + delivery.headers != null && Object.keys(delivery.headers as object).length > 0 + ? JSON.stringify(delivery.headers, null, 2) + : null; + const duration = formatDuration(delivery.createdAt, delivery.processedAt); + + return ( + <> + + + + {delivery.friendlyId} + + } + /> + + + Webhooks docs + + + + + + +
+
+ + setTab("event")} + > + Event payload + + setTab("headers")} + > + Request headers + + +
+
+ {tab === "event" ? ( + eventJson ? ( +
+ {eventTruncatedForDisplay ? ( + + This event is {formatBytes(eventBytes)}. Showing the first{" "} + {formatBytes(EVENT_DISPLAY_CAP)} for display. The full payload was + delivered to your task. + + ) : null} + +
+ ) : ( + + No event payload was captured for this delivery. + + ) + ) : headersJson ? ( + + ) : ( + + No request headers were captured for this delivery. + + )} +
+
+
+ + + +
+
+ Delivery +
+
+ + + + ID + + + + + + Status + + + + + {delivery.filterReason ? ( + + Filter reason + + {delivery.filterReason} + + + ) : null} + + Webhook + + {delivery.webhook ? ( + webhookPath ? ( + + + {delivery.webhook.slug} + + ) : ( + + + {delivery.webhook.slug} + + ) + ) : ( + Unknown + )} + + + {delivery.session && sessionPath ? ( + + Session + + + + {delivery.session.friendlyId} + + + + ) : null} + + Run + + {delivery.run && runPath ? ( + + + {delivery.run.friendlyId} + + ) : ( + None + )} + + + + External delivery ID + + {delivery.externalDeliveryId ? ( + + ) : ( + None + )} + + + + Idempotency key + + {delivery.idempotencyKey ? ( + + ) : ( + None + )} + + + + Raw body hash + + {delivery.rawBodyHash ? ( + + ) : ( + None + )} + + + + Created + + + + + + Processed + + {delivery.processedAt ? ( + + ) : ( + None + )} + + + {duration ? ( + + Duration + {duration} + + ) : null} + {delivery.status === "FAILED" && delivery.errorMessage ? ( + + Error + + {delivery.errorMessage} + + + ) : null} + +
+
+
+
+
+ + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.endpoints.$endpointParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.endpoints.$endpointParam/route.tsx new file mode 100644 index 0000000000..45d973884c --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.endpoints.$endpointParam/route.tsx @@ -0,0 +1,740 @@ +import { BookOpenIcon, KeyIcon, SparklesIcon } from "@heroicons/react/24/solid"; +import { useFetcher, type MetaFunction } from "@remix-run/react"; +import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { randomBytes } from "node:crypto"; +import { WebhookRoutingTarget, WebhookVerifierArtifact } from "@trigger.dev/core/v3"; +import type { WebhookValueSource } from "@trigger.dev/core/v3"; +import { Suspense, useEffect, useState } from "react"; +import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { WebhookIcon } from "~/assets/icons/WebhookIcon"; +import { CodeBlock } from "~/components/code/CodeBlock"; +import { PageBody } from "~/components/layout/AppLayout"; +import { DirectionSchema, ListPagination } from "~/components/ListPagination"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { ClipboardField } from "~/components/primitives/ClipboardField"; +import { CopyableText } from "~/components/primitives/CopyableText"; +import { DateTime } from "~/components/primitives/DateTime"; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; +import { Header2, Header3 } from "~/components/primitives/Headers"; +import { Hint } from "~/components/primitives/Hint"; +import { Input } from "~/components/primitives/Input"; +import { Label } from "~/components/primitives/Label"; +import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import * as Property from "~/components/primitives/PropertyTable"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "~/components/primitives/Resizable"; +import { Spinner } from "~/components/primitives/Spinner"; +import { TextLink } from "~/components/primitives/TextLink"; +import { TimeFilter } from "~/components/runs/v3/SharedFilters"; +import { DeliveriesTable } from "~/components/webhookDeliveries/v1/DeliveriesTable"; +import { EndpointStatusBadge } from "~/components/webhookEndpoints/v1/EndpointStatus"; +import { $replica, prisma, webhookPrisma } from "~/db.server"; +import { webhookIngressUrl } from "~/utils/webhookIngressUrl.server"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { + WebhookDetailPresenter, + type WebhookEndpointDetail, +} from "~/presenters/v3/WebhookDetailPresenter.server"; +import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; +import { getSecretStore } from "~/services/secrets/secretStore.server"; +import { requireUser } from "~/services/session.server"; +import { docsPath, EnvironmentParamSchema, v3WebhookTaskPath } from "~/utils/pathBuilder"; +import { parseFiniteInt } from "~/utils/searchParams"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; +import { flag } from "~/v3/featureFlags.server"; + +const EndpointParamSchema = EnvironmentParamSchema.extend({ + endpointParam: z.string(), +}); + +export const meta: MetaFunction = ({ data }) => { + const friendlyId = (data as { endpoint?: WebhookEndpointDetail } | undefined)?.endpoint + ?.friendlyId; + return [ + { title: friendlyId ? `${friendlyId} | Endpoints | Trigger.dev` : "Endpoint | Trigger.dev" }, + ]; +}; + +// Shared gate + scope resolution for the loader and action. +async function requireWebhookAccess(request: Request, params: LoaderFunctionArgs["params"]) { + const user = await requireUser(request); + const { organizationSlug, projectParam, envParam, endpointParam } = + EndpointParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, user.id); + if (!project) throw new Response("Project not found", { status: 404 }); + const environment = await findEnvironmentBySlug(project.id, envParam, user.id); + if (!environment) throw new Response("Environment not found", { status: 404 }); + + if (!user.admin && !user.isImpersonating) { + const org = await $replica.organization.findFirst({ + where: { id: project.organizationId }, + select: { featureFlags: true }, + }); + const enabled = await flag({ + key: FEATURE_FLAG.hasWebhooksAccess, + defaultValue: false, + overrides: (org?.featureFlags as Record) ?? {}, + }); + if (!enabled) throw new Response("Not found", { status: 404 }); + } + + return { user, project, environment, endpointParam }; +} + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const { project, environment, endpointParam } = await requireWebhookAccess(request, params); + + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + project.organizationId, + "standard" + ); + const presenter = new WebhookDetailPresenter($replica, clickhouse); + + const endpoint = await presenter.findEndpoint({ + environmentId: environment.id, + endpointFriendlyId: endpointParam, + }); + if (!endpoint) throw new Response("Endpoint not found", { status: 404 }); + + const ingestUrl = webhookIngressUrl(endpoint.opaqueId); + + // Parse the tagged-union JSON columns for display (engine validates on write). + const routing = WebhookRoutingTarget.safeParse(endpoint.routingTarget); + const verifier = WebhookVerifierArtifact.safeParse(endpoint.verifierArtifact); + + const url = new URL(request.url); + const periodParam = url.searchParams.get("period") ?? undefined; + const from = parseFiniteInt(url.searchParams.get("from")); + const to = parseFiniteInt(url.searchParams.get("to")); + const hasExplicitWindow = Boolean(periodParam || from || to); + const period = periodParam ?? (hasExplicitWindow ? undefined : "7d"); + const cursor = url.searchParams.get("cursor") ?? undefined; + const directionRaw = url.searchParams.get("direction") ?? undefined; + const direction = directionRaw ? DirectionSchema.parse(directionRaw) : undefined; + + const deliveriesList = presenter + .listDeliveries({ + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + webhookEndpointId: endpoint.id, + period, + from, + to, + hasExplicitWindow, + cursor, + direction, + }) + .catch(() => null); + + return typeddefer({ + endpoint, + ingestUrl, + routing: routing.success ? routing.data : null, + verifier: verifier.success ? verifier.data : null, + deliveriesList, + }); +}; + +const SetSecretSchema = z.object({ + intent: z.literal("set-secret"), + secret: z.string().trim().min(1, "A signing secret is required"), +}); + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const { project, environment, endpointParam } = await requireWebhookAccess(request, params); + + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + project.organizationId, + "standard" + ); + const presenter = new WebhookDetailPresenter($replica, clickhouse); + const endpoint = await presenter.findEndpoint({ + environmentId: environment.id, + endpointFriendlyId: endpointParam, + }); + if (!endpoint) throw new Response("Endpoint not found", { status: 404 }); + + // Store the plaintext secret encrypted under the DATABASE SecretStore provider, in the exact + // shape the engine's resolveSigningSecret reads ({ secret }). Key is namespaced by the endpoint's + // internal id; point signingSecretKey at it so verification picks it up. + const secretKey = `webhook:signing-secret:${endpoint.id}`; + const secretStore = getSecretStore("DATABASE", { prismaClient: prisma }); + + const formData = await request.formData(); + const intent = formData.get("intent"); + + // Generate (integrator-supplied secret): mint a strong secret, store it, and return it so the + // UI can reveal it ONCE for the integrator to paste into their provider. + if (intent === "generate-secret") { + const verifier = WebhookVerifierArtifact.safeParse(endpoint.verifierArtifact); + if ( + verifier.success && + "config" in verifier.data && + verifier.data.config.scheme === "asymmetric" + ) { + return { + success: false as const, + error: "Cannot generate a secret for an asymmetric endpoint; set its public key instead.", + }; + } + const secret = `whsec_${randomBytes(32).toString("hex")}`; + await secretStore.setSecret(secretKey, { secret }); + await webhookPrisma.webhookEndpoint.update({ + where: { id: endpoint.id }, + data: { signingSecretKey: secretKey }, + }); + return { success: true as const, generatedSecret: secret }; + } + + // Set/Rotate (paste a provider-supplied secret). + const submission = SetSecretSchema.safeParse(Object.fromEntries(formData)); + if (!submission.success) { + return { success: false as const, error: submission.error.issues[0]?.message ?? "Invalid" }; + } + await secretStore.setSecret(secretKey, { secret: submission.data.secret }); + await webhookPrisma.webhookEndpoint.update({ + where: { id: endpoint.id }, + data: { signingSecretKey: secretKey }, + }); + + return { success: true as const }; +}; + +export default function Page() { + const { endpoint, ingestUrl, routing, verifier, deliveriesList } = + useTypedLoaderData(); + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + const handlerPath = v3WebhookTaskPath( + organization, + project, + environment, + endpoint.handlerWebhookId + ); + + return ( + <> + + + + {endpoint.friendlyId} + + + } + /> + + + Webhooks docs + + + + + + +
+
+ Deliveries +
+ + + + {(list) => (list ? : null)} + + +
+
+
+ }> + }> + {(list) => + list ? ( + + ) : ( + + ) + } + + +
+
+
+ + + + + +
+
+ + ); +} + +type LoaderData = ReturnType>; + +function EndpointSidebar({ + endpoint, + ingestUrl, + routing, + verifier, + handlerPath, +}: { + endpoint: WebhookEndpointDetail; + ingestUrl: string; + routing: LoaderData["routing"]; + verifier: LoaderData["verifier"]; + handlerPath: string; +}) { + const metadataJson = + endpoint.metadata != null && Object.keys(endpoint.metadata as object).length > 0 + ? JSON.stringify(endpoint.metadata, null, 2) + : null; + + // Asymmetric endpoints store the provider's PUBLIC KEY, not a shared signing secret. + const scheme = verifier && verifier.kind !== "bundle" ? verifier.config.scheme : undefined; + const credentialNoun = scheme === "asymmetric" ? "public key" : "signing secret"; + const credentialLabel = scheme === "asymmetric" ? "Public key" : "Signing secret"; + + // Generate-and-reveal makes sense when the integrator chooses the secret (and it's an HMAC + // shared secret, not a provider public key). "provider" endpoints only paste. + const canGenerate = + scheme !== "asymmetric" && + (endpoint.secretProvisioning === "integrator" || endpoint.secretProvisioning === "either"); + + return ( +
+
+ + + {endpoint.friendlyId} + +
+
+ {/* Connect: the important new bit. Everything an integrator needs to point a provider here. */} +
+ Connect + + + Webhook URL + + + + + + {credentialLabel} + +
+ {endpoint.hasSigningSecret ? ( + + + Set + + ) : ( + Not set, all deliveries are rejected + )} +
+ {canGenerate ? ( + + ) : null} + +
+
+
+
+
+ +
+ +
+ Routing + + + Target + + {routing?.type === "task" ? ( + + {routing.taskId} + + ) : routing?.type === "session" ? ( + session: {routing.taskIdentifier} + ) : ( + Unknown + )} + + + +
+ +
+ Scope + + + Source + + {endpoint.source} + + + + Tenant + + {endpoint.isDefault ? ( + default + ) : ( + {endpoint.tenantId} + )} + + + + External ref + + {endpoint.externalRef ? ( + {endpoint.externalRef} + ) : ( + None + )} + + + + Status + + + + + + Created + + + + + + Updated + + + + + +
+ + {metadataJson ? ( +
+ Metadata + +
+ ) : null} +
+
+ ); +} + +function ProviderSetup({ verifier, source }: { verifier: LoaderData["verifier"]; source: string }) { + if (!verifier) return null; + + if (verifier.kind === "bundle") { + return ( + + This endpoint uses a custom verifier bundle. + + ); + } + + const presetName = verifier.kind === "preset" ? verifier.preset : null; + const config = verifier.config; + + return ( +
+ + Provider setup + + + + Scheme + + + {presetName ? `${presetName} (${config.scheme})` : config.scheme} + + + + {config.scheme === "hmac" || config.scheme === "asymmetric" ? ( + <> + + Signature header + + {config.signatureHeader} + + + + Algorithm + + + {config.algorithm} / {config.encoding} + + + + {config.timestamp ? ( + + Timestamp + + + {describeTimestampSource(config.timestamp.source)} + + + + ) : null} + + Signing string + + + {config.signingString === "raw" ? "raw body" : config.signingString.template} + + + + {config.scheme === "asymmetric" ? ( + + Public key + + {config.publicKeyEncoding ?? "pem"} + + + ) : null} + + ) : config.scheme === "shared-secret" ? ( + <> + + Placement + + {config.placement} + + + {config.fieldName ? ( + + Field name + + {config.fieldName} + + + ) : null} + + ) : ( + <> + + Placement + + {config.placement} + + + + Param name + + {config.paramName} + + + + )} + + + {config.scheme === "asymmetric" + ? `${source} signs with its private key; set its public key above.` + : `Sign deliveries with the ${source} scheme above, using the signing secret.`} + +
+ ); +} + +// Human-readable description of where the replay timestamp is read from. +function describeTimestampSource(source: WebhookValueSource): string { + switch (source.from) { + case "header": + return `header ${source.name}`; + case "signatureField": + return `field "${source.field}" in signature header`; + case "body": + return `body ${source.path}`; + case "url": + return "request URL"; + case "constant": + return "constant"; + } +} + +function SetSecretDialog({ + hasSigningSecret, + credentialNoun, + variant = "secondary/small", +}: { + hasSigningSecret: boolean; + credentialNoun: string; + variant?: "secondary/small" | "tertiary/small"; +}) { + const fetcher = useFetcher(); + const [open, setOpen] = useState(false); + const isSubmitting = fetcher.state !== "idle"; + const verb = hasSigningSecret ? "Rotate" : "Set"; + const isPublicKey = credentialNoun === "public key"; + + // Close on a successful save; the loader revalidates and the state flips to "Set". + useEffect(() => { + if (fetcher.state === "idle" && fetcher.data?.success) { + setOpen(false); + } + }, [fetcher.state, fetcher.data]); + + return ( + + + + + + + {verb} {credentialNoun} + + + +
+ + + + {isPublicKey + ? "The provider's public key. Stored encrypted; deliveries are verified against it." + : "Stored encrypted and never shown again. Deliveries are verified against this secret."} + +
+ {fetcher.data && !fetcher.data.success ? ( + + {fetcher.data.error} + + ) : null} +
+ + +
+
+
+
+ ); +} + +// For integrator-supplied secrets (GitHub/GitLab/standard): mint a strong secret server-side, +// store it, and reveal it ONCE so the user can paste it into their provider. +function GenerateSecretDialog({ hasSigningSecret }: { hasSigningSecret: boolean }) { + const fetcher = useFetcher(); + const [open, setOpen] = useState(false); + const isSubmitting = fetcher.state !== "idle"; + const generated = + fetcher.data && "generatedSecret" in fetcher.data ? fetcher.data.generatedSecret : undefined; + + return ( + + + + + + + {hasSigningSecret ? "Regenerate signing secret" : "Generate signing secret"} + + {generated ? ( +
+ + Copy this now. It won't be shown again. + + + Paste this into your provider's webhook signing-secret field. +
+ +
+
+ ) : ( + + + + Trigger.dev generates a strong signing secret, stores it encrypted, and shows it once + so you can paste it into your provider. + +
+ + +
+
+ )} +
+
+ ); +} + +function TableLoading() { + return ( +
+ +
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks/route.tsx new file mode 100644 index 0000000000..f6723ddeba --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks/route.tsx @@ -0,0 +1,10 @@ +import { Outlet } from "@remix-run/react"; +import { PageContainer } from "~/components/layout/AppLayout"; + +export default function Page() { + return ( + + + + ); +} diff --git a/apps/webapp/app/routes/api.v1.schedules.$scheduleId.ts b/apps/webapp/app/routes/api.v1.schedules.$scheduleId.ts index f98707eecb..c601d0e621 100644 --- a/apps/webapp/app/routes/api.v1.schedules.$scheduleId.ts +++ b/apps/webapp/app/routes/api.v1.schedules.$scheduleId.ts @@ -130,6 +130,7 @@ export async function action({ request, params }: ActionFunctionArgs) { deduplicationKey: schedule.deduplicationKey, environments: schedule.environments, nextRun: schedule.nextRun, + nextRunEffectiveAt: schedule.nextRunEffectiveAt, }; return json(responseObject, { status: 200 }); diff --git a/apps/webapp/app/routes/api.v1.schedules.ts b/apps/webapp/app/routes/api.v1.schedules.ts index 277033dd94..aaa49d1c02 100644 --- a/apps/webapp/app/routes/api.v1.schedules.ts +++ b/apps/webapp/app/routes/api.v1.schedules.ts @@ -72,6 +72,7 @@ export async function action({ request }: ActionFunctionArgs) { deduplicationKey: schedule.deduplicationKey, environments: schedule.environments, nextRun: schedule.nextRun, + nextRunEffectiveAt: schedule.nextRunEffectiveAt, }; return json(responseObject, { status: 200 }); @@ -130,6 +131,7 @@ export async function loader({ request }: LoaderFunctionArgs) { externalId: schedule.externalId, active: schedule.active, nextRun: schedule.nextRun, + nextRunEffectiveAt: schedule.nextRunEffectiveAt, environments: schedule.environments, })), pagination: { diff --git a/apps/webapp/app/routes/api.v1.sessions.ts b/apps/webapp/app/routes/api.v1.sessions.ts index c18412c481..c7cf2b27a0 100644 --- a/apps/webapp/app/routes/api.v1.sessions.ts +++ b/apps/webapp/app/routes/api.v1.sessions.ts @@ -7,8 +7,7 @@ import { type SessionItem, type SessionStatus, } from "@trigger.dev/core/v3"; -import { SessionId } from "@trigger.dev/core/v3/isomorphic"; -import type { Prisma, Session } from "@trigger.dev/database"; +import type { Session } from "@trigger.dev/database"; import { $replica, prisma, type PrismaClient } from "~/db.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { logger } from "~/services/logger.server"; @@ -17,8 +16,8 @@ import { ensureRunForSession, type SessionTriggerConfig, } from "~/services/realtime/sessionRunManager.server"; -import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server"; import { + findOrCreateSession, serializeSession, serializeSessionsWithFriendlyRunIds, } from "~/services/realtime/sessions.server"; @@ -169,66 +168,18 @@ const { action } = createActionApiRoute( }, async ({ authentication, body }) => { try { - const { id, friendlyId } = SessionId.generate(); - - // Idempotent on (env, externalId): two concurrent POSTs converge - // to the same row. We refresh `triggerConfig` on the cached path - // so newly-deployed schema changes (e.g. an updated - // `clientDataSchema` on the agent) propagate to subsequent runs - // — the next `ensureRunForSession` reads back the latest config. - let session: Session; - let isCached = false; - - const triggerConfigJson = body.triggerConfig as unknown as Prisma.InputJsonValue; - - if (body.externalId) { - session = await prisma.session.upsert({ - where: { - runtimeEnvironmentId_externalId: { - runtimeEnvironmentId: authentication.environment.id, - externalId: body.externalId, - }, - }, - create: { - id, - friendlyId, - externalId: body.externalId, - type: body.type, - taskIdentifier: body.taskIdentifier, - triggerConfig: triggerConfigJson, - tags: body.tags ?? [], - metadata: body.metadata as Prisma.InputJsonValue | undefined, - expiresAt: body.expiresAt ?? null, - projectId: authentication.environment.projectId, - runtimeEnvironmentId: authentication.environment.id, - environmentType: authentication.environment.type, - organizationId: authentication.environment.organizationId, - streamBasinName: authentication.environment.organization.streamBasinName, - chatSnapshotStoragePath: chatSnapshotStoragePathForSession(friendlyId), - }, - update: { triggerConfig: triggerConfigJson }, - }); - isCached = session.id !== id; - } else { - session = await prisma.session.create({ - data: { - id, - friendlyId, - type: body.type, - taskIdentifier: body.taskIdentifier, - triggerConfig: triggerConfigJson, - tags: body.tags ?? [], - metadata: body.metadata as Prisma.InputJsonValue | undefined, - expiresAt: body.expiresAt ?? null, - projectId: authentication.environment.projectId, - runtimeEnvironmentId: authentication.environment.id, - environmentType: authentication.environment.type, - organizationId: authentication.environment.organizationId, - streamBasinName: authentication.environment.organization.streamBasinName, - chatSnapshotStoragePath: chatSnapshotStoragePathForSession(friendlyId), - }, - }); - } + // Idempotent on (env, externalId): two concurrent POSTs converge to the same row, and + // `triggerConfig` is refreshed on the cached path so a redeployed config reaches the next run. + const { session, isCached } = await findOrCreateSession({ + environment: authentication.environment, + externalId: body.externalId, + type: body.type, + taskIdentifier: body.taskIdentifier, + triggerConfig: body.triggerConfig, + tags: body.tags, + metadata: body.metadata as Record | undefined, + expiresAt: body.expiresAt, + }); // Reject create on a closed session. The upsert path will return // an already-closed row when the caller reuses an externalId, and diff --git a/apps/webapp/app/routes/api.v1.webhooks.deliveries.$deliveryId.replay.ts b/apps/webapp/app/routes/api.v1.webhooks.deliveries.$deliveryId.replay.ts new file mode 100644 index 0000000000..04f1a409c0 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.webhooks.deliveries.$deliveryId.replay.ts @@ -0,0 +1,60 @@ +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { webhookReplica } from "~/db.server"; +import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; +import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { webhookDeliveriesRepository } from "~/services/webhookDeliveriesRepository/webhookDeliveriesRepository.server"; +import { webhookEngine } from "~/v3/webhookEngine.server"; + +const ParamsSchema = z.object({ deliveryId: z.string() }); + +// POST /api/v1/webhooks/deliveries/:deliveryId/replay — re-run the delivery's task from its stored +// event as a NEW delivery (we don't keep the raw body, so this re-triggers rather than re-verifies). +const { action, loader } = createActionApiRoute( + { + params: ParamsSchema, + method: "POST", + allowJWT: true, + corsStrategy: "all", + authorization: { action: "write", resource: () => ({ type: "webhooks" }) }, + }, + async ({ params, authentication }) => { + const env = authentication.environment; + + // Resolve the friendly id to the internal (id, createdAt) the engine needs (ClickHouse -> PG). + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + env.organizationId, + "standard" + ); + const original = await webhookDeliveriesRepository({ + clickhouse, + prisma: webhookReplica, + }).getDelivery({ + organizationId: env.organizationId, + projectId: env.project.id, + environmentId: env.id, + friendlyId: params.deliveryId, + }); + if (!original) return json({ error: "Not found" }, { status: 404 }); + + const result = await webhookEngine.replayDelivery({ + id: original.id, + createdAt: original.createdAt, + }); + + switch (result.outcome) { + case "replayed": + return json({ deliveryId: result.deliveryFriendlyId, replayedFrom: params.deliveryId }); + case "delivery_not_found": + case "endpoint_not_found": + return json({ error: "Not found" }, { status: 404 }); + case "unsupported_target": + return json( + { error: "This delivery's endpoint does not route to a task." }, + { status: 400 } + ); + } + } +); + +export { action, loader }; diff --git a/apps/webapp/app/routes/api.v1.webhooks.deliveries.$deliveryId.ts b/apps/webapp/app/routes/api.v1.webhooks.deliveries.$deliveryId.ts new file mode 100644 index 0000000000..b54a01e11e --- /dev/null +++ b/apps/webapp/app/routes/api.v1.webhooks.deliveries.$deliveryId.ts @@ -0,0 +1,25 @@ +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { findWebhookDeliveryResource } from "~/presenters/v3/ApiWebhookDeliveryPresenter.server"; +import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; + +const ParamsSchema = z.object({ + deliveryId: z.string(), +}); + +export const loader = createLoaderApiRoute( + { + params: ParamsSchema, + allowJWT: true, + corsStrategy: "all", + findResource: async (params, authentication) => + findWebhookDeliveryResource(authentication, params.deliveryId), + authorization: { + action: "read", + resource: () => ({ type: "webhooks" }), + }, + }, + async ({ resource }) => { + return json(resource); + } +); diff --git a/apps/webapp/app/routes/api.v1.webhooks.deliveries.ts b/apps/webapp/app/routes/api.v1.webhooks.deliveries.ts new file mode 100644 index 0000000000..3607f6b30e --- /dev/null +++ b/apps/webapp/app/routes/api.v1.webhooks.deliveries.ts @@ -0,0 +1,22 @@ +import { json } from "@remix-run/server-runtime"; +import { + ApiWebhookDeliveryListPresenter, + ApiWebhookDeliveryListSearchParams, +} from "~/presenters/v3/ApiWebhookDeliveryPresenter.server"; +import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; + +export const loader = createLoaderApiRoute( + { + searchParams: ApiWebhookDeliveryListSearchParams, + findResource: async () => 1, // Collection route — nothing to resolve. + allowJWT: true, + corsStrategy: "all", + authorization: { action: "read", resource: () => ({ type: "webhooks" }) }, + }, + async ({ searchParams, authentication }) => { + const presenter = new ApiWebhookDeliveryListPresenter(); + const result = await presenter.call(authentication.environment, searchParams); + + return json(result); + } +); diff --git a/apps/webapp/app/routes/api.v1.webhooks.endpoints.$endpointId.disable.ts b/apps/webapp/app/routes/api.v1.webhooks.endpoints.$endpointId.disable.ts new file mode 100644 index 0000000000..bb5cde45d6 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.webhooks.endpoints.$endpointId.disable.ts @@ -0,0 +1,34 @@ +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { webhookPrisma } from "~/db.server"; +import { findWebhookEndpointResource } from "~/presenters/v3/ApiWebhookEndpointPresenter.server"; +import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; + +const ParamsSchema = z.object({ endpointId: z.string() }); + +// POST /api/v1/webhooks/endpoints/:endpointId/disable — pause an endpoint (ingress returns 404). +const { action, loader } = createActionApiRoute( + { + params: ParamsSchema, + method: "POST", + allowJWT: true, + corsStrategy: "all", + authorization: { action: "write", resource: () => ({ type: "webhooks" }) }, + }, + async ({ params, authentication }) => { + const env = authentication.environment; + const endpoint = await webhookPrisma.webhookEndpoint.findFirst({ + where: { friendlyId: params.endpointId, runtimeEnvironmentId: env.id }, + }); + if (!endpoint) return json({ error: "Not found" }, { status: 404 }); + + await webhookPrisma.webhookEndpoint.update({ + where: { id: endpoint.id }, + data: { status: "INACTIVE", manuallyDeactivatedAt: new Date() }, + }); + + return json(await findWebhookEndpointResource(authentication, params.endpointId)); + } +); + +export { action, loader }; diff --git a/apps/webapp/app/routes/api.v1.webhooks.endpoints.$endpointId.enable.ts b/apps/webapp/app/routes/api.v1.webhooks.endpoints.$endpointId.enable.ts new file mode 100644 index 0000000000..e724cc0d81 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.webhooks.endpoints.$endpointId.enable.ts @@ -0,0 +1,34 @@ +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { webhookPrisma } from "~/db.server"; +import { findWebhookEndpointResource } from "~/presenters/v3/ApiWebhookEndpointPresenter.server"; +import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; + +const ParamsSchema = z.object({ endpointId: z.string() }); + +// POST /api/v1/webhooks/endpoints/:endpointId/enable — resume a paused endpoint. +const { action, loader } = createActionApiRoute( + { + params: ParamsSchema, + method: "POST", + allowJWT: true, + corsStrategy: "all", + authorization: { action: "write", resource: () => ({ type: "webhooks" }) }, + }, + async ({ params, authentication }) => { + const env = authentication.environment; + const endpoint = await webhookPrisma.webhookEndpoint.findFirst({ + where: { friendlyId: params.endpointId, runtimeEnvironmentId: env.id }, + }); + if (!endpoint) return json({ error: "Not found" }, { status: 404 }); + + await webhookPrisma.webhookEndpoint.update({ + where: { id: endpoint.id }, + data: { status: "ACTIVE", manuallyDeactivatedAt: null }, + }); + + return json(await findWebhookEndpointResource(authentication, params.endpointId)); + } +); + +export { action, loader }; diff --git a/apps/webapp/app/routes/api.v1.webhooks.endpoints.$endpointId.rotate-secret.ts b/apps/webapp/app/routes/api.v1.webhooks.endpoints.$endpointId.rotate-secret.ts new file mode 100644 index 0000000000..8ff637413f --- /dev/null +++ b/apps/webapp/app/routes/api.v1.webhooks.endpoints.$endpointId.rotate-secret.ts @@ -0,0 +1,50 @@ +import { json } from "@remix-run/server-runtime"; +import { randomBytes } from "node:crypto"; +import { WebhookVerifierArtifact } from "@trigger.dev/core/v3"; +import { z } from "zod"; +import { prisma, webhookPrisma } from "~/db.server"; +import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { getSecretStore } from "~/services/secrets/secretStore.server"; + +const ParamsSchema = z.object({ endpointId: z.string() }); + +// POST /api/v1/webhooks/endpoints/:endpointId/rotate-secret — mint a new signing secret and return +// it ONCE. Only for schemes we generate (hmac / shared-secret); asymmetric endpoints set a public key. +const { action, loader } = createActionApiRoute( + { + params: ParamsSchema, + method: "POST", + allowJWT: true, + corsStrategy: "all", + authorization: { action: "write", resource: () => ({ type: "webhooks" }) }, + }, + async ({ params, authentication }) => { + const env = authentication.environment; + const endpoint = await webhookPrisma.webhookEndpoint.findFirst({ + where: { friendlyId: params.endpointId, runtimeEnvironmentId: env.id }, + }); + if (!endpoint) return json({ error: "Not found" }, { status: 404 }); + + const parsed = WebhookVerifierArtifact.safeParse(endpoint.verifierArtifact); + if (parsed.success && "config" in parsed.data && parsed.data.config.scheme === "asymmetric") { + return json( + { + error: "Cannot generate a secret for an asymmetric endpoint; set its public key instead.", + }, + { status: 400 } + ); + } + + const secret = `whsec_${randomBytes(32).toString("hex")}`; + const secretKey = `webhook:signing-secret:${endpoint.id}`; + await getSecretStore("DATABASE", { prismaClient: prisma }).setSecret(secretKey, { secret }); + await webhookPrisma.webhookEndpoint.update({ + where: { id: endpoint.id }, + data: { signingSecretKey: secretKey }, + }); + + return json({ id: endpoint.friendlyId, secretSet: true as const, secret }); + } +); + +export { action, loader }; diff --git a/apps/webapp/app/routes/api.v1.webhooks.endpoints.$endpointId.ts b/apps/webapp/app/routes/api.v1.webhooks.endpoints.$endpointId.ts new file mode 100644 index 0000000000..f4a308f105 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.webhooks.endpoints.$endpointId.ts @@ -0,0 +1,25 @@ +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { findWebhookEndpointResource } from "~/presenters/v3/ApiWebhookEndpointPresenter.server"; +import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; + +const ParamsSchema = z.object({ + endpointId: z.string(), +}); + +export const loader = createLoaderApiRoute( + { + params: ParamsSchema, + allowJWT: true, + corsStrategy: "all", + findResource: async (params, authentication) => + findWebhookEndpointResource(authentication, params.endpointId), + authorization: { + action: "read", + resource: () => ({ type: "webhooks" }), + }, + }, + async ({ resource }) => { + return json(resource); + } +); diff --git a/apps/webapp/app/routes/api.v1.webhooks.endpoints.ts b/apps/webapp/app/routes/api.v1.webhooks.endpoints.ts new file mode 100644 index 0000000000..11d9d7d3d7 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.webhooks.endpoints.ts @@ -0,0 +1,22 @@ +import { json } from "@remix-run/server-runtime"; +import { + ApiWebhookEndpointListPresenter, + ApiWebhookEndpointListSearchParams, +} from "~/presenters/v3/ApiWebhookEndpointPresenter.server"; +import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; + +export const loader = createLoaderApiRoute( + { + searchParams: ApiWebhookEndpointListSearchParams, + findResource: async () => 1, // Collection route — nothing to resolve. + allowJWT: true, + corsStrategy: "all", + authorization: { action: "read", resource: () => ({ type: "webhooks" }) }, + }, + async ({ searchParams, authentication }) => { + const presenter = new ApiWebhookEndpointListPresenter(); + const result = await presenter.call(authentication.environment, searchParams); + + return json(result); + } +); diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts index 9a3dec90d4..d4dd1d9f19 100644 --- a/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts +++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts @@ -10,6 +10,7 @@ import { resolveSessionWithWriterFallback, } from "~/services/realtime/sessions.server"; import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; +import { stripClientWebhookActionSource } from "~/services/realtime/sanitizeSessionInput.server"; import { claimSessionStreamPart, drainSessionStreamWaitpoints, @@ -137,7 +138,11 @@ const { action, loader } = createActionApiRoute( const addressingKey = canonicalSessionAddressingKey(session, params.session); - const part = await request.text(); + let part = await request.text(); + if (params.io === "in") { + part = stripClientWebhookActionSource(part); + } + const clientPartId = request.headers.get("X-Part-Id"); const partId = clientPartId ?? nanoid(7); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx index 61ce814dbd..74b1a1f295 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx @@ -1,5 +1,6 @@ import { getFormProps, getInputProps, getSelectProps, useForm } from "@conform-to/react"; import { parseWithZod } from "@conform-to/zod"; +import { ScheduleWindow } from "@trigger.dev/core/v3"; import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid"; import { type FetcherWithComponents, @@ -52,6 +53,7 @@ import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { EnvironmentParamSchema, docsPath, v3EnvironmentPath } from "~/utils/pathBuilder"; import { CronPattern, UpsertSchedule } from "~/v3/schedules"; +import { ServiceValidationError } from "~/v3/services/baseService.server"; import { UpsertTaskScheduleService } from "~/v3/services/upsertTaskSchedule.server"; import { AIGeneratedCronField } from "../resources.orgs.$organizationSlug.projects.$projectParam.schedules.new.natural-language"; @@ -114,10 +116,21 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { message ); } catch (error: any) { - logger.error("Failed to create schedule", error); + if (!(error instanceof ServiceValidationError)) { + logger.error("Failed to create schedule", error); + } - const errorMessage = `Something went wrong. Please try again.`; + const errorMessage = + error instanceof ServiceValidationError + ? error.message + : `Something went wrong. Please try again.`; if (wantsJson) { + if (error instanceof ServiceValidationError) { + return json(submission.reply({ formErrors: [error.message] }), { + status: error.status ?? 422, + }); + } + return json({ ok: false as const, message: errorMessage }, { status: 500 }); } return redirectWithErrorMessage( @@ -138,6 +151,15 @@ type CronPatternResult = error: string; }; +type ScheduleWindowResult = + | { + isValid: true; + } + | { + isValid: false; + error: string; + }; + export function UpsertScheduleForm({ schedule, possibleTasks, @@ -167,6 +189,7 @@ export function UpsertScheduleForm({ const [selectedTimezone, setSelectedTimezone] = useState(schedule?.timezone ?? "UTC"); const isUtc = selectedTimezone === "UTC"; const [cronPattern, setCronPattern] = useState(schedule?.cron ?? ""); + const [scheduleWindowValue, setScheduleWindowValue] = useState(schedule?.window ?? ""); const navigation = useNavigation(); const isLoading = submitFetcher ? submitFetcher.state !== "idle" : navigation.state !== "idle"; const organization = useOrganization(); @@ -174,22 +197,40 @@ export function UpsertScheduleForm({ const environment = useEnvironment(); const location = useLocation(); - const [form, { taskIdentifier, cron, timezone, externalId, environments, deduplicationKey }] = - useForm({ - // Disambiguate per-schedule so both sheets (create + edit) can - // coexist without duplicate DOM ids breaking `htmlFor` / conform. - id: schedule?.friendlyId ? `edit-schedule-${schedule.friendlyId}` : "create-schedule", - // TODO: type this - lastResult: lastSubmission as any, - shouldRevalidate: "onSubmit", - onValidate({ formData }) { - return parseWithZod(formData, { schema: UpsertSchedule }); - }, - }); + const [ + form, + { + taskIdentifier, + cron, + timezone, + window: scheduleWindow, + externalId, + environments, + deduplicationKey, + }, + ] = useForm({ + // Disambiguate per-schedule so both sheets (create + edit) can + // coexist without duplicate DOM ids breaking `htmlFor` / conform. + id: schedule?.friendlyId ? `edit-schedule-${schedule.friendlyId}` : "create-schedule", + // TODO: type this + lastResult: lastSubmission as any, + shouldRevalidate: "onSubmit", + onValidate({ formData }) { + return parseWithZod(formData, { schema: UpsertSchedule }); + }, + }); let cronPatternResult: CronPatternResult | undefined = undefined; + let scheduleWindowResult: ScheduleWindowResult | undefined = undefined; let nextRuns: Date[] | undefined = undefined; + if (scheduleWindowValue !== "") { + const result = ScheduleWindow.safeParse(scheduleWindowValue); + scheduleWindowResult = result.success + ? { isValid: true } + : { isValid: false, error: result.error.errors[0].message }; + } + if (cronPattern !== "") { const result = CronPattern.safeParse(cronPattern); @@ -305,9 +346,19 @@ export function UpsertScheduleForm({ {cronPatternResult === undefined ? ( Enter a CRON pattern or use natural language above. ) : cronPatternResult.isValid ? ( - + ) : ( - + )} @@ -335,9 +386,52 @@ export function UpsertScheduleForm({ {timezone.errors} + + + setScheduleWindowValue(event.target.value)} + /> + {scheduleWindowResult === undefined ? ( + + Assigns each run a stable time after its CRON time. Use minutes, hours, or a + percentage of the interval. + + ) : scheduleWindowResult.isValid ? ( + + ) : ( + + )} + {nextRuns !== undefined && (
Next 5 runs + {scheduleWindowValue !== "" && ( + + Actual run times will get a fixed offset based on the window, displayed after + creation. + + )}
@@ -486,9 +580,21 @@ function buttonText(mode: "edit" | "new", isLoading: boolean) { } } -function ValidCronMessage({ isValid, message }: { isValid: boolean; message: string }) { +function ValidationMessage({ + id, + isValid, + validLabel, + invalidLabel, + message, +}: { + id?: string; + isValid: boolean; + validLabel: string; + invalidLabel: string; + message: string; +}) { return ( - + {isValid ? ( @@ -496,7 +602,7 @@ function ValidCronMessage({ isValid, message }: { isValid: boolean; message: str )} - {isValid ? "Valid pattern:" : "Invalid pattern:"} + {isValid ? validLabel : invalidLabel} {message} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.ai-generate-payload.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.ai-generate-payload.tsx index d03b1985cb..7053520854 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.ai-generate-payload.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.ai-generate-payload.tsx @@ -22,6 +22,8 @@ const RequestSchema = z.object({ payloadSchema: z.string().max(50_000).optional(), currentPayload: z.string().max(50_000).optional(), isAgent: z.enum(["true", "false"]).optional(), + payloadKind: z.enum(["standard", "agent", "webhook"]).optional(), + providerSource: z.string().max(100).optional(), }); export async function action({ request, params }: ActionFunctionArgs) { @@ -65,20 +67,31 @@ export async function action({ request, params }: ActionFunctionArgs) { ); } - const { prompt, taskIdentifier, payloadSchema, currentPayload, isAgent } = submission.data; - const agentMode = isAgent === "true"; + const { + prompt, + taskIdentifier, + payloadSchema, + currentPayload, + isAgent, + payloadKind, + providerSource, + } = submission.data; + const kind = payloadKind ?? (isAgent === "true" ? "agent" : "standard"); logger.info("[AI payload] Generating payload", { taskIdentifier, hasPayloadSchema: !!payloadSchema, hasCurrentPayload: !!currentPayload, promptLength: prompt.length, - agentMode, + kind, }); - const systemPrompt = agentMode - ? buildAgentClientDataPrompt(taskIdentifier, payloadSchema, currentPayload) - : buildSystemPrompt(taskIdentifier, payloadSchema, currentPayload); + const systemPrompt = + kind === "webhook" + ? buildWebhookEventPrompt(providerSource, currentPayload) + : kind === "agent" + ? buildAgentClientDataPrompt(taskIdentifier, payloadSchema, currentPayload) + : buildSystemPrompt(taskIdentifier, payloadSchema, currentPayload); const stream = new ReadableStream({ async start(controller) { @@ -101,16 +114,19 @@ export async function action({ request, params }: ActionFunctionArgs) { abortSignal: getRequestAbortSignal(), system: systemPrompt, prompt, - tools: { - getTaskSourceCode: tool({ - description: - "Look up the source code of the task to understand what payload shape it expects. Use this when there is no JSON Schema available and you need to infer the payload structure from the task implementation.", - inputSchema: z.object({}), - execute: async () => { - return getTaskSourceCode(environment.id, environment.type, taskIdentifier); - }, - }), - }, + tools: + kind === "webhook" + ? {} + : { + getTaskSourceCode: tool({ + description: + "Look up the source code of the task to understand what payload shape it expects. Use this when there is no JSON Schema available and you need to infer the payload structure from the task implementation.", + inputSchema: z.object({}), + execute: async () => { + return getTaskSourceCode(environment.id, environment.type, taskIdentifier); + }, + }), + }, stopWhen: stepCountIs(3), }); @@ -293,6 +309,43 @@ Use this as context but generate new client data based on the user's prompt.`; return prompt; } +function buildWebhookEventPrompt(providerSource?: string, currentPayload?: string): string { + const provider = providerSource && providerSource !== "custom" ? providerSource : undefined; + + let prompt = `You are generating a realistic example webhook event body${ + provider ? ` for the "${provider}" provider` : "" + }. + +Return ONLY the raw JSON event body that the provider would POST to a webhook endpoint, wrapped in a \`\`\`json code block. Do NOT include HTTP headers, a signature, or any {event, headers} envelope — just the event JSON itself. + +Requirements: +- Generate a realistic, well-formed event payload matching the shape ${ + provider ? `the "${provider}" provider actually sends` : "a typical webhook provider sends" + }. +- Use plausible values (real-looking ids with the provider's prefixes, unix/ISO timestamps, emails, amounts). +- Include the fields a consumer branches on (e.g. an event "type"/"event" discriminator and a "data"/"object" payload) when the provider uses them. +- The JSON must be valid and parseable.`; + + if (provider) { + prompt += ` + +If you know the "${provider}" webhook event schema, follow it closely (field names, nesting, id prefixes) and pick a common, representative event type for this provider.`; + } + + if (currentPayload) { + prompt += ` + +The current event body in the editor is: +\`\`\`json +${currentPayload} +\`\`\` + +Use it as context but generate a new event based on the user's prompt.`; + } + + return prompt; +} + function buildSystemPrompt( taskIdentifier: string, payloadSchema?: string, diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.deliveries.live.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.deliveries.live.ts new file mode 100644 index 0000000000..5b6c2840f1 --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.deliveries.live.ts @@ -0,0 +1,174 @@ +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { typedjson } from "remix-typedjson"; +import { z } from "zod"; +import { type WebhookDeliveryStatus } from "@trigger.dev/database"; +import { $replica, webhookReplica } from "~/db.server"; +import { WebhookDeliveriesListPresenter } from "~/presenters/v3/WebhookDeliveriesListPresenter.server"; +import { resolveDeliveryRunTargets } from "~/presenters/v3/WebhookDetailPresenter.server"; +import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; +import { loadProjectEnvironmentFromRequest } from "~/services/loadProjectEnvironmentFromRequest.server"; +import { requireUser } from "~/services/session.server"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; +import { flag } from "~/v3/featureFlags.server"; +import { + type ListedWebhookDelivery, + webhookDeliveriesRepository, +} from "~/services/webhookDeliveriesRepository/webhookDeliveriesRepository.server"; + +const deliveryIdsQueryParam = z + .string() + .optional() + .transform((value) => { + const ids = + value + ?.split(",") + .map((id) => id.trim()) + .filter(Boolean) ?? []; + return [...new Set(ids)].slice(0, 100); + }); + +const SearchParamsSchema = z.object({ + webhookEndpointId: z.string().optional(), + deliveryIds: deliveryIdsQueryParam, + includeNewDeliveries: z + .string() + .optional() + .transform((value) => value === "true"), + since: z.coerce.number().optional(), + to: z.coerce.number().optional(), +}); + +const KNOWN_DELIVERY_STATUSES: readonly WebhookDeliveryStatus[] = [ + "PENDING", + "PROCESSING", + "SUCCEEDED", + "FAILED", + "FILTERED", +]; + +/** + * Parse the deliveries-list filters so the new-deliveries count applies the same ones the list is + * showing (its badge must never count events the filtered list would exclude). Mirrors the top-level + * list's param parsing: repeated or CSV `statuses`, repeated `webhooks`, `deliveryId`, `runId`, `test`. + */ +function parseListFilters(searchParams: URLSearchParams) { + const statusValues = searchParams + .getAll("statuses") + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter((value): value is WebhookDeliveryStatus => + KNOWN_DELIVERY_STATUSES.includes(value as WebhookDeliveryStatus) + ); + const webhooks = searchParams.getAll("webhooks").filter((value) => value.length > 0); + const testParam = searchParams.get("test"); + return { + statuses: statusValues.length > 0 ? statusValues : undefined, + webhooks: webhooks.length > 0 ? webhooks : undefined, + deliveryId: searchParams.get("deliveryId") ?? undefined, + runId: searchParams.get("runId") ?? undefined, + isTest: testParam === "only" ? true : testParam === "hide" ? false : undefined, + }; +} + +export type LiveDeliveryFields = { + friendlyId: string; + status: ListedWebhookDelivery["status"]; + runId: string | null; + run: { friendlyId: string } | null; + session: { friendlyId: string; externalId: string | null } | null; + errorMessage: string | null; + processedAt: Date | null; +}; + +export function mapDeliveryToLiveFields( + delivery: ListedWebhookDelivery, + targets: { + runFriendlyIdById: Map; + sessionByRunId: Map; + } +): LiveDeliveryFields { + const runFriendlyId = delivery.runId ? targets.runFriendlyIdById.get(delivery.runId) : undefined; + return { + friendlyId: delivery.friendlyId, + status: delivery.status, + runId: delivery.runId, + run: runFriendlyId ? { friendlyId: runFriendlyId } : null, + session: delivery.runId ? (targets.sessionByRunId.get(delivery.runId) ?? null) : null, + errorMessage: delivery.errorMessage, + processedAt: delivery.processedAt, + }; +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + const url = new URL(request.url); + const { webhookEndpointId, deliveryIds, includeNewDeliveries, since, to } = + SearchParamsSchema.parse(Object.fromEntries(url.searchParams)); + + const newDeliveriesSince = includeNewDeliveries && since !== undefined ? since : undefined; + + if (deliveryIds.length === 0 && newDeliveriesSince === undefined) { + return typedjson({ deliveries: [] }); + } + + const { project, environment } = await loadProjectEnvironmentFromRequest(request, params); + + const user = await requireUser(request); + if (!user.admin && !user.isImpersonating) { + const org = await $replica.organization.findFirst({ + where: { id: project.organizationId }, + select: { featureFlags: true }, + }); + const enabled = await flag({ + key: FEATURE_FLAG.hasWebhooksAccess, + defaultValue: false, + overrides: (org?.featureFlags as Record) ?? {}, + }); + if (!enabled) throw new Response("Not found", { status: 404 }); + } + + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + project.organizationId, + "standard" + ); + const repository = webhookDeliveriesRepository({ clickhouse, prisma: webhookReplica }); + + const [deliveries, newDeliveriesResult] = await Promise.all([ + deliveryIds.length > 0 + ? repository + .getDeliveriesByFriendlyIds({ + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + friendlyIds: deliveryIds, + }) + .then(async (rows) => { + const targets = await resolveDeliveryRunTargets($replica, rows); + return rows.map((row) => mapDeliveryToLiveFields(row, targets)); + }) + : Promise.resolve([]), + newDeliveriesSince !== undefined + ? (async () => { + const filters = parseListFilters(url.searchParams); + const count = await new WebhookDeliveriesListPresenter( + $replica, + clickhouse + ).countNewDeliveries({ + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + webhookEndpointId, + ...filters, + since: newDeliveriesSince, + to, + }); + return { count, since: newDeliveriesSince }; + })() + : Promise.resolve(undefined), + ]); + + if (newDeliveriesResult) { + return typedjson({ deliveries, ...newDeliveriesResult }); + } + + return typedjson({ deliveries }); +} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.endpoints.$endpointParam.replay-source.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.endpoints.$endpointParam.replay-source.ts new file mode 100644 index 0000000000..54305c31ad --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.endpoints.$endpointParam.replay-source.ts @@ -0,0 +1,148 @@ +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { WebhookVerifierArtifact } from "@trigger.dev/core/v3"; +import { typedjson } from "remix-typedjson"; +import { z } from "zod"; +import { $replica, webhookReplica } from "~/db.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { WebhookDetailPresenter } from "~/presenters/v3/WebhookDetailPresenter.server"; +import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; +import { requireUser } from "~/services/session.server"; +import { webhookDeliveriesRepository } from "~/services/webhookDeliveriesRepository/webhookDeliveriesRepository.server"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; +import { flag } from "~/v3/featureFlags.server"; + +const ParamsSchema = EnvironmentParamSchema.extend({ endpointParam: z.string() }); + +const RECENT_DELIVERIES_LIMIT = 20; + +export type ReplaySourceDelivery = { + friendlyId: string; + status: string; + isTest: boolean; + externalDeliveryId: string; + createdAt: string; +}; + +export type ReplaySourceData = + | { kind: "list"; deliveries: ReplaySourceDelivery[] } + | { kind: "payload"; body: string; headers: Record } + | { kind: "error"; error: string }; + +const HEADER_DENYLIST = new Set([ + "content-type", + "content-length", + "host", + "accept", + "accept-encoding", + "accept-language", + "user-agent", + "connection", + "cache-control", + "pragma", + "origin", + "referer", + "x-trigger-test", +]); + +function isNoiseHeader(lower: string): boolean { + return lower.startsWith("sec-") || HEADER_DENYLIST.has(lower); +} + +function signatureHeaderNames(artifact: unknown): Set { + const names = new Set(); + const parsed = WebhookVerifierArtifact.safeParse(artifact); + if (!parsed.success || parsed.data.kind === "bundle") return names; + const config = parsed.data.config; + if (config.scheme === "hmac" || config.scheme === "asymmetric") { + names.add(config.signatureHeader.toLowerCase()); + if (config.timestamp?.source.from === "header") { + names.add(config.timestamp.source.name.toLowerCase()); + } + } + return names; +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + const user = await requireUser(request); + const { organizationSlug, projectParam, envParam, endpointParam } = ParamsSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, user.id); + if (!project) return typedjson({ kind: "error", error: "Project not found" } as ReplaySourceData); + const environment = await findEnvironmentBySlug(project.id, envParam, user.id); + if (!environment) + return typedjson({ kind: "error", error: "Environment not found" } as ReplaySourceData); + + if (!user.admin && !user.isImpersonating) { + const org = await $replica.organization.findFirst({ + where: { id: project.organizationId }, + select: { featureFlags: true }, + }); + const enabled = await flag({ + key: FEATURE_FLAG.hasWebhooksAccess, + defaultValue: false, + overrides: (org?.featureFlags as Record) ?? {}, + }); + if (!enabled) return typedjson({ kind: "error", error: "Not found" } as ReplaySourceData); + } + + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + project.organizationId, + "standard" + ); + const presenter = new WebhookDetailPresenter($replica, clickhouse); + const endpoint = await presenter.findEndpoint({ + environmentId: environment.id, + endpointFriendlyId: endpointParam, + }); + if (!endpoint) + return typedjson({ kind: "error", error: "Endpoint not found" } as ReplaySourceData); + + const repository = webhookDeliveriesRepository({ clickhouse, prisma: webhookReplica }); + + const url = new URL(request.url); + const deliveryId = url.searchParams.get("deliveryId") ?? undefined; + + if (deliveryId) { + const delivery = await repository.getDelivery({ + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + friendlyId: deliveryId, + }); + if (!delivery || delivery.webhookEndpointId !== endpoint.id) { + return typedjson({ kind: "error", error: "Delivery not found" } as ReplaySourceData); + } + const body = + delivery.parsedEvent != null ? JSON.stringify(delivery.parsedEvent, null, 2) : "{}"; + const strip = signatureHeaderNames(endpoint.verifierArtifact); + const rawHeaders = (delivery.headers ?? {}) as Record; + const headers: Record = {}; + for (const [key, value] of Object.entries(rawHeaders)) { + const lower = key.toLowerCase(); + if (isNoiseHeader(lower) || strip.has(lower)) continue; + if (typeof value === "string") headers[key] = value; + } + return typedjson({ kind: "payload", body, headers } as ReplaySourceData); + } + + const { deliveries } = await repository.listDeliveries({ + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + webhookEndpointId: endpoint.id, + page: { size: RECENT_DELIVERIES_LIMIT }, + }); + + return typedjson({ + kind: "list", + deliveries: deliveries.map((delivery) => ({ + friendlyId: delivery.friendlyId, + status: delivery.status, + isTest: delivery.isTest, + externalDeliveryId: delivery.externalDeliveryId, + createdAt: delivery.createdAt.toISOString(), + })), + } as ReplaySourceData); +} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.endpoints.$endpointParam.send.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.endpoints.$endpointParam.send.ts new file mode 100644 index 0000000000..80171d7bb2 --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.endpoints.$endpointParam.send.ts @@ -0,0 +1,221 @@ +import { signWithVerifierConfig } from "@internal/webhook-engine"; +import { type ActionFunctionArgs, redirect } from "@remix-run/server-runtime"; +import { WebhookVerifierArtifact } from "@trigger.dev/core/v3"; +import { z } from "zod"; +import { $replica, prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { WebhookDetailPresenter } from "~/presenters/v3/WebhookDetailPresenter.server"; +import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; +import { getSecretStore } from "~/services/secrets/secretStore.server"; +import { requireUser } from "~/services/session.server"; +import { webhookConsoleSendRateLimiter } from "~/services/webhookConsoleSendRateLimit.server"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { webhookIngressUrl } from "~/utils/webhookIngressUrl.server"; +import { webhookEngine } from "~/v3/webhookEngine.server"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; +import { flag } from "~/v3/featureFlags.server"; + +const ParamsSchema = EnvironmentParamSchema.extend({ endpointParam: z.string() }); + +const SigningSecretSchema = z.object({ secret: z.string() }); + +const SendSchema = z.object({ + body: z.string(), + headers: z.record(z.string()).optional(), + signatureMode: z.enum(["signed", "unsigned", "tampered", "simulate"]).default("signed"), + redirect: z.boolean().default(true), +}); + +export type WebhookSendResult = + | { + success: true; + httpStatus: number; + deliveryId?: string; + deduplicated?: boolean; + handshake?: boolean; + responseBody: string; + } + | { success: false; error: string; notSignable?: boolean }; + +/** + * Authenticated dashboard test-send. Session/cookie auth via requireUser; authorization via + * findProjectBySlug (org membership) + findEnvironmentBySlug (env access) + the webhooks feature flag. + * The delivery is injected in-process through the engine (verify -> filter -> route -> run), never by + * looping back through the public ingress, so it does not consume the provider's ingress rate budget + * and is not gated by WEBHOOK_INGRESS_ENABLED. + */ +export async function action({ request, params }: ActionFunctionArgs): Promise { + const user = await requireUser(request); + const { organizationSlug, projectParam, envParam, endpointParam } = ParamsSchema.parse(params); + + if (env.WEBHOOK_ENABLED !== "1") { + return { success: false, error: "Webhooks are not enabled on this instance." }; + } + + const project = await findProjectBySlug(organizationSlug, projectParam, user.id); + if (!project) return { success: false, error: "Project not found" }; + const environment = await findEnvironmentBySlug(project.id, envParam, user.id); + if (!environment) return { success: false, error: "Environment not found" }; + + if (!user.admin && !user.isImpersonating) { + const org = await $replica.organization.findFirst({ + where: { id: project.organizationId }, + select: { featureFlags: true }, + }); + const enabled = await flag({ + key: FEATURE_FLAG.hasWebhooksAccess, + defaultValue: false, + overrides: (org?.featureFlags as Record) ?? {}, + }); + if (!enabled) return { success: false, error: "Not found" }; + } + + const rateLimit = await webhookConsoleSendRateLimiter.limit(user.id); + if (!rateLimit.success) { + return { success: false, error: "Too many test sends. Wait a moment and try again." }; + } + + const parsed = SendSchema.safeParse(await request.json()); + if (!parsed.success) { + return { success: false, error: parsed.error.issues[0]?.message ?? "Invalid request" }; + } + const { body, headers: providedHeaders, signatureMode, redirect: shouldRedirect } = parsed.data; + + const rawBody = new TextEncoder().encode(body); + const limitBytes = env.WEBHOOK_INGRESS_BODY_SIZE_LIMIT_MB * 1024 * 1024; + if (rawBody.length > limitBytes) { + return { + success: false, + error: `Body exceeds the ${env.WEBHOOK_INGRESS_BODY_SIZE_LIMIT_MB} MB limit.`, + }; + } + + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + project.organizationId, + "standard" + ); + const presenter = new WebhookDetailPresenter($replica, clickhouse); + const endpoint = await presenter.findEndpoint({ + environmentId: environment.id, + endpointFriendlyId: endpointParam, + }); + if (!endpoint) return { success: false, error: "Endpoint not found" }; + + const verifier = WebhookVerifierArtifact.safeParse(endpoint.verifierArtifact); + if (!verifier.success) return { success: false, error: "Endpoint has no verifier config" }; + if (verifier.data.kind === "bundle") { + return { success: false, error: "Bundle verifiers cannot be sent from the console" }; + } + const config = verifier.data.config; + + const ingressUrl = webhookIngressUrl(endpoint.opaqueId); + const baseHeaders = { ...(providedHeaders ?? {}) }; + + let ingestHeaders: Record = baseHeaders; + let ingestBody: Uint8Array = rawBody; + let ingestUrl = ingressUrl; + + if (signatureMode === "signed") { + if (!endpoint.hasSigningSecret) { + return { success: false, error: "This endpoint has no signing secret. Set one first." }; + } + const secretStore = getSecretStore("DATABASE", { prismaClient: prisma }); + const stored = await secretStore.getSecret( + SigningSecretSchema, + `webhook:signing-secret:${endpoint.id}` + ); + if (!stored?.secret) { + return { success: false, error: "The signing secret could not be read." }; + } + const signed = signWithVerifierConfig({ + config, + secret: stored.secret, + rawBody, + url: ingressUrl, + headers: baseHeaders, + }); + if (!signed.ok) { + return { success: false, error: signed.error, notSignable: signed.notSignable }; + } + ingestHeaders = signed.headers; + ingestBody = signed.body; + ingestUrl = signed.url; + } else if (signatureMode === "tampered") { + const bogus = signWithVerifierConfig({ + config, + secret: `tampered-${Date.now()}`, + rawBody, + url: ingressUrl, + headers: baseHeaders, + }); + if (bogus.ok) { + ingestHeaders = bogus.headers; + ingestBody = bogus.body; + ingestUrl = bogus.url; + } else if (config.scheme === "hmac" || config.scheme === "asymmetric") { + ingestHeaders = { ...baseHeaders, [config.signatureHeader]: "deadbeef" }; + } + } + + const finalHeaders: Record = { + "content-type": "application/json", + ...ingestHeaders, + "x-trigger-test": "1", + }; + + const ingestInput = { + opaqueId: endpoint.opaqueId, + rawBytes: ingestBody, + headers: finalHeaders, + url: ingestUrl, + }; + const result = + signatureMode === "simulate" + ? await webhookEngine.simulateInject(ingestInput) + : await webhookEngine.ingest(ingestInput); + + const deliveryPathFor = (friendlyId: string) => + `/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/webhooks/deliveries/${friendlyId}`; + + switch (result.outcome) { + case "accepted": { + const friendlyId = result.deliveryFriendlyId; + if (shouldRedirect) throw redirect(deliveryPathFor(friendlyId)); + return { + success: true, + httpStatus: 200, + deliveryId: friendlyId, + responseBody: JSON.stringify({ received: true, deliveryId: friendlyId }), + }; + } + case "handshake": + return { success: true, httpStatus: 200, handshake: true, responseBody: result.body }; + case "duplicate": { + const friendlyId = result.deliveryId; + if (shouldRedirect && friendlyId) throw redirect(deliveryPathFor(friendlyId)); + return { + success: true, + httpStatus: 200, + deliveryId: friendlyId, + deduplicated: true, + responseBody: JSON.stringify({ received: true, deliveryId: friendlyId }), + }; + } + case "verification_failed": + return { + success: false, + error: result.error ?? "Signature verification failed. No delivery was recorded.", + }; + case "secret_missing": + return { success: false, error: "This endpoint has no signing secret. Set one first." }; + case "endpoint_not_found": + case "endpoint_inactive": + return { success: false, error: "This endpoint is not active." }; + case "enqueue_failed": + return { success: false, error: result.error ?? "Failed to record the delivery." }; + default: + return { success: false, error: "The delivery could not be sent." }; + } +} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.samples.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.samples.ts new file mode 100644 index 0000000000..fdd25ee2aa --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.samples.ts @@ -0,0 +1,122 @@ +import { + categoryLabel, + categoryOrder, + getProvider, + getSample, + sampleManifest, +} from "@internal/webhook-sources"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { typedjson } from "remix-typedjson"; +import { $replica } from "~/db.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { requireUser } from "~/services/session.server"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; +import { flag } from "~/v3/featureFlags.server"; + +export type WebhookSampleMeta = { + provider: string; + providerLabel?: string; + presetId?: string; + eventType: string; + name: string; + description?: string; +}; + +/** Provider metadata joined from the registry, for the producer pane. */ +export type WebhookProviderMeta = { + id: string; + label: string; + category?: string; + categoryLabel?: string; + docsUrl?: string; + /** Set when the provider maps to a verifier preset (i.e. round-trippable / first-class). */ + preset?: string; + eventCount: number; +}; + +export type WebhookSamplesData = + | { kind: "manifest"; providers: WebhookProviderMeta[]; samples: WebhookSampleMeta[] } + | { kind: "body"; body: string; extraHeaders?: Record } + | { kind: "error"; error: string }; + +function titleCase(id: string): string { + return id.charAt(0).toUpperCase() + id.slice(1); +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + const user = await requireUser(request); + const { organizationSlug, projectParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, user.id); + if (!project) + return typedjson({ kind: "error", error: "Project not found" } as WebhookSamplesData); + + if (!user.admin && !user.isImpersonating) { + const org = await $replica.organization.findFirst({ + where: { id: project.organizationId }, + select: { featureFlags: true }, + }); + const enabled = await flag({ + key: FEATURE_FLAG.hasWebhooksAccess, + defaultValue: false, + overrides: (org?.featureFlags as Record) ?? {}, + }); + if (!enabled) throw new Response("Not found", { status: 404 }); + } + + const url = new URL(request.url); + const provider = url.searchParams.get("provider") ?? undefined; + const eventType = url.searchParams.get("eventType") ?? undefined; + + if (provider && eventType) { + const sample = getSample(provider, eventType); + if (!sample) + return typedjson({ kind: "error", error: "Sample not found" } as WebhookSamplesData); + return typedjson({ + kind: "body", + body: JSON.stringify(sample.body, null, 2), + extraHeaders: sample.extraHeaders, + } as WebhookSamplesData); + } + + const manifest = sampleManifest().filter((sample) => getProvider(sample.provider)); + + const items: WebhookSampleMeta[] = manifest.map((sample) => { + const entry = getProvider(sample.provider); + return { + provider: sample.provider, + providerLabel: entry?.label ?? sample.providerLabel, + presetId: sample.presetId, + eventType: sample.eventType, + name: sample.name, + description: sample.description, + }; + }); + + const providerMap = new Map(); + for (const sample of manifest) { + const existing = providerMap.get(sample.provider); + if (existing) { + existing.eventCount += 1; + continue; + } + const entry = getProvider(sample.provider); + providerMap.set(sample.provider, { + id: sample.provider, + label: entry?.label ?? sample.providerLabel ?? titleCase(sample.provider), + category: entry?.category, + categoryLabel: entry ? categoryLabel(entry.category) : undefined, + docsUrl: entry?.docsUrl, + preset: entry?.preset, + eventCount: 1, + }); + } + + const providers = [...providerMap.values()].sort((a, b) => { + const cat = categoryOrder(a.category ?? "") - categoryOrder(b.category ?? ""); + return cat !== 0 ? cat : a.label.localeCompare(b.label); + }); + + return typedjson({ kind: "manifest", providers, samples: items } as WebhookSamplesData); +} diff --git a/apps/webapp/app/routes/webhooks.v1.ingest.$opaqueId.ts b/apps/webapp/app/routes/webhooks.v1.ingest.$opaqueId.ts new file mode 100644 index 0000000000..5b50cc8f16 --- /dev/null +++ b/apps/webapp/app/routes/webhooks.v1.ingest.$opaqueId.ts @@ -0,0 +1,75 @@ +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { env } from "~/env.server"; +import { logger } from "~/services/logger.server"; +import { readBodyWithCap } from "~/utils/readBodyWithCap.server"; +import { webhookIngressRateLimiter } from "~/services/webhookIngressRateLimit.server"; +import { webhookEngine } from "~/v3/webhookEngine.server"; + +// Public, unauthenticated webhook ingress. A Remix `action` (NOT +// createActionApiRoute, which parses JSON) so we can capture the raw bytes the +// signature scheme verifies. The engine resolves the endpoint (and its env id + +// type) from the globally-unique opaqueId, so this route runs no env query. +export async function action({ request, params }: ActionFunctionArgs) { + if (request.method !== "POST") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + if (env.WEBHOOK_ENABLED !== "1" || env.WEBHOOK_INGRESS_ENABLED !== "1") { + return json({ error: "Not found" }, { status: 404 }); + } + + const opaqueId = params.opaqueId; + if (!opaqueId) return json({ error: "Not found" }, { status: 404 }); + + // Per-opaqueId rate limit FIRST, before any DB or secret work. + const rl = await webhookIngressRateLimiter.limit(opaqueId); + if (!rl.success) { + logger.info("webhook ingress rate limited", { opaqueId }); + return json({ error: "Too many requests" }, { status: 429 }); + } + + // Content-Length is a cheap fast-path reject; the capped streaming read is the real enforcement + // (a chunked request can omit/understate Content-Length and would otherwise buffer unbounded). + const limitBytes = env.WEBHOOK_INGRESS_BODY_SIZE_LIMIT_MB * 1024 * 1024; + const contentLength = Number(request.headers.get("content-length") ?? "0"); + if (Number.isFinite(contentLength) && contentLength > limitBytes) { + return json({ error: "Payload too large" }, { status: 413 }); + } + + const rawBytes = await readBodyWithCap(request, limitBytes); + if (rawBytes === null) { + return json({ error: "Payload too large" }, { status: 413 }); + } + + const headers: Record = {}; + request.headers.forEach((v, k) => (headers[k] = v)); + + const result = await webhookEngine.ingest({ + opaqueId, + rawBytes, + headers, + url: request.url, // url-secret reads this; never logged with its query string + }); + + switch (result.outcome) { + case "accepted": + logger.info("webhook ingress accepted", { opaqueId, deliveryId: result.deliveryId }); + return json({ received: true, deliveryId: result.deliveryFriendlyId }, { status: 200 }); + case "handshake": + // Provider handshake echo (e.g. Slack url_verification): the challenge value, plain text, 200. + return new Response(result.body, { status: 200, headers: { "content-type": "text/plain" } }); + case "duplicate": + return json({ received: true, deliveryId: result.deliveryId }, { status: 200 }); + case "endpoint_not_found": + case "endpoint_inactive": + return json({ error: "Not found" }, { status: 404 }); + case "secret_missing": + logger.warn("webhook ingress rejected: signing secret unset", { opaqueId }); + return json({ error: "Bad request" }, { status: 400 }); + case "verification_failed": + logger.info("webhook ingress verification failed", { opaqueId }); + return json({ error: "Bad request" }, { status: 400 }); + case "enqueue_failed": + logger.error("webhook ingress enqueue failed", { opaqueId, error: result.error }); + return json({ error: "Internal error" }, { status: 500 }); + } +} diff --git a/apps/webapp/app/runEngine/concerns/traceEvents.server.ts b/apps/webapp/app/runEngine/concerns/traceEvents.server.ts index a0efcd1ac0..7e8734164f 100644 --- a/apps/webapp/app/runEngine/concerns/traceEvents.server.ts +++ b/apps/webapp/app/runEngine/concerns/traceEvents.server.ts @@ -2,6 +2,7 @@ import { SemanticInternalAttributes } from "@trigger.dev/core/v3/semanticInterna import type { TaskRun } from "@trigger.dev/database"; import type { IEventRepository } from "~/v3/eventRepository/eventRepository.types"; import { getEventRepository } from "~/v3/eventRepository/index.server"; +import { runTriggeredAt } from "~/v3/runTimestamps"; import type { TracedEventSpan, TraceEventConcern, TriggerTaskRequest } from "../types"; export class DefaultTraceEventsConcern implements TraceEventConcern { @@ -22,6 +23,13 @@ export class DefaultTraceEventsConcern implements TraceEventConcern { callback: (span: TracedEventSpan, store: string) => Promise ): Promise { const { repository, store } = await this.#getEventRepository(request, parentStore); + const startTime = request.options?.overrideCreatedAt + ? runTriggeredAt({ + createdAt: request.options.overrideCreatedAt, + queueTimestamp: request.options.queueTimestamp, + scheduleId: request.options.scheduleId, + }) + : undefined; return await repository.traceEvent( request.taskId, @@ -39,9 +47,7 @@ export class DefaultTraceEventsConcern implements TraceEventConcern { }, incomplete: true, immediate: true, - startTime: request.options?.overrideCreatedAt - ? BigInt(request.options.overrideCreatedAt.getTime()) * BigInt(1000000) - : undefined, + startTime: startTime ? BigInt(startTime.getTime()) * BigInt(1000000) : undefined, }, async (event, traceContext, traceparent) => { return await callback( diff --git a/apps/webapp/app/runEngine/types.ts b/apps/webapp/app/runEngine/types.ts index 339f1a36e1..14c992a285 100644 --- a/apps/webapp/app/runEngine/types.ts +++ b/apps/webapp/app/runEngine/types.ts @@ -16,6 +16,8 @@ export type TriggerTaskServiceOptions = { runFriendlyId?: string; skipChecks?: boolean; oneTimeUseToken?: string; + scheduleId?: string; + queueTimestamp?: Date; overrideCreatedAt?: Date; planType?: string; }; diff --git a/apps/webapp/app/services/clickhouse/clickhouseFactory.server.ts b/apps/webapp/app/services/clickhouse/clickhouseFactory.server.ts index 7624810efe..2027e8aeca 100644 --- a/apps/webapp/app/services/clickhouse/clickhouseFactory.server.ts +++ b/apps/webapp/app/services/clickhouse/clickhouseFactory.server.ts @@ -191,6 +191,33 @@ function initializeSessionsReplicationClickhouseClient(): ClickHouse { }); } +const defaultWebhookDeliveriesReplicationClickhouseClient = singleton( + "webhookDeliveriesReplicationClickhouseClient", + initializeWebhookDeliveriesReplicationClickhouseClient +); + +function initializeWebhookDeliveriesReplicationClickhouseClient(): ClickHouse { + if (!env.WEBHOOK_DELIVERIES_REPLICATION_CLICKHOUSE_URL) { + // Webhook deliveries replication worker gates on this URL; factory may still resolve "webhook_deliveries_replication" for tests. + return defaultClickhouseClient; + } + + const url = new URL(env.WEBHOOK_DELIVERIES_REPLICATION_CLICKHOUSE_URL); + url.searchParams.delete("secure"); + + return new ClickHouse({ + url: url.toString(), + name: "webhook-deliveries-replication", + keepAlive: { + enabled: env.SESSION_REPLICATION_KEEP_ALIVE_ENABLED === "1", + idleSocketTtl: env.SESSION_REPLICATION_KEEP_ALIVE_IDLE_SOCKET_TTL_MS, + }, + logLevel: env.SESSION_REPLICATION_CLICKHOUSE_LOG_LEVEL, + compression: { request: true }, + maxOpenConnections: env.SESSION_REPLICATION_MAX_OPEN_CONNECTIONS, + }); +} + /** Run-engine PendingVersionSystem lookup (`RUN_ENGINE_CLICKHOUSE_URL`); * falls back to the default client if unset. */ const defaultRunEngineClickhouseClient = singleton( @@ -398,6 +425,7 @@ export type ClientType = | "events" | "replication" | "sessions_replication" + | "webhook_deliveries_replication" | "logs" | "query" | "admin" @@ -439,6 +467,9 @@ function buildOrgClickhouseClient(url: string, clientType: ClientType): ClickHou maxOpenConnections: env.RUN_REPLICATION_MAX_OPEN_CONNECTIONS, }); case "sessions_replication": + // Webhook deliveries replication shares the sessions replication ClickHouse + // client config (same infra, both replication writers). + case "webhook_deliveries_replication": return new ClickHouse({ url: parsed.toString(), name, @@ -566,6 +597,8 @@ export class ClickhouseFactory { return defaultRunsReplicationClickhouseClient; case "sessions_replication": return defaultSessionsReplicationClickhouseClient; + case "webhook_deliveries_replication": + return defaultWebhookDeliveriesReplicationClickhouseClient; case "logs": return defaultLogsClickhouseClient; case "query": diff --git a/apps/webapp/app/services/platformNotifications.server.ts b/apps/webapp/app/services/platformNotifications.server.ts index 24e95740a7..0b39fd915c 100644 --- a/apps/webapp/app/services/platformNotifications.server.ts +++ b/apps/webapp/app/services/platformNotifications.server.ts @@ -1,6 +1,6 @@ import type { z } from "zod"; import { errAsync, fromPromise, type ResultAsync } from "neverthrow"; -import { prisma } from "~/db.server"; +import { Prisma, prisma, sqlDatabaseSchema } from "~/db.server"; import { type PlatformNotificationScope, type PlatformNotificationSurface, @@ -49,25 +49,41 @@ export async function getAdminNotificationsList({ orderBy: [{ createdAt: "desc" }], skip: (page - 1) * pageSize, take: pageSize, - include: { - _count: { - select: { interactions: true }, - }, - interactions: { - select: { - webappDismissedAt: true, - webappClickedAt: true, - cliDismissedAt: true, - }, - }, - }, }), prisma.platformNotification.count({ where }), ]); + const notificationIds = notifications.map((n) => n.id); + + const interactionStats = + notificationIds.length > 0 + ? await prisma.$queryRaw< + { + notificationId: string; + seen: bigint; + clicked: bigint; + dismissed: bigint; + }[] + >` + SELECT + "notificationId", + COUNT(*) AS seen, + COUNT(*) FILTER (WHERE "webappClickedAt" IS NOT NULL) AS clicked, + COUNT(*) FILTER ( + WHERE "webappDismissedAt" IS NOT NULL OR "cliDismissedAt" IS NOT NULL + ) AS dismissed + FROM ${sqlDatabaseSchema}."PlatformNotificationInteraction" + WHERE "notificationId" IN (${Prisma.join(notificationIds)}) + GROUP BY "notificationId" + ` + : []; + + const statsById = new Map(interactionStats.map((row) => [row.notificationId, row])); + return { notifications: notifications.map((n) => { const parsed = PayloadV1Schema.safeParse(n.payload); + const stats = statsById.get(n.id); return { id: n.id, friendlyId: n.friendlyId, @@ -99,11 +115,9 @@ export async function getAdminNotificationsList({ cliMaxDaysAfterFirstSeen: n.cliMaxDaysAfterFirstSeen, cliShowEvery: n.cliShowEvery, stats: { - seen: n._count.interactions, - clicked: n.interactions.filter((i) => i.webappClickedAt !== null).length, - dismissed: n.interactions.filter( - (i) => i.webappDismissedAt !== null || i.cliDismissedAt !== null - ).length, + seen: stats ? Number(stats.seen) : 0, + clicked: stats ? Number(stats.clicked) : 0, + dismissed: stats ? Number(stats.dismissed) : 0, }, }; }), diff --git a/apps/webapp/app/services/realtime/sanitizeSessionInput.server.ts b/apps/webapp/app/services/realtime/sanitizeSessionInput.server.ts new file mode 100644 index 0000000000..6609ece58d --- /dev/null +++ b/apps/webapp/app/services/realtime/sanitizeSessionInput.server.ts @@ -0,0 +1,25 @@ +/** + * Strip a client-forged `actionSource: "webhook"` from a session `.in` append part. + * + * Only the hosted webhook ingress may claim webhook trust, and it appends server-side rather than + * through the client append route. A client with session write access could otherwise send a record + * carrying `actionSource: "webhook"`, which the run loop uses to skip action-schema validation. We + * downgrade it here (delete the field) so the record is validated as a normal client action. + */ +export function stripClientWebhookActionSource(part: string): string { + if (!part.includes('"actionSource"')) return part; + + let record: { payload?: { actionSource?: string } } | undefined; + try { + record = JSON.parse(part) as { payload?: { actionSource?: string } }; + } catch { + return part; + } + + if (record?.payload?.actionSource === "webhook") { + delete record.payload.actionSource; + return JSON.stringify(record); + } + + return part; +} diff --git a/apps/webapp/app/services/realtime/sessions.server.ts b/apps/webapp/app/services/realtime/sessions.server.ts index 7bb7ee2f7c..08b612f334 100644 --- a/apps/webapp/app/services/realtime/sessions.server.ts +++ b/apps/webapp/app/services/realtime/sessions.server.ts @@ -1,7 +1,10 @@ -import type { PrismaClient, Session } from "@trigger.dev/database"; -import type { SessionItem } from "@trigger.dev/core/v3"; +import type { Prisma, PrismaClient, Session } from "@trigger.dev/database"; +import type { SessionItem, SessionTriggerConfig } from "@trigger.dev/core/v3"; +import { SessionId } from "@trigger.dev/core/v3/isomorphic"; import type { RunStore } from "@internal/run-store"; import { $replica, prisma } from "~/db.server"; +import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server"; import { runStore as defaultRunStore } from "~/v3/runStore.server"; import { boundedIn } from "@trigger.dev/database"; @@ -63,6 +66,71 @@ export function isSessionFriendlyIdForm(value: string): boolean { return value.startsWith(SESSION_FRIENDLY_ID_PREFIX); } +/** + * Find-or-create a Session row. Idempotent on `(environment, externalId)`: two concurrent callers + * converge to the same row (and `triggerConfig` is refreshed on the cached path so a redeployed + * config propagates to the next run). Shared by `POST /api/v1/sessions` and the webhook session + * delivery port so the create-field set (org/env scoping, streamBasin, snapshot path) stays in one place. + */ +export async function findOrCreateSession(params: { + environment: AuthenticatedEnvironment; + externalId?: string; + type: string; + taskIdentifier: string; + triggerConfig: SessionTriggerConfig; + tags?: string[]; + metadata?: Record; + expiresAt?: Date | null; +}): Promise<{ session: Session; isCached: boolean }> { + const { id, friendlyId } = SessionId.generate(); + const env = params.environment; + const triggerConfigJson = params.triggerConfig as unknown as Prisma.InputJsonValue; + + const common = { + type: params.type, + taskIdentifier: params.taskIdentifier, + triggerConfig: triggerConfigJson, + tags: params.tags ?? [], + metadata: params.metadata as Prisma.InputJsonValue | undefined, + expiresAt: params.expiresAt ?? null, + projectId: env.projectId, + runtimeEnvironmentId: env.id, + environmentType: env.type, + organizationId: env.organizationId, + streamBasinName: env.organization.streamBasinName, + chatSnapshotStoragePath: chatSnapshotStoragePathForSession(friendlyId), + }; + + if (params.externalId) { + const session = await prisma.session.upsert({ + where: { + runtimeEnvironmentId_externalId: { + runtimeEnvironmentId: env.id, + externalId: params.externalId, + }, + }, + create: { id, friendlyId, externalId: params.externalId, ...common }, + update: { triggerConfig: triggerConfigJson }, + }); + return { session, isCached: session.id !== id }; + } + + const session = await prisma.session.create({ data: { id, friendlyId, ...common } }); + return { session, isCached: false }; +} + +/** Find a session by externalId without creating one (resume-only channel delivery, e.g. startOn). */ +export async function findSessionByExternalId( + environment: AuthenticatedEnvironment, + externalId: string +): Promise { + return prisma.session.findUnique({ + where: { + runtimeEnvironmentId_externalId: { runtimeEnvironmentId: environment.id, externalId }, + }, + }); +} + /** * Canonicalise the addressing key used for everything stream-level: the * S2 stream path and the run-engine waitpoint cache key. `chat.agent` diff --git a/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts b/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts index f9db41e4f0..808e30431e 100644 --- a/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts @@ -280,6 +280,8 @@ export class ClickHouseRunsRepository implements IRunsRepository { runtimeEnvironmentId: true, status: true, createdAt: true, + queueTimestamp: true, + scheduleId: true, startedAt: true, lockedAt: true, delayUntil: true, diff --git a/apps/webapp/app/services/taskIdentifierRegistry.server.ts b/apps/webapp/app/services/taskIdentifierRegistry.server.ts index 527460439c..ce7b147134 100644 --- a/apps/webapp/app/services/taskIdentifierRegistry.server.ts +++ b/apps/webapp/app/services/taskIdentifierRegistry.server.ts @@ -16,6 +16,7 @@ import { function toTriggerSource(source: string | undefined): TaskTriggerSource { const normalized = source?.toUpperCase(); if (normalized === "AGENT") return "AGENT"; + if (normalized === "WEBHOOK") return "WEBHOOK"; if (normalized === "SCHEDULED" || normalized === "SCHEDULE") return "SCHEDULED"; return "STANDARD"; } diff --git a/apps/webapp/app/services/webhookConsoleSendRateLimit.server.ts b/apps/webapp/app/services/webhookConsoleSendRateLimit.server.ts new file mode 100644 index 0000000000..7e7ed3584e --- /dev/null +++ b/apps/webapp/app/services/webhookConsoleSendRateLimit.server.ts @@ -0,0 +1,14 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { RateLimiter, type Duration } from "./rateLimiter.server"; + +/** + * Per-user limiter for the authenticated webhook console test-send. The console injects deliveries + * in-process (not through the public ingress), so it does NOT ride the per-opaqueId ingress limiter; + * this keeps a single user's testing from flooding the engine + task queue, keyed by user id. + */ +export const webhookConsoleSendRateLimiter = new RateLimiter({ + keyPrefix: "webhook-console-send", + limiter: Ratelimit.fixedWindow(30, "60 s" as Duration), + logSuccess: false, + logFailure: true, +}); diff --git a/apps/webapp/app/services/webhookDeliveriesReplicationInstance.server.ts b/apps/webapp/app/services/webhookDeliveriesReplicationInstance.server.ts new file mode 100644 index 0000000000..191cd6cadb --- /dev/null +++ b/apps/webapp/app/services/webhookDeliveriesReplicationInstance.server.ts @@ -0,0 +1,92 @@ +import invariant from "tiny-invariant"; +import { env } from "~/env.server"; +import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; +import { singleton } from "~/utils/singleton"; +import { meter, provider } from "~/v3/tracer.server"; +import { WebhookDeliveriesReplicationService } from "./webhookDeliveriesReplicationService.server"; +import { signalsEmitter } from "./signals.server"; + +export const webhookDeliveriesReplicationInstance = singleton( + "webhookDeliveriesReplicationInstance", + initializeWebhookDeliveriesReplicationInstance +); + +function initializeWebhookDeliveriesReplicationInstance() { + const { DATABASE_URL } = process.env; + invariant(typeof DATABASE_URL === "string", "DATABASE_URL env var not set"); + + if (!env.WEBHOOK_DELIVERIES_REPLICATION_CLICKHOUSE_URL) { + console.log("🗃️ Webhook deliveries replication service not enabled"); + return; + } + + console.log("🗃️ Webhook deliveries replication service enabled"); + + const service = new WebhookDeliveriesReplicationService({ + clickhouseFactory, + // Follows the webhook writer DB (where WebhookDelivery physically lives once split). + pgConnectionUrl: env.WEBHOOK_DATABASE_URL ?? DATABASE_URL, + serviceName: "webhook-deliveries-replication", + slotName: env.WEBHOOK_DELIVERIES_REPLICATION_SLOT_NAME, + publicationName: env.WEBHOOK_DELIVERIES_REPLICATION_PUBLICATION_NAME, + // The source WebhookDelivery is a partitioned parent; without this the slot stays silent. + publishViaPartitionRoot: true, + redisOptions: { + keyPrefix: "webhook-deliveries-replication:", + port: env.RUN_REPLICATION_REDIS_PORT ?? undefined, + host: env.RUN_REPLICATION_REDIS_HOST ?? undefined, + username: env.RUN_REPLICATION_REDIS_USERNAME ?? undefined, + password: env.RUN_REPLICATION_REDIS_PASSWORD ?? undefined, + enableAutoPipelining: true, + ...(env.RUN_REPLICATION_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }), + }, + maxFlushConcurrency: env.WEBHOOK_DELIVERIES_REPLICATION_MAX_FLUSH_CONCURRENCY, + flushIntervalMs: env.WEBHOOK_DELIVERIES_REPLICATION_FLUSH_INTERVAL_MS, + flushBatchSize: env.WEBHOOK_DELIVERIES_REPLICATION_FLUSH_BATCH_SIZE, + leaderLockTimeoutMs: env.WEBHOOK_DELIVERIES_REPLICATION_LEADER_LOCK_TIMEOUT_MS, + leaderLockExtendIntervalMs: env.WEBHOOK_DELIVERIES_REPLICATION_LEADER_LOCK_EXTEND_INTERVAL_MS, + leaderLockAcquireAdditionalTimeMs: + env.WEBHOOK_DELIVERIES_REPLICATION_LEADER_LOCK_ADDITIONAL_TIME_MS, + leaderLockRetryIntervalMs: env.WEBHOOK_DELIVERIES_REPLICATION_LEADER_LOCK_RETRY_INTERVAL_MS, + ackIntervalSeconds: env.WEBHOOK_DELIVERIES_REPLICATION_ACK_INTERVAL_SECONDS, + logLevel: env.WEBHOOK_DELIVERIES_REPLICATION_LOG_LEVEL, + waitForAsyncInsert: env.WEBHOOK_DELIVERIES_REPLICATION_WAIT_FOR_ASYNC_INSERT === "1", + tracer: provider.getTracer("webhook-deliveries-replication-service"), + meter, + insertMaxRetries: env.WEBHOOK_DELIVERIES_REPLICATION_INSERT_MAX_RETRIES, + insertBaseDelayMs: env.WEBHOOK_DELIVERIES_REPLICATION_INSERT_BASE_DELAY_MS, + insertMaxDelayMs: env.WEBHOOK_DELIVERIES_REPLICATION_INSERT_MAX_DELAY_MS, + insertStrategy: env.WEBHOOK_DELIVERIES_REPLICATION_INSERT_STRATEGY, + }); + + if (env.WEBHOOK_DELIVERIES_REPLICATION_ENABLED === "1") { + // Gate start() on the org data-stores registry being loaded. Starting earlier would + // race the registry load — sync factory lookups would return `null` and route org-scoped + // deliveries to the default ClickHouse, writing them to the wrong cluster. + clickhouseFactory + .isReady() + .then(() => service.start()) + .then(() => { + console.log("🗃️ Webhook deliveries replication service started"); + }) + .catch((error) => { + console.error("🗃️ Webhook deliveries replication service failed to start", { + error, + }); + }); + + // SIGTERM/SIGINT fire during process teardown; wrap the async shutdown so an + // unhandled rejection doesn't bubble past process exit. + const shutdownWebhookDeliveriesReplication = () => { + service.shutdown().catch((error) => { + console.error("🗃️ Webhook deliveries replication service shutdown error", { + error, + }); + }); + }; + signalsEmitter.on("SIGTERM", shutdownWebhookDeliveriesReplication); + signalsEmitter.on("SIGINT", shutdownWebhookDeliveriesReplication); + } + + return service; +} diff --git a/apps/webapp/app/services/webhookDeliveriesReplicationService.server.ts b/apps/webapp/app/services/webhookDeliveriesReplicationService.server.ts new file mode 100644 index 0000000000..0a62e0e4e9 --- /dev/null +++ b/apps/webapp/app/services/webhookDeliveriesReplicationService.server.ts @@ -0,0 +1,821 @@ +import type { ClickHouse, WebhookDeliveryInsertArray } from "@internal/clickhouse"; +import { getWebhookDeliveryField } from "@internal/clickhouse"; +import { type RedisOptions } from "@internal/redis"; +import { + LogicalReplicationClient, + type MessageDelete, + type MessageInsert, + type MessageUpdate, + type PgoutputMessage, +} from "@internal/replication"; +import { + getMeter, + recordSpanError, + startSpan, + trace, + type Counter, + type Histogram, + type Meter, + type Tracer, +} from "@internal/tracing"; +import { Logger, type LogLevel } from "@trigger.dev/core/logger"; +import { tryCatch } from "@trigger.dev/core/utils"; +import { type WebhookDelivery } from "@trigger.dev/database"; +import EventEmitter from "node:events"; +import type { ClickhouseFactory } from "~/services/clickhouse/clickhouseFactory.server"; +import { ConcurrentFlushScheduler } from "./runsReplicationService.server"; + +interface TransactionEvent { + tag: "insert" | "update" | "delete"; + data: T; + raw: MessageInsert | MessageUpdate | MessageDelete; +} + +interface Transaction { + beginStartTimestamp: number; + commitLsn: string | null; + commitEndLsn: string | null; + xid: number; + events: TransactionEvent[]; + replicationLagMs: number; +} + +export type WebhookDeliveriesReplicationServiceOptions = { + clickhouseFactory: ClickhouseFactory; + pgConnectionUrl: string; + serviceName: string; + slotName: string; + publicationName: string; + publishViaPartitionRoot?: boolean; + redisOptions: RedisOptions; + maxFlushConcurrency?: number; + flushIntervalMs?: number; + flushBatchSize?: number; + leaderLockTimeoutMs?: number; + leaderLockExtendIntervalMs?: number; + leaderLockAcquireAdditionalTimeMs?: number; + leaderLockRetryIntervalMs?: number; + ackIntervalSeconds?: number; + acknowledgeTimeoutMs?: number; + logger?: Logger; + logLevel?: LogLevel; + tracer?: Tracer; + meter?: Meter; + waitForAsyncInsert?: boolean; + insertStrategy?: "insert" | "insert_async"; + // Retry configuration for insert operations + insertMaxRetries?: number; + insertBaseDelayMs?: number; + insertMaxDelayMs?: number; +}; + +type WebhookDeliveryInsert = { + _version: bigint; + delivery: WebhookDelivery; + event: "insert" | "update" | "delete"; +}; + +export type WebhookDeliveriesReplicationServiceEvents = { + message: [ + { lsn: string; message: PgoutputMessage; service: WebhookDeliveriesReplicationService }, + ]; + batchFlushed: [{ flushId: string; deliveryInserts: WebhookDeliveryInsertArray[] }]; +}; + +export class WebhookDeliveriesReplicationService { + private _isSubscribed = false; + private _currentTransaction: + | (Omit, "commitEndLsn" | "replicationLagMs"> & { + commitEndLsn?: string | null; + replicationLagMs?: number; + }) + | null = null; + + private _replicationClient: LogicalReplicationClient; + private _concurrentFlushScheduler: ConcurrentFlushScheduler; + private logger: Logger; + private _isShuttingDown = false; + private _isShutDownComplete = false; + private _tracer: Tracer; + private _meter: Meter; + private _currentParseDurationMs: number | null = null; + private _lastAcknowledgedAt: number | null = null; + private _acknowledgeTimeoutMs: number; + private _latestCommitEndLsn: string | null = null; + private _lastAcknowledgedLsn: string | null = null; + private _acknowledgeInterval: NodeJS.Timeout | null = null; + // Retry configuration + private _insertMaxRetries: number; + private _insertBaseDelayMs: number; + private _insertMaxDelayMs: number; + private _insertStrategy: "insert" | "insert_async"; + + // Metrics + private _replicationLagHistogram: Histogram; + private _batchesFlushedCounter: Counter; + private _batchSizeHistogram: Histogram; + private _deliveriesInsertedCounter: Counter; + private _insertRetriesCounter: Counter; + private _eventsProcessedCounter: Counter; + private _flushDurationHistogram: Histogram; + + public readonly events: EventEmitter; + + constructor(private readonly options: WebhookDeliveriesReplicationServiceOptions) { + this.logger = + options.logger ?? + new Logger("WebhookDeliveriesReplicationService", options.logLevel ?? "info"); + this.events = new EventEmitter(); + this._tracer = options.tracer ?? trace.getTracer("webhook-deliveries-replication-service"); + this._meter = options.meter ?? getMeter("webhook-deliveries-replication"); + + // Initialize metrics + this._replicationLagHistogram = this._meter.createHistogram( + "webhook_deliveries_replication.replication_lag_ms", + { + description: "Replication lag from Postgres commit to processing", + unit: "ms", + } + ); + + this._batchesFlushedCounter = this._meter.createCounter( + "webhook_deliveries_replication.batches_flushed", + { + description: "Total batches flushed to ClickHouse", + } + ); + + this._batchSizeHistogram = this._meter.createHistogram( + "webhook_deliveries_replication.batch_size", + { + description: "Number of items per batch flush", + unit: "items", + } + ); + + this._deliveriesInsertedCounter = this._meter.createCounter( + "webhook_deliveries_replication.deliveries_inserted", + { + description: "Webhook delivery inserts to ClickHouse", + unit: "inserts", + } + ); + + this._insertRetriesCounter = this._meter.createCounter( + "webhook_deliveries_replication.insert_retries", + { + description: "Insert retry attempts", + } + ); + + this._eventsProcessedCounter = this._meter.createCounter( + "webhook_deliveries_replication.events_processed", + { + description: "Replication events processed (inserts, updates, deletes)", + } + ); + + this._flushDurationHistogram = this._meter.createHistogram( + "webhook_deliveries_replication.flush_duration_ms", + { + description: "Duration of batch flush operations", + unit: "ms", + } + ); + + this._acknowledgeTimeoutMs = options.acknowledgeTimeoutMs ?? 1_000; + + this._insertStrategy = options.insertStrategy ?? "insert"; + + this._replicationClient = new LogicalReplicationClient({ + pgConfig: { + connectionString: options.pgConnectionUrl, + }, + name: options.serviceName, + slotName: options.slotName, + publicationName: options.publicationName, + table: "WebhookDelivery", + publishViaPartitionRoot: options.publishViaPartitionRoot, + redisOptions: options.redisOptions, + autoAcknowledge: false, + publicationActions: ["insert", "update", "delete"], + logger: options.logger ?? new Logger("LogicalReplicationClient", options.logLevel ?? "info"), + leaderLockTimeoutMs: options.leaderLockTimeoutMs ?? 30_000, + leaderLockExtendIntervalMs: options.leaderLockExtendIntervalMs ?? 10_000, + ackIntervalSeconds: options.ackIntervalSeconds ?? 10, + leaderLockAcquireAdditionalTimeMs: options.leaderLockAcquireAdditionalTimeMs ?? 10_000, + leaderLockRetryIntervalMs: options.leaderLockRetryIntervalMs ?? 500, + tracer: options.tracer, + }); + + this._concurrentFlushScheduler = new ConcurrentFlushScheduler({ + batchSize: options.flushBatchSize ?? 50, + flushInterval: options.flushIntervalMs ?? 100, + maxConcurrency: options.maxFlushConcurrency ?? 100, + callback: this.#flushBatch.bind(this), + // Key-based deduplication to reduce duplicates sent to ClickHouse + getKey: (item) => { + if (!item?.delivery?.id) { + this.logger.warn("Skipping replication event with null delivery", { event: item }); + return null; + } + return `${item.event}_${item.delivery.id}`; + }, + // Keep the delivery with the higher version (latest) + // and take the last occurrence for that version. + // Items originating from the same DB transaction have the same version. + shouldReplace: (existing, incoming) => incoming._version >= existing._version, + logger: new Logger("ConcurrentFlushScheduler", options.logLevel ?? "info"), + tracer: options.tracer, + }); + + this._replicationClient.events.on("data", async ({ lsn, log, parseDuration }) => { + this.#handleData(lsn, log, parseDuration); + }); + + this._replicationClient.events.on("heartbeat", async ({ lsn, shouldRespond }) => { + if (this._isShuttingDown) return; + if (this._isShutDownComplete) return; + + if (shouldRespond) { + this._lastAcknowledgedLsn = lsn; + await this._replicationClient.acknowledge(lsn); + } + }); + + this._replicationClient.events.on("error", (error) => { + this.logger.error("Replication client error", { + error, + }); + }); + + this._replicationClient.events.on("start", () => { + this.logger.info("Replication client started"); + }); + + this._replicationClient.events.on("acknowledge", ({ lsn }) => { + this.logger.debug("Acknowledged", { lsn }); + }); + + this._replicationClient.events.on("leaderElection", (isLeader) => { + this.logger.info("Leader election", { isLeader }); + }); + + // Initialize retry configuration + this._insertMaxRetries = options.insertMaxRetries ?? 3; + this._insertBaseDelayMs = options.insertBaseDelayMs ?? 100; + this._insertMaxDelayMs = options.insertMaxDelayMs ?? 2000; + } + + public async shutdown() { + if (this._isShuttingDown) return; + + this._isShuttingDown = true; + + this.logger.info("Initiating shutdown of webhook deliveries replication service"); + + if (!this._currentTransaction) { + this.logger.info("No transaction to commit, shutting down immediately"); + await this._replicationClient.stop(); + this._isSubscribed = false; + this._isShutDownComplete = true; + return; + } + + this._concurrentFlushScheduler.shutdown(); + } + + async start() { + if (this._isSubscribed) { + this.logger.debug("Replication client already started, skipping start"); + return; + } + + this.logger.info("Starting replication client", { + lastLsn: this._latestCommitEndLsn, + }); + + await this._replicationClient.subscribe(this._latestCommitEndLsn ?? undefined); + + this._acknowledgeInterval = setInterval(this.#acknowledgeLatestTransaction.bind(this), 1000); + this._concurrentFlushScheduler.start(); + this._isSubscribed = true; + } + + async stop() { + this.logger.info("Stopping replication client"); + + await this._replicationClient.stop(); + + if (this._acknowledgeInterval) { + clearInterval(this._acknowledgeInterval); + this._acknowledgeInterval = null; + } + + this._isSubscribed = false; + } + + async teardown() { + this.logger.info("Teardown replication client"); + + await this._replicationClient.teardown(); + + if (this._acknowledgeInterval) { + clearInterval(this._acknowledgeInterval); + this._acknowledgeInterval = null; + } + + this._isSubscribed = false; + } + + #handleData(lsn: string, message: PgoutputMessage, parseDuration: bigint) { + this.logger.debug("Handling data", { + lsn, + tag: message.tag, + parseDuration, + }); + + this.events.emit("message", { lsn, message, service: this }); + + switch (message.tag) { + case "begin": { + if (this._isShuttingDown || this._isShutDownComplete) { + return; + } + + this._currentTransaction = { + beginStartTimestamp: Date.now(), + commitLsn: message.commitLsn, + xid: message.xid, + events: [], + }; + + this._currentParseDurationMs = Number(parseDuration) / 1_000_000; + + break; + } + case "insert": { + if (!this._currentTransaction) { + return; + } + + if (this._currentParseDurationMs) { + this._currentParseDurationMs = + this._currentParseDurationMs + Number(parseDuration) / 1_000_000; + } + + this._currentTransaction.events.push({ + tag: message.tag, + data: message.new as WebhookDelivery, + raw: message, + }); + break; + } + case "update": { + if (!this._currentTransaction) { + return; + } + + if (this._currentParseDurationMs) { + this._currentParseDurationMs = + this._currentParseDurationMs + Number(parseDuration) / 1_000_000; + } + + this._currentTransaction.events.push({ + tag: message.tag, + data: message.new as WebhookDelivery, + raw: message, + }); + break; + } + case "delete": { + if (!this._currentTransaction) { + return; + } + + if (this._currentParseDurationMs) { + this._currentParseDurationMs = + this._currentParseDurationMs + Number(parseDuration) / 1_000_000; + } + + this._currentTransaction.events.push({ + tag: message.tag, + data: message.old as WebhookDelivery, + raw: message, + }); + + break; + } + case "commit": { + if (!this._currentTransaction) { + return; + } + + if (this._currentParseDurationMs) { + this._currentParseDurationMs = + this._currentParseDurationMs + Number(parseDuration) / 1_000_000; + } + + const replicationLagMs = Date.now() - Number(message.commitTime / 1000n); + this._currentTransaction.commitEndLsn = message.commitEndLsn; + this._currentTransaction.replicationLagMs = replicationLagMs; + const transaction = this._currentTransaction as Transaction; + this._currentTransaction = null; + + if (transaction.commitEndLsn) { + this._latestCommitEndLsn = transaction.commitEndLsn; + } + + this.#handleTransaction(transaction); + break; + } + default: { + this.logger.debug("Unknown message tag", { + pgMessage: message, + }); + } + } + } + + #handleTransaction(transaction: Transaction) { + if (this._isShutDownComplete) return; + + if (this._isShuttingDown) { + this._replicationClient.stop().finally(() => { + this._isSubscribed = false; + this._isShutDownComplete = true; + }); + } + + // If there are no events, do nothing + if (transaction.events.length === 0) { + return; + } + + if (!transaction.commitEndLsn) { + this.logger.error("Transaction has no commit end lsn", { + transaction, + }); + + return; + } + + const lsnToUInt64Start = process.hrtime.bigint(); + + // If there are events, we need to handle them + const _version = lsnToUInt64(transaction.commitEndLsn); + + const lsnToUInt64DurationMs = Number(process.hrtime.bigint() - lsnToUInt64Start) / 1_000_000; + + this._concurrentFlushScheduler.addToBatch( + transaction.events.map((event) => ({ + _version, + delivery: event.data, + event: event.tag, + })) + ); + + // Record metrics + this._replicationLagHistogram.record(transaction.replicationLagMs); + + // Count events by type + for (const event of transaction.events) { + this._eventsProcessedCounter.add(1, { event_type: event.tag }); + } + + this.logger.debug("handle_transaction", { + transaction: { + xid: transaction.xid, + commitLsn: transaction.commitLsn, + commitEndLsn: transaction.commitEndLsn, + events: transaction.events.length, + parseDurationMs: this._currentParseDurationMs, + lsnToUInt64DurationMs, + version: _version.toString(), + }, + }); + } + + async #acknowledgeLatestTransaction() { + if (!this._latestCommitEndLsn) { + return; + } + + if (this._lastAcknowledgedLsn === this._latestCommitEndLsn) { + return; + } + + const now = Date.now(); + + if (this._lastAcknowledgedAt) { + const timeSinceLastAcknowledged = now - this._lastAcknowledgedAt; + // If we've already acknowledged within the last second, don't acknowledge again + if (timeSinceLastAcknowledged < this._acknowledgeTimeoutMs) { + return; + } + } + + this._lastAcknowledgedAt = now; + this._lastAcknowledgedLsn = this._latestCommitEndLsn; + + this.logger.debug("acknowledge_latest_transaction", { + commitEndLsn: this._latestCommitEndLsn, + lastAcknowledgedAt: this._lastAcknowledgedAt, + }); + + const [ackError] = await tryCatch( + this._replicationClient.acknowledge(this._latestCommitEndLsn) + ); + + if (ackError) { + this.logger.error("Error acknowledging transaction", { ackError }); + } + + if (this._isShutDownComplete && this._acknowledgeInterval) { + clearInterval(this._acknowledgeInterval); + } + } + + async #flushBatch(flushId: string, batch: Array) { + if (batch.length === 0) { + return; + } + + this.logger.debug("Flushing batch", { + flushId, + batchSize: batch.length, + }); + + const flushStartTime = performance.now(); + + await startSpan(this._tracer, "flushBatch", async (span) => { + const routeCache = new Map(); + const groups = new Map(); + + for (const item of batch) { + if (!item.delivery.organizationId) { + continue; + } + + let client = routeCache.get(item.delivery.organizationId); + if (!client) { + client = this.options.clickhouseFactory.getClickhouseForOrganizationSync( + item.delivery.organizationId, + "webhook_deliveries_replication" + ); + routeCache.set(item.delivery.organizationId, client); + } + + let group = groups.get(client); + if (!group) { + group = { deliveryInserts: [] }; + groups.set(client, group); + } + + group.deliveryInserts.push( + toWebhookDeliveryInsertArray(item.delivery, item._version, item.event === "delete") + ); + } + + // batch inserts in clickhouse are more performant if the items + // are pre-sorted by the primary key + const sortDeliveryInserts = (rows: WebhookDeliveryInsertArray[]) => + rows.sort((a, b) => { + const aOrgId = getWebhookDeliveryField(a, "organization_id"); + const bOrgId = getWebhookDeliveryField(b, "organization_id"); + if (aOrgId !== bOrgId) { + return aOrgId < bOrgId ? -1 : 1; + } + const aProjId = getWebhookDeliveryField(a, "project_id"); + const bProjId = getWebhookDeliveryField(b, "project_id"); + if (aProjId !== bProjId) { + return aProjId < bProjId ? -1 : 1; + } + const aEnvId = getWebhookDeliveryField(a, "environment_id"); + const bEnvId = getWebhookDeliveryField(b, "environment_id"); + if (aEnvId !== bEnvId) { + return aEnvId < bEnvId ? -1 : 1; + } + const aCreatedAt = getWebhookDeliveryField(a, "created_at"); + const bCreatedAt = getWebhookDeliveryField(b, "created_at"); + if (aCreatedAt !== bCreatedAt) { + return aCreatedAt - bCreatedAt; + } + const aDeliveryId = getWebhookDeliveryField(a, "delivery_id"); + const bDeliveryId = getWebhookDeliveryField(b, "delivery_id"); + if (aDeliveryId === bDeliveryId) return 0; + return aDeliveryId < bDeliveryId ? -1 : 1; + }); + + const combinedDeliveryInserts: WebhookDeliveryInsertArray[] = []; + let deliveryError: Error | null = null; + + // Sequential per-group flush — matches runsReplicationService for the same reason + // (parallel writes have hit Linux net.ipv4.tcp_wmem buffer pressure at high throughput). + for (const [clickhouse, group] of groups) { + sortDeliveryInserts(group.deliveryInserts); + combinedDeliveryInserts.push(...group.deliveryInserts); + + const [insErr] = await this.#insertWithRetry( + (attempt) => this.#insertDeliveryInserts(clickhouse, group.deliveryInserts, attempt), + "delivery inserts", + flushId + ); + if (insErr && !deliveryError) { + deliveryError = insErr; + } + + if (!insErr) { + this._deliveriesInsertedCounter.add(group.deliveryInserts.length); + } + } + + span.setAttribute("delivery_inserts", combinedDeliveryInserts.length); + + this.logger.debug("Flushing inserts", { + flushId, + deliveryInserts: combinedDeliveryInserts.length, + clickhouseGroups: groups.size, + }); + + if (deliveryError) { + this.logger.error("Error inserting delivery inserts", { + error: deliveryError, + flushId, + }); + recordSpanError(span, deliveryError); + } + + this.logger.debug("Flushed inserts", { + flushId, + deliveryInserts: combinedDeliveryInserts.length, + }); + + this.events.emit("batchFlushed", { flushId, deliveryInserts: combinedDeliveryInserts }); + + const flushDurationMs = performance.now() - flushStartTime; + const hasErrors = deliveryError !== null; + + this._batchSizeHistogram.record(batch.length); + this._flushDurationHistogram.record(flushDurationMs); + this._batchesFlushedCounter.add(1, { success: !hasErrors }); + }); + } + + // New method to handle inserts with retry logic for connection errors + async #insertWithRetry( + insertFn: (attempt: number) => Promise, + operationName: string, + flushId: string + ): Promise<[Error | null, T | null]> { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= this._insertMaxRetries; attempt++) { + try { + const result = await insertFn(attempt); + return [null, result]; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // Check if this is a retryable error + if (this.#isRetryableError(lastError)) { + const delay = this.#calculateRetryDelay(attempt); + + this.logger.warn(`Retrying WebhookDeliveriesReplication insert due to error`, { + operationName, + flushId, + attempt, + maxRetries: this._insertMaxRetries, + error: lastError.message, + delay, + }); + + // Record retry metric + this._insertRetriesCounter.add(1, { operation: "webhook_deliveries" }); + + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + break; + } + } + + return [lastError, null]; + } + + // Retry all errors except known permanent ones + #isRetryableError(error: Error): boolean { + const errorMessage = error.message.toLowerCase(); + + // Permanent errors that should NOT be retried + const permanentErrorPatterns = [ + "authentication failed", + "permission denied", + "invalid credentials", + "table not found", + "database not found", + "column not found", + "schema mismatch", + "invalid query", + "syntax error", + "type error", + "constraint violation", + "duplicate key", + "foreign key violation", + ]; + + // If it's a known permanent error, don't retry + if (permanentErrorPatterns.some((pattern) => errorMessage.includes(pattern))) { + return false; + } + + // Retry everything else + return true; + } + + #calculateRetryDelay(attempt: number): number { + // Exponential backoff: baseDelay, baseDelay*2, baseDelay*4, etc. + const delay = Math.min( + this._insertBaseDelayMs * Math.pow(2, attempt - 1), + this._insertMaxDelayMs + ); + + // Add some jitter to prevent thundering herd + const jitter = Math.random() * 100; + return delay + jitter; + } + + #getClickhouseInsertSettings() { + if (this._insertStrategy === "insert") { + return {}; + } + + return { + async_insert: 1 as const, + async_insert_max_data_size: "1000000", + async_insert_busy_timeout_ms: 1000, + wait_for_async_insert: this.options.waitForAsyncInsert ? (1 as const) : (0 as const), + }; + } + + async #insertDeliveryInserts( + clickhouse: ClickHouse, + deliveryInserts: WebhookDeliveryInsertArray[], + attempt: number + ) { + if (deliveryInserts.length === 0) { + return; + } + return await startSpan(this._tracer, "insertDeliveryInserts", async (span) => { + const [insertError, insertResult] = await clickhouse.webhookDeliveries.insertCompactArrays( + deliveryInserts, + { + params: { + clickhouse_settings: this.#getClickhouseInsertSettings(), + }, + } + ); + + if (insertError) { + this.logger.error("Error inserting delivery inserts attempt", { + error: insertError, + attempt, + }); + + recordSpanError(span, insertError); + throw insertError; + } + + return insertResult; + }); + } +} + +function toWebhookDeliveryInsertArray( + delivery: WebhookDelivery, + version: bigint, + isDeleted: boolean +): WebhookDeliveryInsertArray { + return [ + delivery.runtimeEnvironmentId, + delivery.organizationId, + delivery.projectId, + delivery.id, + delivery.webhookEndpointId, + delivery.environmentType, + delivery.friendlyId, + delivery.externalDeliveryId ?? "", + delivery.runId ?? "", + delivery.status, + delivery.isTest ? 1 : 0, + delivery.createdAt.getTime(), + delivery.updatedAt.getTime(), + version.toString(), + isDeleted ? 1 : 0, + ]; +} + +function lsnToUInt64(lsn: string): bigint { + const [seg, off] = lsn.split("/"); + return (BigInt("0x" + seg) << 32n) | BigInt("0x" + off); +} diff --git a/apps/webapp/app/services/webhookDeliveriesRepository/clickhouseWebhookDeliveriesRepository.server.ts b/apps/webapp/app/services/webhookDeliveriesRepository/clickhouseWebhookDeliveriesRepository.server.ts new file mode 100644 index 0000000000..f8a43d150d --- /dev/null +++ b/apps/webapp/app/services/webhookDeliveriesRepository/clickhouseWebhookDeliveriesRepository.server.ts @@ -0,0 +1,382 @@ +import { type ClickhouseQueryBuilder } from "@internal/clickhouse"; +import { WebhookDeliveryId } from "@trigger.dev/core/v3/isomorphic"; +import { boundedIn } from "@trigger.dev/database"; +import { createdAtMsBounds, deliveryIdsCreatedAtBounds } from "./deliveryIdBounds"; +import { decodeRunsCursor, encodeRunsCursor } from "../runsRepository/runsCursor.server"; +import { + type CountDeliveriesByEndpointOptions, + type DetailedWebhookDelivery, + type FilterWebhookDeliveriesOptions, + type GetDeliveriesByFriendlyIdsOptions, + type GetWebhookDeliveryOptions, + type IWebhookDeliveriesRepository, + type ListedWebhookDelivery, + type ListWebhookDeliveriesOptions, + type WebhookDeliveriesRepositoryOptions, + type WebhookDeliveryIdsPage, +} from "./webhookDeliveriesRepository.server"; + +type DeliveryCursorRow = { deliveryId: string; createdAt: number }; + +const DELIVERY_DETAIL_SELECT = { + id: true, + friendlyId: true, + webhookEndpointId: true, + runtimeEnvironmentId: true, + environmentType: true, + status: true, + externalDeliveryId: true, + idempotencyKey: true, + runId: true, + rawBodyHash: true, + parsedEvent: true, + headers: true, + errorMessage: true, + filterReason: true, + createdAt: true, + updatedAt: true, + processedAt: true, +} as const; + +const DELIVERY_LIST_SELECT = { + id: true, + friendlyId: true, + webhookEndpointId: true, + runtimeEnvironmentId: true, + status: true, + isTest: true, + externalDeliveryId: true, + runId: true, + createdAt: true, + processedAt: true, + errorMessage: true, +} as const; + +export class ClickHouseWebhookDeliveriesRepository implements IWebhookDeliveriesRepository { + constructor(private readonly options: WebhookDeliveriesRepositoryOptions) {} + + get name() { + return "clickhouse"; + } + + /** + * Runs the keyset-paginated query and returns `{ deliveryId, createdAt }` rows + * (one extra beyond `page.size` to signal "has more"). The ordering is always + * the composite `(created_at, delivery_id)`; the cursor predicate must match + * it. The cursor is the shared opaque `runsCursor` token — its `runId` field + * carries the delivery id here. + */ + private async listDeliveryRows( + options: ListWebhookDeliveriesOptions + ): Promise { + const queryBuilder = this.options.clickhouse.webhookDeliveries.queryBuilder(); + applyDeliveryFiltersToQueryBuilder(queryBuilder, options); + + const forward = options.page.direction === "forward" || !options.page.direction; + + if (options.page.cursor) { + const decoded = decodeRunsCursor(options.page.cursor); + + if (forward) { + if (decoded.kind === "composite") { + queryBuilder.where( + "(created_at, delivery_id) < (fromUnixTimestamp64Milli({cursorCreatedAt: Int64}), {deliveryId: String})", + { cursorCreatedAt: decoded.createdAt, deliveryId: decoded.runId } + ); + } else { + queryBuilder.where("delivery_id < {deliveryId: String}", { deliveryId: decoded.runId }); + } + queryBuilder.orderBy("created_at DESC, delivery_id DESC"); + } else { + if (decoded.kind === "composite") { + queryBuilder.where( + "(created_at, delivery_id) > (fromUnixTimestamp64Milli({cursorCreatedAt: Int64}), {deliveryId: String})", + { cursorCreatedAt: decoded.createdAt, deliveryId: decoded.runId } + ); + } else { + queryBuilder.where("delivery_id > {deliveryId: String}", { deliveryId: decoded.runId }); + } + queryBuilder.orderBy("created_at ASC, delivery_id ASC"); + } + + queryBuilder.limit(options.page.size + 1); + } else { + // Initial page - no cursor provided + queryBuilder.orderBy("created_at DESC, delivery_id DESC").limit(options.page.size + 1); + } + + const [queryError, result] = await queryBuilder.execute(); + + if (queryError) { + throw queryError; + } + + return result.map((row) => ({ deliveryId: row.delivery_id, createdAt: row.created_at_ms })); + } + + /** + * Turns the (size + 1) result rows into the actual page of rows plus the + * forward/backward cursors. Mirrors ClickHouseRunsRepository.listRunIds; the + * cursor tokens are composite so pagination can't duplicate or skip + * deliveries. Shared by listDeliveryIds and listDeliveries so the hydration + * path can reuse the page rows (and their created_at values) directly. + */ + private buildPage( + rows: DeliveryCursorRow[], + options: ListWebhookDeliveriesOptions + ): { + pageRows: DeliveryCursorRow[]; + pagination: { nextCursor: string | null; previousCursor: string | null }; + } { + // listDeliveryRows fetches one extra row beyond page.size to detect "has more". + const hasMore = rows.length > options.page.size; + + const cursorFor = (row: DeliveryCursorRow | undefined): string | null => + row ? encodeRunsCursor(row.createdAt, row.deliveryId) : null; + + let nextCursor: string | null = null; + let previousCursor: string | null = null; + + const direction = options.page.direction ?? "forward"; + switch (direction) { + case "forward": { + previousCursor = options.page.cursor ? cursorFor(rows.at(0)) : null; + if (hasMore) { + // The next cursor is the last delivery on this page. + nextCursor = cursorFor(rows[options.page.size - 1]); + } + break; + } + case "backward": { + const reversedRows = [...rows].reverse(); + if (hasMore) { + previousCursor = cursorFor(reversedRows.at(1)); + nextCursor = cursorFor(reversedRows.at(options.page.size)); + } else { + // No newer rows, so there's no previous (newer) page. The next + // (older) cursor is the oldest row on this page = rows[0] (rows are + // ASC here). Index by the actual row count, not page.size — on a + // partial page (fewer than page.size rows) page.size-1 overshoots + // and would null the cursor, stranding forward navigation. + nextCursor = cursorFor(rows.at(0)); + } + break; + } + } + + // The page is always the first `page.size` rows of the result. listDeliveryRows + // fetches one extra row only to detect `hasMore`; that extra row is the + // farthest from the cursor in BOTH directions (forward orders DESC, backward + // orders ASC), so it's always the trailing element to drop — never the + // leading one. + const pageRows = rows.slice(0, options.page.size); + + return { pageRows, pagination: { nextCursor, previousCursor } }; + } + + async listDeliveryIds(options: ListWebhookDeliveriesOptions): Promise { + const rows = await this.listDeliveryRows(options); + const { pageRows, pagination } = this.buildPage(rows, options); + + return { deliveryIds: pageRows.map((row) => row.deliveryId), pagination }; + } + + async listDeliveries(options: ListWebhookDeliveriesOptions) { + const rows = await this.listDeliveryRows(options); + const { pageRows, pagination } = this.buildPage(rows, options); + + if (pageRows.length === 0) { + return { deliveries: [], pagination }; + } + + const deliveryIds = pageRows.map((row) => row.deliveryId); + + // PARTITION PRUNING: webhookDelivery is RANGE-partitioned on createdAt. An + // `id IN (...)` query without a createdAt predicate scans every child + // partition, so derive a [min, max] range from the CH page and pass it + // through. This is the one place webhook hydration diverges from runs. + const bounds = createdAtMsBounds(pageRows.map((row) => row.createdAt)); + + // CH gives the ordered id list; Postgres hydrates the full lean rows by PK id. + const deliveries = await this.options.prisma.webhookDelivery.findMany({ + where: { + id: { in: boundedIn(deliveryIds) }, + ...(bounds ? { createdAt: bounds } : {}), + }, + select: DELIVERY_LIST_SELECT, + }); + + // Re-order to CH order (findMany does not preserve `in` order). + const byId = new Map(deliveries.map((d) => [d.id, d])); + let result = deliveryIds + .map((id) => byId.get(id)) + .filter((d): d is (typeof deliveries)[number] => Boolean(d)); + + // ClickHouse is slightly delayed, so re-filter status in memory too. + if (options.statuses && options.statuses.length > 0) { + result = result.filter((d) => options.statuses!.includes(d.status)); + } + + return { deliveries: result, pagination }; + } + + async countDeliveries(options: FilterWebhookDeliveriesOptions) { + const queryBuilder = this.options.clickhouse.webhookDeliveries.countQueryBuilder(); + applyDeliveryFiltersToQueryBuilder(queryBuilder, options); + + const [queryError, result] = await queryBuilder.execute(); + + if (queryError) { + throw queryError; + } + + if (result.length === 0) { + throw new Error("No count rows returned"); + } + + return result[0].count; + } + + async countDeliveriesByEndpoint( + options: CountDeliveriesByEndpointOptions + ): Promise> { + if (options.webhookEndpointIds.length === 0) { + return new Map(); + } + + const queryBuilder = this.options.clickhouse.webhookDeliveries.groupedCountQueryBuilder(); + queryBuilder + .where("organization_id = {organizationId: String}", { + organizationId: options.organizationId, + }) + .where("project_id = {projectId: String}", { projectId: options.projectId }) + .where("environment_id = {environmentId: String}", { environmentId: options.environmentId }) + .where("webhook_endpoint_id IN {webhookEndpointIds: Array(String)}", { + webhookEndpointIds: options.webhookEndpointIds, + }) + .where("created_at >= fromUnixTimestamp64Milli({period: Int64})", { + period: Date.now() - options.period, + }) + .groupBy("webhook_endpoint_id"); + + const [queryError, rows] = await queryBuilder.execute(); + + if (queryError) { + throw queryError; + } + + const counts = new Map(); + for (const row of rows) { + counts.set(row.webhook_endpoint_id, row.count); + } + return counts; + } + + /** + * A point lookup: pure Postgres, never ClickHouse. `friendlyId` is `whd_` + the row id, so we + * strip the prefix and hit the composite-PK index (scoped by environment). ClickHouse is for + * aggregations and for filtering/ordering a list into ids, never for selecting a row's columns. + * + * `WebhookDelivery` is RANGE-partitioned on `createdAt`, so a bare `id` predicate can't prune and + * probes every partition. The delivery id is time-encoded (see `WebhookDeliveryId`) with the same + * timestamp the engine stores as `createdAt`, so we recover it from the id and add it as an exact + * predicate to prune to the row's partition. + */ + async getDelivery(options: GetWebhookDeliveryOptions): Promise { + const id = WebhookDeliveryId.toId(options.friendlyId); + const createdAt = WebhookDeliveryId.parseTimestamp(options.friendlyId); + + return this.options.prisma.webhookDelivery.findFirst({ + where: { + id, + runtimeEnvironmentId: options.environmentId, + ...(createdAt ? { createdAt } : {}), + }, + select: DELIVERY_DETAIL_SELECT, + }); + } + + /** + * Hydrate a known set of deliveries by friendlyId. Pure Postgres: the caller (the live poll) + * already has the ids, so there is nothing for ClickHouse to filter or order. + * + * The ids are time-encoded (see `WebhookDeliveryId`), so we bound the query to the span of their + * mint timestamps, which equal the rows' `createdAt`. That prunes the RANGE-partitioned table to + * the visible page's few days instead of probing all of retention on every poll. + */ + async getDeliveriesByFriendlyIds( + options: GetDeliveriesByFriendlyIdsOptions + ): Promise { + const ids = options.friendlyIds.map((friendlyId) => WebhookDeliveryId.toId(friendlyId)); + if (ids.length === 0) return []; + + const bounds = deliveryIdsCreatedAtBounds(options.friendlyIds); + + return this.options.prisma.webhookDelivery.findMany({ + where: { + id: { in: boundedIn(ids) }, + runtimeEnvironmentId: options.environmentId, + ...(bounds ? { createdAt: bounds } : {}), + }, + select: DELIVERY_LIST_SELECT, + }); + } +} + +function applyDeliveryFiltersToQueryBuilder( + queryBuilder: ClickhouseQueryBuilder, + options: FilterWebhookDeliveriesOptions +) { + queryBuilder + .where("organization_id = {organizationId: String}", { + organizationId: options.organizationId, + }) + .where("project_id = {projectId: String}", { projectId: options.projectId }) + .where("environment_id = {environmentId: String}", { environmentId: options.environmentId }); + + if (options.webhookEndpointId) { + queryBuilder.where("webhook_endpoint_id = {webhookEndpointId: String}", { + webhookEndpointId: options.webhookEndpointId, + }); + } + + if (options.webhookEndpointIds && options.webhookEndpointIds.length > 0) { + queryBuilder.where("webhook_endpoint_id IN {webhookEndpointIds: Array(String)}", { + webhookEndpointIds: options.webhookEndpointIds, + }); + } + + if (options.deliveryId) { + queryBuilder.where( + "(friendly_id = {deliveryId: String} OR external_delivery_id = {deliveryId: String})", + { deliveryId: options.deliveryId } + ); + } + + if (options.runId) { + queryBuilder.where("run_id = {runId: String}", { runId: options.runId }); + } + + if (options.statuses && options.statuses.length > 0) { + queryBuilder.where("status IN {statuses: Array(String)}", { statuses: options.statuses }); + } + + if (options.isTest !== undefined) { + queryBuilder.where("is_test = {isTest: UInt8}", { isTest: options.isTest ? 1 : 0 }); + } + + // PARTITION PRUNING: the list MUST carry a created_at range. + if (options.period) { + queryBuilder.where("created_at >= fromUnixTimestamp64Milli({period: Int64})", { + period: new Date(Date.now() - options.period).getTime(), + }); + } + if (options.from) { + queryBuilder.where("created_at >= fromUnixTimestamp64Milli({from: Int64})", { + from: options.from, + }); + } + if (options.to) { + queryBuilder.where("created_at <= fromUnixTimestamp64Milli({to: Int64})", { to: options.to }); + } +} diff --git a/apps/webapp/app/services/webhookDeliveriesRepository/deliveryIdBounds.ts b/apps/webapp/app/services/webhookDeliveriesRepository/deliveryIdBounds.ts new file mode 100644 index 0000000000..31d2c8f86c --- /dev/null +++ b/apps/webapp/app/services/webhookDeliveriesRepository/deliveryIdBounds.ts @@ -0,0 +1,55 @@ +import { WebhookDeliveryId } from "@trigger.dev/core/v3/isomorphic"; + +/** + * Single-pass min/max over a set of `createdAt` timestamps (unix ms), returned as a Prisma + * `{ gte, lte }` range for partition-pruning the RANGE-partitioned `WebhookDelivery` table. + * + * Avoids `Math.min(...spread)` / `Math.max(...spread)`: the spread builds an O(n) argument list and + * throws "Maximum call stack size exceeded" once the array is large (~1e5+ elements). Returns + * `undefined` for an empty set, so the caller adds no `createdAt` predicate. + */ +export function createdAtMsBounds(msValues: number[]): { gte: Date; lte: Date } | undefined { + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + + for (const ms of msValues) { + if (ms < min) min = ms; + if (ms > max) max = ms; + } + + if (min === Number.POSITIVE_INFINITY) return undefined; + return { gte: new Date(min), lte: new Date(max) }; +} + +/** + * Compute the `createdAt` span covering a set of webhook delivery friendlyIds, for partition-pruning + * a lookup by id on the RANGE-partitioned `WebhookDelivery` table. + * + * A delivery id body is `base32hex(big-endian ms timestamp then random bytes)`, and base32hex is + * order-preserving, so lexical id order equals chronological order. The earliest and latest + * timestamps therefore sit at the lexical extremes, and we recover the span by decoding only those + * two ids instead of all N. The embedded timestamp equals the row's `createdAt`, so `[gte, lte]` + * covers every row in the set exactly. + * + * Returns `undefined` for an empty set, or if an extreme id fails to decode, so the caller adds no + * `createdAt` predicate and the lookup stays correct (just unpruned). + */ +export function deliveryIdsCreatedAtBounds( + friendlyIds: string[] +): { gte: Date; lte: Date } | undefined { + if (friendlyIds.length === 0) return undefined; + + let minBody = WebhookDeliveryId.toId(friendlyIds[0]!); + let maxBody = minBody; + for (let i = 1; i < friendlyIds.length; i++) { + const body = WebhookDeliveryId.toId(friendlyIds[i]!); + if (body < minBody) minBody = body; + if (body > maxBody) maxBody = body; + } + + const gte = WebhookDeliveryId.parseTimestamp(minBody); + const lte = WebhookDeliveryId.parseTimestamp(maxBody); + if (!gte || !lte) return undefined; + + return { gte, lte }; +} diff --git a/apps/webapp/app/services/webhookDeliveriesRepository/webhookDeliveriesRepository.server.ts b/apps/webapp/app/services/webhookDeliveriesRepository/webhookDeliveriesRepository.server.ts new file mode 100644 index 0000000000..32a9b4923b --- /dev/null +++ b/apps/webapp/app/services/webhookDeliveriesRepository/webhookDeliveriesRepository.server.ts @@ -0,0 +1,226 @@ +import { type ClickHouse } from "@internal/clickhouse"; +import { type Tracer } from "@internal/tracing"; +import { type Logger, type LogLevel } from "@trigger.dev/core/logger"; +import { type Prisma, type WebhookDeliveryStatus } from "@trigger.dev/database"; +import { type WebhookReplicaDatabase } from "~/db.server"; +import { startActiveSpan } from "~/v3/tracer.server"; +import { ClickHouseWebhookDeliveriesRepository } from "./clickhouseWebhookDeliveriesRepository.server"; + +export type WebhookDeliveriesRepositoryOptions = { + clickhouse: ClickHouse; + prisma: WebhookReplicaDatabase; + logger?: Logger; + logLevel?: LogLevel; + tracer?: Tracer; +}; + +export type FilterWebhookDeliveriesOptions = { + organizationId: string; + projectId: string; + environmentId: string; + webhookEndpointId?: string; + webhookEndpointIds?: string[]; // webhook (handler) filter, resolved to endpoint ids by the presenter + deliveryId?: string; // matches the delivery friendlyId OR external delivery id + runId?: string; // INTERNAL run id (presenter resolves friendly -> internal) + statuses?: WebhookDeliveryStatus[]; + isTest?: boolean; + period?: number; + from?: number; + to?: number; +}; + +export type ListWebhookDeliveriesOptions = FilterWebhookDeliveriesOptions & { + page: { size: number; cursor?: string; direction?: "forward" | "backward" }; +}; + +export type WebhookDeliveryIdsPage = { + deliveryIds: string[]; + pagination: { nextCursor: string | null; previousCursor: string | null }; +}; + +export type ListedWebhookDelivery = Prisma.WebhookDeliveryGetPayload<{ + select: { + id: true; + friendlyId: true; + webhookEndpointId: true; + runtimeEnvironmentId: true; + status: true; + isTest: true; + externalDeliveryId: true; + runId: true; + createdAt: true; + processedAt: true; + errorMessage: true; + }; +}>; + +// The detail view selects more than the list, including the size-capped +// `parsedEvent` snapshot (which the list omits). +export type DetailedWebhookDelivery = Prisma.WebhookDeliveryGetPayload<{ + select: { + id: true; + friendlyId: true; + webhookEndpointId: true; + runtimeEnvironmentId: true; + environmentType: true; + status: true; + externalDeliveryId: true; + idempotencyKey: true; + runId: true; + rawBodyHash: true; + parsedEvent: true; + headers: true; + errorMessage: true; + filterReason: true; + createdAt: true; + updatedAt: true; + processedAt: true; + }; +}>; + +export type GetWebhookDeliveryOptions = { + organizationId: string; + projectId: string; + environmentId: string; + friendlyId: string; +}; + +export type GetDeliveriesByFriendlyIdsOptions = { + organizationId: string; + projectId: string; + environmentId: string; + friendlyIds: string[]; +}; + +export type CountDeliveriesByEndpointOptions = { + organizationId: string; + projectId: string; + environmentId: string; + webhookEndpointIds: string[]; + period: number; // lookback window in ms +}; + +export interface IWebhookDeliveriesRepository { + name: string; + listDeliveryIds(options: ListWebhookDeliveriesOptions): Promise; + listDeliveries(options: ListWebhookDeliveriesOptions): Promise<{ + deliveries: ListedWebhookDelivery[]; + pagination: { nextCursor: string | null; previousCursor: string | null }; + }>; + getDeliveriesByFriendlyIds( + options: GetDeliveriesByFriendlyIdsOptions + ): Promise; + countDeliveries(options: FilterWebhookDeliveriesOptions): Promise; + // One grouped query for many endpoints -> Map (avoids an N+1 of count queries). + countDeliveriesByEndpoint( + options: CountDeliveriesByEndpointOptions + ): Promise>; + getDelivery(options: GetWebhookDeliveryOptions): Promise; +} + +export class WebhookDeliveriesRepository implements IWebhookDeliveriesRepository { + private readonly clickHouseRepository: ClickHouseWebhookDeliveriesRepository; + + constructor(private readonly options: WebhookDeliveriesRepositoryOptions) { + this.clickHouseRepository = new ClickHouseWebhookDeliveriesRepository(options); + } + + get name() { + return this.clickHouseRepository.name; + } + + async listDeliveryIds(options: ListWebhookDeliveriesOptions) { + return startActiveSpan( + "webhookDeliveriesRepository.listDeliveryIds", + async () => this.clickHouseRepository.listDeliveryIds(options), + { + attributes: { + "repository.name": "clickhouse", + organizationId: options.organizationId, + projectId: options.projectId, + environmentId: options.environmentId, + }, + } + ); + } + + async listDeliveries(options: ListWebhookDeliveriesOptions) { + return startActiveSpan( + "webhookDeliveriesRepository.listDeliveries", + async () => this.clickHouseRepository.listDeliveries(options), + { + attributes: { + "repository.name": "clickhouse", + organizationId: options.organizationId, + projectId: options.projectId, + environmentId: options.environmentId, + }, + } + ); + } + + async getDeliveriesByFriendlyIds(options: GetDeliveriesByFriendlyIdsOptions) { + return startActiveSpan( + "webhookDeliveriesRepository.getDeliveriesByFriendlyIds", + async () => this.clickHouseRepository.getDeliveriesByFriendlyIds(options), + { + attributes: { + "repository.name": "clickhouse", + organizationId: options.organizationId, + projectId: options.projectId, + environmentId: options.environmentId, + }, + } + ); + } + + async countDeliveries(options: FilterWebhookDeliveriesOptions) { + return startActiveSpan( + "webhookDeliveriesRepository.countDeliveries", + async () => this.clickHouseRepository.countDeliveries(options), + { + attributes: { + "repository.name": "clickhouse", + organizationId: options.organizationId, + projectId: options.projectId, + environmentId: options.environmentId, + }, + } + ); + } + + async countDeliveriesByEndpoint(options: CountDeliveriesByEndpointOptions) { + return startActiveSpan( + "webhookDeliveriesRepository.countDeliveriesByEndpoint", + async () => this.clickHouseRepository.countDeliveriesByEndpoint(options), + { + attributes: { + "repository.name": "clickhouse", + organizationId: options.organizationId, + projectId: options.projectId, + environmentId: options.environmentId, + }, + } + ); + } + + async getDelivery(options: GetWebhookDeliveryOptions) { + return startActiveSpan( + "webhookDeliveriesRepository.getDelivery", + async () => this.clickHouseRepository.getDelivery(options), + { + attributes: { + "repository.name": "clickhouse", + organizationId: options.organizationId, + projectId: options.projectId, + environmentId: options.environmentId, + }, + } + ); + } +} + +// Inline-constructed per request (NOT a module-level singleton), like RunsRepository. +export function webhookDeliveriesRepository(opts: WebhookDeliveriesRepositoryOptions) { + return new WebhookDeliveriesRepository(opts); +} diff --git a/apps/webapp/app/services/webhookIngressIpRateLimit.server.ts b/apps/webapp/app/services/webhookIngressIpRateLimit.server.ts new file mode 100644 index 0000000000..03499be206 --- /dev/null +++ b/apps/webapp/app/services/webhookIngressIpRateLimit.server.ts @@ -0,0 +1,31 @@ +import type { RequestHandler } from "express"; +import { Ratelimit } from "@upstash/ratelimit"; +import { env } from "~/env.server"; +import { logger } from "./logger.server"; +import { RateLimiter, type Duration } from "./rateLimiter.server"; + +const ipLimiter = new RateLimiter({ + keyPrefix: "webhook-ingress-ip", + limiter: Ratelimit.fixedWindow( + env.WEBHOOK_INGRESS_IP_RATE_LIMIT_TOKENS, + env.WEBHOOK_INGRESS_IP_RATE_LIMIT_WINDOW as Duration + ), +}); + +// Coarse per-IP gate mounted in server.ts ahead of the Remix handler. The +// per-opaqueId limiter (webhookIngressRateLimit.server) is the real protection. +export const webhookIngressIpRateLimiter: RequestHandler = async (req, res, next) => { + if (!req.path.startsWith("/webhooks/v1/ingest/")) return next(); + const ip = + (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() || req.ip || "unknown"; + try { + const { success } = await ipLimiter.limit(ip); + if (!success) { + res.status(429).json({ error: "Too many requests" }); + return; + } + } catch (error) { + logger.warn("webhookIngressIpRateLimiter: limiter error, allowing request", { error }); + } + next(); +}; diff --git a/apps/webapp/app/services/webhookIngressRateLimit.server.ts b/apps/webapp/app/services/webhookIngressRateLimit.server.ts new file mode 100644 index 0000000000..a55e7e831d --- /dev/null +++ b/apps/webapp/app/services/webhookIngressRateLimit.server.ts @@ -0,0 +1,16 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { env } from "~/env.server"; +import { RateLimiter, type Duration } from "./rateLimiter.server"; + +// Per-opaqueId fixed-window limiter for the unauthenticated webhook ingress +// route. apiRateLimiter/engineRateLimiter only match /api and /engine and key +// off the auth header, so they never see this request; this is the real +// protection (the per-IP limiter in server.ts is coarse). +export const webhookIngressRateLimiter = new RateLimiter({ + keyPrefix: "webhook-ingress", + limiter: Ratelimit.fixedWindow( + env.WEBHOOK_INGRESS_RATE_LIMIT_TOKENS, + env.WEBHOOK_INGRESS_RATE_LIMIT_WINDOW as Duration + ), + logFailure: true, +}); diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css index 19f3f5b7d6..6a303778fd 100644 --- a/apps/webapp/app/tailwind.css +++ b/apps/webapp/app/tailwind.css @@ -209,6 +209,7 @@ --color-aiMetrics: var(--color-green-500); --color-errors: var(--color-amber-500); --color-agents: var(--color-purple-500); + --color-webhooks: var(--color-teal-500); --color-sessions: var(--color-pink-500); --color-playgrounds: var(--color-fuchsia-500); --color-models: var(--color-violet-500); diff --git a/apps/webapp/app/utils/deeplinkPages.ts b/apps/webapp/app/utils/deeplinkPages.ts index bd626690f7..898816f25e 100644 --- a/apps/webapp/app/utils/deeplinkPages.ts +++ b/apps/webapp/app/utils/deeplinkPages.ts @@ -35,6 +35,7 @@ export const ENV_PAGE_TARGETS: ReadonlyMap = new Map([ ["tasks", { landing: "", prefix: "tasks" }], ["test", page("test")], ["waitpoints", { landing: "waitpoints/tokens", prefix: "waitpoints/tokens" }], + ["webhooks", page("webhooks")], ]); export const DEEPLINK_PATH_PREFIX = "/_"; diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 33b688ac48..59cba15001 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -402,6 +402,52 @@ export function v3AgentTaskPath( )}`; } +export function v3WebhooksPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + // Top-level Webhooks section landing = the cross-endpoint deliveries list. + return `${v3EnvironmentPath(organization, project, environment)}/webhooks`; +} + +export function v3WebhookTaskPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath, + webhookSlug: string +) { + return `${v3EnvironmentPath(organization, project, environment)}/webhooks/${encodeURIComponent( + webhookSlug + )}`; +} + +export function v3WebhookDeliveryPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath, + deliveryFriendlyId: string +) { + return `${v3EnvironmentPath( + organization, + project, + environment + )}/webhooks/deliveries/${encodeURIComponent(deliveryFriendlyId)}`; +} + +export function v3WebhookEndpointPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath, + endpointFriendlyId: string +) { + return `${v3EnvironmentPath( + organization, + project, + environment + )}/webhooks/endpoints/${encodeURIComponent(endpointFriendlyId)}`; +} + export function v3StandardTaskPath( organization: OrgForPath, project: ProjectForPath, diff --git a/apps/webapp/app/utils/readBodyWithCap.server.ts b/apps/webapp/app/utils/readBodyWithCap.server.ts new file mode 100644 index 0000000000..0c5a865724 --- /dev/null +++ b/apps/webapp/app/utils/readBodyWithCap.server.ts @@ -0,0 +1,40 @@ +// Read a request body, aborting as soon as the accumulated bytes exceed `limitBytes`. A chunked +// upload can omit or understate Content-Length, so reading the stream incrementally (instead of +// request.arrayBuffer(), which buffers the whole stream first) caps the memory a single request can +// force us to hold before rejection. Returns null when the body is over the limit. +export async function readBodyWithCap( + request: Request, + limitBytes: number +): Promise { + if (!request.body) { + return new Uint8Array(0); + } + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > limitBytes) { + await reader.cancel(); + return null; + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out; +} diff --git a/apps/webapp/app/utils/webhookIngressUrl.server.ts b/apps/webapp/app/utils/webhookIngressUrl.server.ts new file mode 100644 index 0000000000..8789fd7a2b --- /dev/null +++ b/apps/webapp/app/utils/webhookIngressUrl.server.ts @@ -0,0 +1,11 @@ +import { env } from "~/env.server"; + +// Public origin webhook providers POST to. A dedicated WEBHOOK_INGRESS_ORIGIN (e.g. +// https://webhook.trigger.dev) takes precedence; otherwise it rides the API/app origin. +export function webhookIngressOrigin(): string { + return env.WEBHOOK_INGRESS_ORIGIN ?? env.API_ORIGIN ?? env.APP_ORIGIN; +} + +export function webhookIngressUrl(opaqueId: string): string { + return `${webhookIngressOrigin()}/webhooks/v1/ingest/${opaqueId}`; +} diff --git a/apps/webapp/app/v3/featureFlags.ts b/apps/webapp/app/v3/featureFlags.ts index d7ab026b12..6b01c5adfa 100644 --- a/apps/webapp/app/v3/featureFlags.ts +++ b/apps/webapp/app/v3/featureFlags.ts @@ -5,6 +5,7 @@ export const FEATURE_FLAG = { taskEventRepository: "taskEventRepository", hasQueryAccess: "hasQueryAccess", hasLogsPageAccess: "hasLogsPageAccess", + hasWebhooksAccess: "hasWebhooksAccess", hasAiAccess: "hasAiAccess", hasDashboardAgentAccess: "hasDashboardAgentAccess", dashboardAgentTurnEvalsEnabled: "dashboardAgentTurnEvalsEnabled", @@ -41,6 +42,7 @@ export const FeatureFlagCatalog = { [FEATURE_FLAG.taskEventRepository]: z.enum(["clickhouse", "clickhouse_v2", "postgres"]), [FEATURE_FLAG.hasQueryAccess]: z.coerce.boolean(), [FEATURE_FLAG.hasLogsPageAccess]: z.coerce.boolean(), + [FEATURE_FLAG.hasWebhooksAccess]: z.coerce.boolean(), [FEATURE_FLAG.hasAiAccess]: z.coerce.boolean(), // Gates the in-dashboard AI agent panel. Controllable globally and per-org // (org wins). Defaults off via DASHBOARD_AGENT_ENABLED. diff --git a/apps/webapp/app/v3/models/workerDeployment.server.ts b/apps/webapp/app/v3/models/workerDeployment.server.ts index 48995bf2b2..56f880f7b8 100644 --- a/apps/webapp/app/v3/models/workerDeployment.server.ts +++ b/apps/webapp/app/v3/models/workerDeployment.server.ts @@ -209,6 +209,15 @@ export async function findCurrentWorkerFromEnvironment( where: { runtimeEnvironmentId: environment.id, }, + select: { + id: true, + friendlyId: true, + version: true, + sdkVersion: true, + cliVersion: true, + supportsLazyAttempts: true, + engine: true, + }, orderBy: { createdAt: "desc", }, diff --git a/apps/webapp/app/v3/runOpsMigration/controlPlaneCache.server.ts b/apps/webapp/app/v3/runOpsMigration/controlPlaneCache.server.ts index 01fd205030..fdfd6d92ce 100644 --- a/apps/webapp/app/v3/runOpsMigration/controlPlaneCache.server.ts +++ b/apps/webapp/app/v3/runOpsMigration/controlPlaneCache.server.ts @@ -1,11 +1,4 @@ -import type { - BackgroundWorker, - BackgroundWorkerTask, - Prisma, - RuntimeEnvironmentType, - TaskQueue, - WorkerDeployment, -} from "@trigger.dev/database"; +import type { BackgroundWorker, Prisma, RuntimeEnvironmentType } from "@trigger.dev/database"; import { BoundedTtlCache } from "~/services/realtime/boundedTtlCache"; import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment"; @@ -49,12 +42,67 @@ export type ResolvedEnv = { concurrencyLimitBurstFactor: Prisma.Decimal; }; +/** + * The BackgroundWorkerTask columns the dequeue resolve path reads. Mirrors run-engine's + * `ResolvedWorkerTask` exactly. The unread heavy JSON columns (`payloadSchema`, `config`, + * `queueConfig`, `description`) are dropped so this hot control-plane read stops shipping + * ~62KB/query (and each cached entry stays small); `machineConfig`/`retryConfig` are read + * at dequeue and stay. + */ +export type ResolvedWorkerTask = { + id: string; + slug: string; + machineConfig: Prisma.JsonValue | null; + retryConfig: Prisma.JsonValue | null; + maxDurationInSeconds: number | null; +}; + +/** The `select` that yields a `ResolvedWorkerTask`. */ +export const resolvedWorkerTaskSelect = { + id: true, + slug: true, + machineConfig: true, + retryConfig: true, + maxDurationInSeconds: true, +} satisfies Prisma.BackgroundWorkerTaskSelect; + +/** Mirrors run-engine's `ResolvedTaskQueue` exactly. `id` + `name` (the matcher keys on both). */ +export type ResolvedTaskQueue = { + id: string; + name: string; +}; + +/** The `select` that yields a `ResolvedTaskQueue`. */ +export const resolvedTaskQueueSelect = { + id: true, + name: true, +} satisfies Prisma.TaskQueueSelect; + +/** + * Mirrors run-engine's `ResolvedWorkerDeployment` exactly. Drops the unread heavy JSON columns + * (`externalBuildData`, `buildServerMetadata`, `errorData`, `git`) from this single-row read. + */ +export type ResolvedWorkerDeployment = { + id: string; + friendlyId: string; + imageReference: string | null; + imagePlatform: string; +}; + +/** The `select` that yields a `ResolvedWorkerDeployment`. */ +export const resolvedWorkerDeploymentSelect = { + id: true, + friendlyId: true, + imageReference: true, + imagePlatform: true, +} satisfies Prisma.WorkerDeploymentSelect; + /** Mirrors `WorkerDeploymentWithWorkerTasks` in `dequeueSystem.ts` exactly. */ export type ResolvedWorkerVersion = { worker: BackgroundWorker; - tasks: BackgroundWorkerTask[]; - queues: TaskQueue[]; - deployment: WorkerDeployment | null; + tasks: ResolvedWorkerTask[]; + queues: ResolvedTaskQueue[]; + deployment: ResolvedWorkerDeployment | null; }; // The canonical authenticated-environment shape (slug/type/project/organization/orgMember/…) @@ -180,7 +228,6 @@ export class ControlPlaneCache { this.#bump(`env:${id}`); } - // worker version: key = `${environmentId}:${backgroundWorkerId ?? "current"}` getWorkerVersion(key: string): (ResolvedWorkerVersion | null) | undefined { return this.#read(this.#version, `version:${key}`); } diff --git a/apps/webapp/app/v3/runOpsMigration/controlPlaneResolver.dispatchFreshness.test.ts b/apps/webapp/app/v3/runOpsMigration/controlPlaneResolver.dispatchFreshness.test.ts new file mode 100644 index 0000000000..af1d30288e --- /dev/null +++ b/apps/webapp/app/v3/runOpsMigration/controlPlaneResolver.dispatchFreshness.test.ts @@ -0,0 +1,320 @@ +/** + * TRI-13291: the dequeue worker-version resolve reads the currently-promoted worker fresh on every + * call. The previous env-keyed TTL cache served the superseded worker for up to the TTL after a + * deployment promotion / dev re-register (nothing invalidated it); it has been removed, so a + * promotion or re-register is reflected on the very next resolve. The DB is never mocked: every + * query runs against the real Postgres container. + */ +import { postgresTest } from "@internal/testcontainers"; +import { describe, expect } from "vitest"; +import type { PrismaClient, PrismaReplicaClient } from "@trigger.dev/database"; +import { CURRENT_DEPLOYMENT_LABEL } from "@trigger.dev/core/v3/isomorphic"; +import { ControlPlaneCache } from "./controlPlaneCache.server"; +import { ControlPlaneResolver } from "./controlPlaneResolver.server"; + +let n = 0; + +async function seedEnv(prisma: PrismaClient, type: "PRODUCTION" | "DEVELOPMENT" = "PRODUCTION") { + const s = n++; + const organization = await prisma.organization.create({ + data: { title: `Org ${s}`, slug: `org-${s}` }, + }); + const project = await prisma.project.create({ + data: { + name: `P ${s}`, + slug: `p-${s}`, + externalRef: `proj_${s}`, + organizationId: organization.id, + }, + }); + const environment = await prisma.runtimeEnvironment.create({ + data: { + type, + slug: `env-${s}`, + projectId: project.id, + organizationId: organization.id, + apiKey: `tr_${s}`, + pkApiKey: `pk_${s}`, + shortcode: `sc_${s}`, + }, + }); + return { organization, project, environment }; +} + +async function seedWorkerWithTask( + prisma: PrismaClient, + ctx: { projectId: string; runtimeEnvironmentId: string }, + version: string, + taskSlug: string +) { + const s = n++; + const worker = await prisma.backgroundWorker.create({ + data: { + friendlyId: `worker_${s}`, + version, + contentHash: `hash_${s}`, + projectId: ctx.projectId, + runtimeEnvironmentId: ctx.runtimeEnvironmentId, + metadata: {}, + }, + }); + await prisma.backgroundWorkerTask.create({ + data: { + friendlyId: `task_${s}`, + slug: taskSlug, + filePath: `src/${taskSlug}.ts`, + workerId: worker.id, + projectId: ctx.projectId, + runtimeEnvironmentId: ctx.runtimeEnvironmentId, + }, + }); + return worker; +} + +async function seedManagedDeployment( + prisma: PrismaClient, + ctx: { projectId: string; environmentId: string; workerId: string }, + version: string +) { + const s = n++; + return prisma.workerDeployment.create({ + data: { + friendlyId: `deploy_${s}`, + shortCode: `dep${s}`, + contentHash: `dhash_${s}`, + version, + type: "MANAGED", + status: "DEPLOYED", + projectId: ctx.projectId, + environmentId: ctx.environmentId, + workerId: ctx.workerId, + }, + }); +} + +async function promote(prisma: PrismaClient, environmentId: string, deploymentId: string) { + await prisma.workerDeploymentPromotion.upsert({ + where: { environmentId_label: { environmentId, label: CURRENT_DEPLOYMENT_LABEL } }, + create: { deploymentId, environmentId, label: CURRENT_DEPLOYMENT_LABEL }, + update: { deploymentId }, + }); +} + +function makeResolver(prisma: PrismaClient, freshRead = true) { + return new ControlPlaneResolver({ + controlPlanePrimary: prisma, + controlPlaneReplica: prisma as unknown as PrismaReplicaClient, + cache: new ControlPlaneCache(), + splitEnabled: () => true, + workerVersionFreshReadEnabled: () => freshRead, + }); +} + +describe("ControlPlaneResolver worker-version dispatch freshness (TRI-13291)", () => { + postgresTest( + "deployed :current: resolves the newly-promoted worker on the next call (no stale cache)", + async ({ prisma }) => { + const { project, environment } = await seedEnv(prisma); + const ctx = { projectId: project.id, runtimeEnvironmentId: environment.id }; + + const workerV1 = await seedWorkerWithTask(prisma, ctx, "20240101.1", "task-a"); + const workerV2 = await seedWorkerWithTask(prisma, ctx, "20240101.2", "task-a"); + const depV1 = await seedManagedDeployment( + prisma, + { projectId: project.id, environmentId: environment.id, workerId: workerV1.id }, + "20240101.1" + ); + const depV2 = await seedManagedDeployment( + prisma, + { projectId: project.id, environmentId: environment.id, workerId: workerV2.id }, + "20240101.2" + ); + + await promote(prisma, environment.id, depV1.id); + + const resolver = makeResolver(prisma); + + const first = await resolver.resolveWorkerVersion({ + environmentId: environment.id, + type: "PRODUCTION", + taskIdentifier: "task-a", + }); + expect(first?.worker.id).toBe(workerV1.id); + expect(first?.worker.version).toBe("20240101.1"); + + await promote(prisma, environment.id, depV2.id); + + const afterPromotion = await resolver.resolveWorkerVersion({ + environmentId: environment.id, + type: "PRODUCTION", + taskIdentifier: "task-a", + }); + expect(afterPromotion?.worker.id).toBe(workerV2.id); + expect(afterPromotion?.worker.version).toBe("20240101.2"); + }, + 30_000 + ); + + postgresTest( + "deployed :current: resolves the rolled-back worker on the next call", + async ({ prisma }) => { + const { project, environment } = await seedEnv(prisma); + const ctx = { projectId: project.id, runtimeEnvironmentId: environment.id }; + + const workerV1 = await seedWorkerWithTask(prisma, ctx, "20240101.1", "task-a"); + const workerV2 = await seedWorkerWithTask(prisma, ctx, "20240101.2", "task-a"); + const depV1 = await seedManagedDeployment( + prisma, + { projectId: project.id, environmentId: environment.id, workerId: workerV1.id }, + "20240101.1" + ); + const depV2 = await seedManagedDeployment( + prisma, + { projectId: project.id, environmentId: environment.id, workerId: workerV2.id }, + "20240101.2" + ); + + await promote(prisma, environment.id, depV2.id); + const resolver = makeResolver(prisma); + const onV2 = await resolver.resolveWorkerVersion({ + environmentId: environment.id, + type: "PRODUCTION", + taskIdentifier: "task-a", + }); + expect(onV2?.worker.id).toBe(workerV2.id); + + await promote(prisma, environment.id, depV1.id); + const rolledBack = await resolver.resolveWorkerVersion({ + environmentId: environment.id, + type: "PRODUCTION", + taskIdentifier: "task-a", + }); + expect(rolledBack?.worker.id).toBe(workerV1.id); + }, + 30_000 + ); + + postgresTest( + "dev :current: resolves the re-registered worker on the next call", + async ({ prisma }) => { + const { project, environment } = await seedEnv(prisma, "DEVELOPMENT"); + const ctx = { projectId: project.id, runtimeEnvironmentId: environment.id }; + + const workerV1 = await seedWorkerWithTask(prisma, ctx, "20240101.1", "task-a"); + + const resolver = makeResolver(prisma); + const first = await resolver.resolveWorkerVersion({ + environmentId: environment.id, + type: "DEVELOPMENT", + taskIdentifier: "task-a", + }); + expect(first?.worker.id).toBe(workerV1.id); + + const workerV2 = await seedWorkerWithTask(prisma, ctx, "20240101.2", "task-a"); + + const afterReregister = await resolver.resolveWorkerVersion({ + environmentId: environment.id, + type: "DEVELOPMENT", + taskIdentifier: "task-a", + }); + expect(afterReregister?.worker.id).toBe(workerV2.id); + }, + 30_000 + ); + + postgresTest( + "resolves only the matched task and queue (per-slug/per-queue dispatch)", + async ({ prisma }) => { + const { project, environment } = await seedEnv(prisma); + const ctx = { projectId: project.id, runtimeEnvironmentId: environment.id }; + + const worker = await seedWorkerWithTask(prisma, ctx, "20240101.1", "task-a"); + await prisma.backgroundWorkerTask.create({ + data: { + friendlyId: `task_extra_${n++}`, + slug: "task-b", + filePath: "src/task-b.ts", + workerId: worker.id, + projectId: project.id, + runtimeEnvironmentId: environment.id, + }, + }); + const qA = await prisma.taskQueue.create({ + data: { + friendlyId: `q_${n++}`, + name: "queue-a", + runtimeEnvironmentId: environment.id, + projectId: project.id, + workers: { connect: { id: worker.id } }, + }, + }); + await prisma.taskQueue.create({ + data: { + friendlyId: `q_${n++}`, + name: "queue-b", + runtimeEnvironmentId: environment.id, + projectId: project.id, + workers: { connect: { id: worker.id } }, + }, + }); + const dep = await seedManagedDeployment( + prisma, + { projectId: project.id, environmentId: environment.id, workerId: worker.id }, + "20240101.1" + ); + await promote(prisma, environment.id, dep.id); + + const resolved = await makeResolver(prisma).resolveWorkerVersion({ + environmentId: environment.id, + type: "PRODUCTION", + taskIdentifier: "task-a", + queue: { name: "queue-a" }, + }); + + expect(resolved?.tasks.map((t) => t.slug)).toEqual(["task-a"]); + expect(resolved?.queues.map((q) => q.id)).toEqual([qA.id]); + }, + 30_000 + ); + + postgresTest( + "kill-switch off falls back to the legacy env-keyed cache (serves the pre-promotion worker)", + async ({ prisma }) => { + const { project, environment } = await seedEnv(prisma); + const ctx = { projectId: project.id, runtimeEnvironmentId: environment.id }; + + const workerV1 = await seedWorkerWithTask(prisma, ctx, "20240101.1", "task-a"); + const workerV2 = await seedWorkerWithTask(prisma, ctx, "20240101.2", "task-a"); + const depV1 = await seedManagedDeployment( + prisma, + { projectId: project.id, environmentId: environment.id, workerId: workerV1.id }, + "20240101.1" + ); + const depV2 = await seedManagedDeployment( + prisma, + { projectId: project.id, environmentId: environment.id, workerId: workerV2.id }, + "20240101.2" + ); + + await promote(prisma, environment.id, depV1.id); + + const resolver = makeResolver(prisma, false); + const first = await resolver.resolveWorkerVersion({ + environmentId: environment.id, + type: "PRODUCTION", + taskIdentifier: "task-a", + }); + expect(first?.worker.id).toBe(workerV1.id); + + await promote(prisma, environment.id, depV2.id); + + const afterPromotion = await resolver.resolveWorkerVersion({ + environmentId: environment.id, + type: "PRODUCTION", + taskIdentifier: "task-a", + }); + expect(afterPromotion?.worker.id).toBe(workerV1.id); + }, + 30_000 + ); +}); diff --git a/apps/webapp/app/v3/runOpsMigration/controlPlaneResolver.server.ts b/apps/webapp/app/v3/runOpsMigration/controlPlaneResolver.server.ts index 97d7a93ac4..4a92b227a8 100644 --- a/apps/webapp/app/v3/runOpsMigration/controlPlaneResolver.server.ts +++ b/apps/webapp/app/v3/runOpsMigration/controlPlaneResolver.server.ts @@ -10,6 +10,9 @@ import { ControlPlaneCache, DEFAULT_CP_CACHE_MAX_ENTRIES, DEFAULT_CP_CACHE_TTL_MS, + resolvedTaskQueueSelect, + resolvedWorkerDeploymentSelect, + resolvedWorkerTaskSelect, type ResolvedAuthenticatedEnv, type ResolvedEnv, type ResolvedWorkerVersion, @@ -55,10 +58,27 @@ export type ControlPlaneResolverOptions = { controlPlaneReplica: PrismaReplicaClient; cache: ControlPlaneCache; splitEnabled: () => boolean; + /** + * When true (the default when omitted), the dequeue worker-version resolve reads the matched + * task/queue fresh on every call (no cache). When false, it falls back to the legacy env-keyed + * cache over the whole task/queue set — a kill-switch, retained only so the read shape can be + * reverted via config. + */ + workerVersionFreshReadEnabled?: () => boolean; }; type CpClient = PrismaClient | PrismaReplicaClient; +type WorkerVersionWheres = { + taskWhere: { slug: string } | undefined; + queueWhere: { id: string } | { name: string } | undefined; +}; + +const ALL_WORKER_VERSION_WHERES: WorkerVersionWheres = { + taskWhere: undefined, + queueWhere: undefined, +}; + function workerVersionKey( environmentId: string, backgroundWorkerId: string | undefined, @@ -85,12 +105,14 @@ export class ControlPlaneResolver { private readonly controlPlaneReplica: PrismaReplicaClient; private readonly cache: ControlPlaneCache; private readonly splitEnabled: () => boolean; + private readonly workerVersionFreshReadEnabled: () => boolean; constructor(opts: ControlPlaneResolverOptions) { this.controlPlanePrimary = opts.controlPlanePrimary; this.controlPlaneReplica = opts.controlPlaneReplica; this.cache = opts.cache; this.splitEnabled = opts.splitEnabled; + this.workerVersionFreshReadEnabled = opts.workerVersionFreshReadEnabled ?? (() => true); } async resolveEnv(environmentId: string): Promise { @@ -343,53 +365,76 @@ export class ControlPlaneResolver { * When omitted, the original app behavior applies (worker-by-id, else current promotion). */ type?: RuntimeEnvironmentType; + taskIdentifier?: string; + queue?: { lockedQueueId?: string | null; name: string }; }): Promise { const { environmentId, backgroundWorkerId, type } = args; - if (!this.splitEnabled()) { - return this.#queryWorkerVersion( - this.controlPlanePrimary, + if (!this.workerVersionFreshReadEnabled()) { + if (!this.splitEnabled()) { + return this.#queryWorkerVersion( + this.controlPlanePrimary, + environmentId, + backgroundWorkerId, + type, + ALL_WORKER_VERSION_WHERES + ); + } + + const key = workerVersionKey(environmentId, backgroundWorkerId, type); + const cached = this.cache.getWorkerVersion(key); + if (cached !== undefined) { + return cached; + } + + const resolved = await this.#queryWorkerVersion( + this.controlPlaneReplica, environmentId, backgroundWorkerId, - type + type, + ALL_WORKER_VERSION_WHERES ); + this.cache.setWorkerVersion(key, resolved); + return resolved; } - const key = workerVersionKey(environmentId, backgroundWorkerId, type); - const cached = this.cache.getWorkerVersion(key); - if (cached !== undefined) { - return cached; - } + const wheres: WorkerVersionWheres = { + taskWhere: args.taskIdentifier ? { slug: args.taskIdentifier } : undefined, + queueWhere: args.queue + ? args.queue.lockedQueueId + ? { id: args.queue.lockedQueueId } + : { name: args.queue.name } + : undefined, + }; - const resolved = await this.#queryWorkerVersion( - this.controlPlaneReplica, - environmentId, - backgroundWorkerId, - type - ); - this.cache.setWorkerVersion(key, resolved); - return resolved; + const client = this.splitEnabled() ? this.controlPlaneReplica : this.controlPlanePrimary; + return this.#queryWorkerVersion(client, environmentId, backgroundWorkerId, type, wheres); } async #queryWorkerVersion( client: CpClient, environmentId: string, - backgroundWorkerId?: string, - type?: RuntimeEnvironmentType + backgroundWorkerId: string | undefined, + type: RuntimeEnvironmentType | undefined, + wheres: WorkerVersionWheres ): Promise { // Full run-engine dequeue dispatch (mirrors dequeueSystem's four helpers) when the env type is // known. DEVELOPMENT envs resolve by most-recent worker; deployed envs resolve the promoted // MANAGED deployment. if (type === "DEVELOPMENT") { return backgroundWorkerId - ? this.#queryWorkerById(client, backgroundWorkerId) - : this.#queryMostRecentWorker(client, environmentId); + ? this.#queryWorkerById(client, backgroundWorkerId, wheres) + : this.#queryMostRecentWorker(client, environmentId, wheres); } if (backgroundWorkerId) { const worker = await client.backgroundWorker.findFirst({ where: { id: backgroundWorkerId }, - include: { deployment: true, tasks: true, queues: true }, + include: { + deployment: { select: resolvedWorkerDeploymentSelect }, + tasks: { where: wheres.taskWhere, select: resolvedWorkerTaskSelect }, + queues: { where: wheres.queueWhere, select: resolvedTaskQueueSelect }, + }, }); if (!worker) { @@ -411,7 +456,16 @@ export class ControlPlaneResolver { where: { environmentId, label: CURRENT_DEPLOYMENT_LABEL }, include: { deployment: { - include: { worker: { include: { tasks: true, queues: true } } }, + select: { + ...resolvedWorkerDeploymentSelect, + type: true, + worker: { + include: { + tasks: { where: wheres.taskWhere, select: resolvedWorkerTaskSelect }, + queues: { where: wheres.queueWhere, select: resolvedTaskQueueSelect }, + }, + }, + }, }, }, }); @@ -421,11 +475,17 @@ export class ControlPlaneResolver { } if (type === undefined || promotion.deployment.type === "MANAGED") { + const { worker } = promotion.deployment; return { - worker: promotion.deployment.worker, - tasks: promotion.deployment.worker.tasks, - queues: promotion.deployment.worker.queues, - deployment: promotion.deployment, + worker, + tasks: worker.tasks, + queues: worker.queues, + deployment: { + id: promotion.deployment.id, + friendlyId: promotion.deployment.friendlyId, + imageReference: promotion.deployment.imageReference, + imagePlatform: promotion.deployment.imagePlatform, + }, }; } @@ -434,7 +494,15 @@ export class ControlPlaneResolver { const latestV2Deployment = await client.workerDeployment.findFirst({ where: { environmentId, type: "MANAGED" }, orderBy: [{ createdAt: "desc" }, { id: "desc" }], - include: { worker: { include: { tasks: true, queues: true } } }, + select: { + ...resolvedWorkerDeploymentSelect, + worker: { + include: { + tasks: { where: wheres.taskWhere, select: resolvedWorkerTaskSelect }, + queues: { where: wheres.queueWhere, select: resolvedTaskQueueSelect }, + }, + }, + }, }); if (!latestV2Deployment?.worker) { @@ -445,17 +513,27 @@ export class ControlPlaneResolver { worker: latestV2Deployment.worker, tasks: latestV2Deployment.worker.tasks, queues: latestV2Deployment.worker.queues, - deployment: latestV2Deployment, + deployment: { + id: latestV2Deployment.id, + friendlyId: latestV2Deployment.friendlyId, + imageReference: latestV2Deployment.imageReference, + imagePlatform: latestV2Deployment.imagePlatform, + }, }; } async #queryWorkerById( client: CpClient, - workerId: string + workerId: string, + wheres: WorkerVersionWheres ): Promise { const worker = await client.backgroundWorker.findFirst({ where: { id: workerId }, - include: { deployment: true, tasks: true, queues: true }, + include: { + deployment: { select: resolvedWorkerDeploymentSelect }, + tasks: { where: wheres.taskWhere, select: resolvedWorkerTaskSelect }, + queues: { where: wheres.queueWhere, select: resolvedTaskQueueSelect }, + }, }); if (!worker) { @@ -467,11 +545,15 @@ export class ControlPlaneResolver { async #queryMostRecentWorker( client: CpClient, - environmentId: string + environmentId: string, + wheres: WorkerVersionWheres ): Promise { const worker = await client.backgroundWorker.findFirst({ where: { runtimeEnvironmentId: environmentId }, - include: { tasks: true, queues: true }, + include: { + tasks: { where: wheres.taskWhere, select: resolvedWorkerTaskSelect }, + queues: { where: wheres.queueWhere, select: resolvedTaskQueueSelect }, + }, orderBy: [{ createdAt: "desc" }, { id: "desc" }], }); @@ -550,4 +632,5 @@ export const controlPlaneResolver = new ControlPlaneResolver({ maxEntries: env.CONTROL_PLANE_CACHE_MAX_ENTRIES ?? DEFAULT_CP_CACHE_MAX_ENTRIES, }), splitEnabled: () => SPLIT_ENABLED, + workerVersionFreshReadEnabled: () => env.RUN_OPS_WORKER_VERSION_FRESH_READ_ENABLED, }); diff --git a/apps/webapp/app/v3/runOpsMigration/runEngineControlPlaneResolver.server.ts b/apps/webapp/app/v3/runOpsMigration/runEngineControlPlaneResolver.server.ts index 014e446464..71e7eafa88 100644 --- a/apps/webapp/app/v3/runOpsMigration/runEngineControlPlaneResolver.server.ts +++ b/apps/webapp/app/v3/runOpsMigration/runEngineControlPlaneResolver.server.ts @@ -49,11 +49,15 @@ export class RunEngineControlPlaneResolver implements EngineControlPlaneResolver environmentId: string; type: RuntimeEnvironmentType; workerId?: string; + taskIdentifier?: string; + queue?: { lockedQueueId?: string | null; name: string }; }): Promise { return this.#resolver.resolveWorkerVersion({ environmentId: args.environmentId, backgroundWorkerId: args.workerId, type: args.type, + taskIdentifier: args.taskIdentifier, + queue: args.queue, }); } diff --git a/apps/webapp/app/v3/runQueue.server.ts b/apps/webapp/app/v3/runQueue.server.ts index 0ff28fa088..304819f9f3 100644 --- a/apps/webapp/app/v3/runQueue.server.ts +++ b/apps/webapp/app/v3/runQueue.server.ts @@ -1,17 +1,34 @@ +import { type PrismaClientOrTransaction } from "@trigger.dev/database"; +import { prisma } from "~/db.server"; import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { engine } from "./runEngine.server"; /** Updates the RunQueue env concurrency limits */ export async function updateEnvConcurrencyLimits( environment: AuthenticatedEnvironment, - maximumConcurrencyLimit?: number + maximumConcurrencyLimit?: number, + db: PrismaClientOrTransaction = prisma ) { - let updatedEnvironment = environment; - if (maximumConcurrencyLimit !== undefined) { - updatedEnvironment.maximumConcurrencyLimit = maximumConcurrencyLimit; + let limit = maximumConcurrencyLimit; + + if (limit === undefined) { + // A paused env is only enforced by a 0 limit in the RunQueue, so a push without an explicit + // limit must not resurrect the real limit. Callers hold an environment read at auth time, so + // resolve both values here instead of trusting it: a stale `paused: false` silently resumes a + // paused env, and a stale `paused: true` strands a resumed one at 0 with nothing to restore it. + const current = await db.runtimeEnvironment.findFirst({ + where: { id: environment.id }, + select: { paused: true, maximumConcurrencyLimit: true }, + }); + + const resolved = current ?? environment; + limit = resolved.paused ? 0 : resolved.maximumConcurrencyLimit; } - await engine.runQueue.updateEnvConcurrencyLimits(updatedEnvironment); + await engine.runQueue.updateEnvConcurrencyLimits({ + ...environment, + maximumConcurrencyLimit: limit, + }); } /** Updates the RunQueue limits for a queue */ diff --git a/apps/webapp/app/v3/runStore.server.ts b/apps/webapp/app/v3/runStore.server.ts index bf1642b5f4..9ccf84b511 100644 --- a/apps/webapp/app/v3/runStore.server.ts +++ b/apps/webapp/app/v3/runStore.server.ts @@ -12,6 +12,10 @@ import { } from "~/db.server"; import { env } from "~/env.server"; import { singleton } from "~/utils/singleton"; +import { + resilienceForClient, + type TransactionResilienceConfig, +} from "./transactionResilience.server"; type BuildRunStoreDeps = { /** Boot constant: true only when both run-ops DBs are configured and the split flag is on. */ @@ -27,6 +31,10 @@ type BuildRunStoreDeps = { singleReplica: PrismaReplicaClient; /** Residency classifier; defaults to ownerEngine inside RoutingRunStore. */ classify?: (id: string) => Residency; + /** Per-pool transaction-resilience configs threaded into the store(s) this builds (IoC). */ + singleResilience?: TransactionResilienceConfig; + newResilience?: TransactionResilienceConfig; + legacyResilience?: TransactionResilienceConfig; }; /** @@ -46,6 +54,8 @@ export function buildRunStore(deps: BuildRunStoreDeps): RunStore { return new PostgresRunStore({ prisma: deps.singleWriter, readOnlyPrisma: deps.singleReplica, + maxWait: deps.singleResilience?.maxWait, + transactionStartRetry: deps.singleResilience?.startRetry, }); } @@ -59,10 +69,14 @@ export function buildRunStore(deps: BuildRunStoreDeps): RunStore { prisma: deps.newWriter, readOnlyPrisma: deps.newReplica, schemaVariant: "dedicated", + maxWait: deps.newResilience?.maxWait, + transactionStartRetry: deps.newResilience?.startRetry, }); const legacyStore = new PostgresRunStore({ prisma: deps.legacyWriter, readOnlyPrisma: deps.legacyReplica, + maxWait: deps.legacyResilience?.maxWait, + transactionStartRetry: deps.legacyResilience?.startRetry, }); return new RoutingRunStore({ @@ -110,6 +124,7 @@ export const runStore: RunStore = singleton("RunStore", () => { splitEnabled: false, singleWriter: prisma, singleReplica: $replica, + singleResilience: resilienceForClient(prisma), }); } return buildRunStore({ @@ -117,5 +132,8 @@ export const runStore: RunStore = singleton("RunStore", () => { ...handles, singleWriter: prisma, singleReplica: $replica, + singleResilience: resilienceForClient(prisma), + newResilience: resilienceForClient(handles.newWriter), + legacyResilience: resilienceForClient(handles.legacyWriter), }); }); diff --git a/apps/webapp/app/v3/runTimestamps.ts b/apps/webapp/app/v3/runTimestamps.ts new file mode 100644 index 0000000000..23bcac6681 --- /dev/null +++ b/apps/webapp/app/v3/runTimestamps.ts @@ -0,0 +1,11 @@ +export function runTriggeredAt({ + createdAt, + queueTimestamp, + scheduleId, +}: { + createdAt: Date; + queueTimestamp?: Date | null; + scheduleId?: string | null; +}) { + return scheduleId && queueTimestamp ? queueTimestamp : createdAt; +} diff --git a/apps/webapp/app/v3/scheduleWindow.server.ts b/apps/webapp/app/v3/scheduleWindow.server.ts index e89123488e..c0bf8cea69 100644 --- a/apps/webapp/app/v3/scheduleWindow.server.ts +++ b/apps/webapp/app/v3/scheduleWindow.server.ts @@ -1,5 +1,10 @@ -import { parseScheduleWindow } from "@internal/schedule-engine"; -import type { ScheduleWindow } from "@trigger.dev/core/v3"; +import { calculateEffectiveScheduleTime, calculateSchedulePhase } from "@internal/schedule-engine"; +import { + ScheduleWindow, + parseScheduleWindow, + type NormalizedScheduleWindow, +} from "@trigger.dev/core/v3"; +import { nextScheduledTimestamps } from "./utils/calculateNextSchedule.server"; const SECONDS_PER_UNIT = { m: 60, @@ -11,9 +16,12 @@ export type ScheduleWindowDatabaseFields = { windowPercentage: number | null; }; -export function normalizeScheduleWindow( - window: ScheduleWindow | undefined -): ScheduleWindowDatabaseFields { +export type ScheduleRunTiming = { + nominalAt: Date; + effectiveAt: Date; +}; + +export function normalizeScheduleWindow(window: string | undefined): ScheduleWindowDatabaseFields { if (window === undefined) { return { windowDurationSeconds: null, @@ -39,7 +47,7 @@ export function normalizeScheduleWindow( export function formatScheduleWindow({ windowDurationSeconds, windowPercentage, -}: ScheduleWindowDatabaseFields): ScheduleWindow | undefined { +}: ScheduleWindowDatabaseFields): string | undefined { if (windowPercentage !== null) { return `${windowPercentage}%`; } @@ -59,20 +67,73 @@ export function formatScheduleWindow({ return `${windowDurationSeconds / SECONDS_PER_UNIT.m}m`; } +export function calculateNextScheduleRunTimes({ + cron, + timezone, + deduplicationKey, + environmentId, + schedulePhase, + phaseSecret, + windowDurationSeconds, + windowPercentage, + from = new Date(), + count = 1, +}: { + cron: string; + timezone: string | null; + deduplicationKey: string; + environmentId: string; + schedulePhase: number | null; + phaseSecret: string; + windowDurationSeconds: number | null; + windowPercentage: number | null; + from?: Date; + count?: number; +}): ScheduleRunTiming[] { + if (count <= 0) { + return []; + } + + const phase = + schedulePhase ?? + calculateSchedulePhase({ + secret: phaseSecret, + environmentId, + deduplicationKey, + }); + const window: NormalizedScheduleWindow | undefined = + windowPercentage !== null + ? { type: "percentage", percentage: windowPercentage } + : windowDurationSeconds !== null + ? { type: "duration", durationSeconds: windowDurationSeconds } + : undefined; + const nominalTimes = nextScheduledTimestamps(cron, timezone, from, count + 1); + + return nominalTimes.slice(0, count).map((nominalAt, index) => ({ + nominalAt, + effectiveAt: calculateEffectiveScheduleTime({ + nominalAt, + nextNominalAt: nominalTimes[index + 1], + schedulePhase: phase, + window, + }).effectiveAt, + })); +} + export function validateScheduleWindowSyntax( - window: ScheduleWindow | undefined + window: string | undefined ): { valid: true } | { valid: false; message: string } { if (window === undefined) { return { valid: true }; } - try { - parseScheduleWindow(window); + const result = ScheduleWindow.safeParse(window); + if (result.success) { return { valid: true }; - } catch (error) { - return { - valid: false, - message: error instanceof Error ? error.message : String(error), - }; } + + return { + valid: false, + message: result.error.issues[0]?.message ?? "Invalid schedule window", + }; } diff --git a/apps/webapp/app/v3/schedules.ts b/apps/webapp/app/v3/schedules.ts index bb1d3af55d..d2355a5f54 100644 --- a/apps/webapp/app/v3/schedules.ts +++ b/apps/webapp/app/v3/schedules.ts @@ -57,7 +57,7 @@ export const UpsertSchedule = z.object({ externalId: z.string().optional(), deduplicationKey: z.string().optional(), timezone: z.string().optional(), - window: ScheduleWindow.optional(), + window: z.preprocess((value) => (value === "" ? undefined : value), ScheduleWindow.optional()), }); export type UpsertSchedule = z.infer; diff --git a/apps/webapp/app/v3/services/adminWorker.server.ts b/apps/webapp/app/v3/services/adminWorker.server.ts index 562e4f451d..1fa7aa669e 100644 --- a/apps/webapp/app/v3/services/adminWorker.server.ts +++ b/apps/webapp/app/v3/services/adminWorker.server.ts @@ -16,6 +16,11 @@ import { runsReplicationInstance } from "~/services/runsReplicationInstance.serv // effect the bundler must preserve. See TRI-9864. import { sessionsReplicationInstance } from "~/services/sessionsReplicationInstance.server"; (globalThis as Record).__sessionsReplicationInstance = sessionsReplicationInstance; +// Same reference-hold as the sessions replicator above (and the same +// `void`-tree-shaking caveat) for the webhook deliveries replication singleton. +import { webhookDeliveriesReplicationInstance } from "~/services/webhookDeliveriesReplicationInstance.server"; +(globalThis as Record).__webhookDeliveriesReplicationInstance = + webhookDeliveriesReplicationInstance; import { singleton } from "~/utils/singleton"; import { tracer } from "../tracer.server"; import { $replica } from "~/db.server"; diff --git a/apps/webapp/app/v3/services/allocateConcurrency.server.ts b/apps/webapp/app/v3/services/allocateConcurrency.server.ts index 78c8b82915..24aebfefcf 100644 --- a/apps/webapp/app/v3/services/allocateConcurrency.server.ts +++ b/apps/webapp/app/v3/services/allocateConcurrency.server.ts @@ -88,7 +88,7 @@ export class AllocateConcurrencyService extends BaseService { }); if (!updatedEnvironment.paused) { - await updateEnvConcurrencyLimits(updatedEnvironment); + await updateEnvConcurrencyLimits(updatedEnvironment, undefined, this._prisma); } // Percent-based queue overrides follow the environment limit automatically. Note the diff --git a/apps/webapp/app/v3/services/changeCurrentDeployment.server.ts b/apps/webapp/app/v3/services/changeCurrentDeployment.server.ts index 9782d4d8cb..5f5697dd7c 100644 --- a/apps/webapp/app/v3/services/changeCurrentDeployment.server.ts +++ b/apps/webapp/app/v3/services/changeCurrentDeployment.server.ts @@ -1,6 +1,7 @@ import { BackgroundWorkerMetadata, tryCatch } from "@trigger.dev/core/v3"; import { CURRENT_DEPLOYMENT_LABEL } from "@trigger.dev/core/v3/isomorphic"; import type { PrismaClientOrTransaction, WorkerDeployment } from "@trigger.dev/database"; +import { webhookPrisma } from "~/db.server"; import { logger } from "~/services/logger.server"; import { syncTaskIdentifiers } from "~/services/taskIdentifierRegistry.server"; import { @@ -9,7 +10,7 @@ import { } from "~/services/taskMetadataCache.server"; import { taskMetadataCacheInstance } from "~/services/taskMetadataCacheInstance.server"; import { BaseService, ServiceValidationError } from "./baseService.server"; -import { syncDeclarativeSchedules } from "./createBackgroundWorker.server"; +import { syncDeclarativeSchedules, syncDeclarativeWebhooks } from "./createBackgroundWorker.server"; import { compareDeploymentVersions } from "../utils/deploymentVersions"; export type ChangeCurrentDeploymentDirection = "promote" | "rollback"; @@ -217,5 +218,12 @@ export class ChangeCurrentDeploymentService extends BaseService { } await syncDeclarativeSchedules(parsed.data.tasks, worker, environment, this._prisma); + await syncDeclarativeWebhooks( + parsed.data.webhooks, + worker, + environment, + this._prisma, + webhookPrisma + ); } } diff --git a/apps/webapp/app/v3/services/checkSchedule.server.ts b/apps/webapp/app/v3/services/checkSchedule.server.ts index ea7be4e39e..946cfad080 100644 --- a/apps/webapp/app/v3/services/checkSchedule.server.ts +++ b/apps/webapp/app/v3/services/checkSchedule.server.ts @@ -5,7 +5,6 @@ import { resolveProjectScopedEnvironments } from "./resolveProjectScopedEnvironm import { getLimit } from "~/services/platform.v3.server"; import { getTimezones } from "~/utils/timezones.server"; import { env } from "~/env.server"; -import type { ScheduleWindow } from "@trigger.dev/core/v3"; import { boundedIn, type PrismaClientOrTransaction } from "@trigger.dev/database"; import { validateScheduleWindowSyntax } from "../scheduleWindow.server"; @@ -14,7 +13,7 @@ type Schedule = { timezone?: string; taskIdentifier: string; friendlyId?: string; - window?: ScheduleWindow; + window?: string; }; export class CheckScheduleService extends BaseService { diff --git a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts index f73f177270..1dd6b1b34c 100644 --- a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts +++ b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts @@ -2,16 +2,24 @@ import type { BackgroundWorkerMetadata, BackgroundWorkerSourceFileMetadata, CreateBackgroundWorkerRequestBody, + FilterAst, PromptResource, QueueManifest, TaskResource, + WebhookResource, } from "@trigger.dev/core/v3"; -import { tryCatch } from "@trigger.dev/core/v3"; -import { BackgroundWorkerId, stringifyDuration } from "@trigger.dev/core/v3/isomorphic"; +import { FILTER_AST_VERSION, tryCatch } from "@trigger.dev/core/v3"; +import { FilterParseError, parseFilter } from "@internal/webhook-engine"; +import { + BackgroundWorkerId, + WebhookEndpointId, + stringifyDuration, +} from "@trigger.dev/core/v3/isomorphic"; +import { randomBytes } from "node:crypto"; import type { BackgroundWorker, TaskQueue, TaskQueueType } from "@trigger.dev/database"; import cronstrue from "cronstrue"; -import type { PrismaClientOrTransaction } from "~/db.server"; -import { $transaction, Prisma, boundedIn } from "~/db.server"; +import type { PrismaClientOrTransaction, WebhookDatabase } from "~/db.server"; +import { $transaction, Prisma, boundedIn, webhookPrisma } from "~/db.server"; import { sanitizeQueueName } from "~/models/taskQueue.server"; import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; @@ -200,6 +208,34 @@ export class CreateBackgroundWorkerService extends BaseService { throw new ServiceValidationError("Error syncing declarative schedules"); } + const [webhooksError] = await tryCatch( + syncDeclarativeWebhooks( + body.metadata.webhooks, + backgroundWorker, + environment, + this._prisma, + webhookPrisma + ) + ); + if (webhooksError) { + if (webhooksError instanceof ServiceValidationError) { + logger.warn("Error syncing declarative webhooks", { + error: webhooksError.message, + backgroundWorker, + environment, + }); + throw webhooksError; + } + + logger.error("Error syncing declarative webhooks", { + error: webhooksError, + backgroundWorker, + environment, + }); + + throw new ServiceValidationError("Error syncing declarative webhooks"); + } + const [syncIdentifiersError] = await tryCatch( syncTaskIdentifiers( environment.id, @@ -238,7 +274,7 @@ export class CreateBackgroundWorkerService extends BaseService { } const [updateConcurrencyLimitsError] = await tryCatch( - updateEnvConcurrencyLimits(environment) + updateEnvConcurrencyLimits(environment, undefined, this._prisma) ); if (updateConcurrencyLimitsError) { @@ -354,7 +390,7 @@ async function createWorkerTask( ): Promise { // Hoisted so the P2002 catch branch can return the same entry shape. let queue: TaskQueue | undefined; - let resolvedTriggerSource: "SCHEDULED" | "AGENT" | "STANDARD" | undefined; + let resolvedTriggerSource: "SCHEDULED" | "AGENT" | "WEBHOOK" | "STANDARD" | undefined; let resolvedTtl: string | null | undefined; try { @@ -380,7 +416,9 @@ async function createWorkerTask( ? ("SCHEDULED" as const) : task.triggerSource === "agent" ? ("AGENT" as const) - : ("STANDARD" as const); + : task.triggerSource === "webhook" + ? ("WEBHOOK" as const) + : ("STANDARD" as const); resolvedTtl = typeof task.ttl === "number" ? (stringifyDuration(task.ttl) ?? null) : (task.ttl ?? null); @@ -640,6 +678,145 @@ export class CreateDeclarativeScheduleError extends Error { } } +// TODO: centralize (the P2 dynamic webhooks.create() API will also mint opaqueIds). +function generateOpaqueId(): string { + return randomBytes(16).toString("base64url"); +} + +export async function syncDeclarativeWebhooks( + webhooks: WebhookResource[] | undefined, + worker: BackgroundWorker, + environment: AuthenticatedEnvironment, + prisma: PrismaClientOrTransaction, + // Endpoint rows live on the webhook DB; the task-existence check below stays on the main client. + webhookPrisma: WebhookDatabase +) { + if (webhooks === undefined) return; + + const existing = await webhookPrisma.webhookEndpoint.findMany({ + where: { + runtimeEnvironmentId: environment.id, + endpointTenantId: "", + endpointExternalRef: "", + }, + }); + const missing = new Set(existing.map((e) => e.handlerWebhookId)); + + for (const wh of webhooks) { + // Both routing targets resolve to a task: a fan-out webhook to its own task, a session webhook to + // the claiming agent. Validate the target exists in this worker so a bad route fails at sync. + const targetTaskSlug = + wh.routingTarget.type === "task" ? wh.routingTarget.taskId : wh.routingTarget.taskIdentifier; + const taskExists = await prisma.backgroundWorkerTask.findFirst({ + where: { workerId: worker.id, slug: targetTaskSlug }, + select: { id: true }, + }); + if (!taskExists) { + throw new ServiceValidationError( + `Webhook "${wh.id}" routes to unknown task "${targetTaskSlug}"` + ); + } + + missing.delete(wh.id); + + if ( + "config" in wh.verifierArtifact && + wh.verifierArtifact.config.scheme === "url-secret" && + wh.verifierArtifact.config.placement === "path" + ) { + throw new ServiceValidationError( + `Webhook "${wh.id}" uses url-secret verification with path placement, which cannot be verified on the hosted ingress URL. Use query placement or a header-based scheme.` + ); + } + + // Compile `filter` into a FilterAst, once here at sync. A bad filter fails the deploy with a clear + // message rather than surfacing at ingest. Re-deploying without a filter nulls the columns. + let filterNode: FilterAst | undefined; + if (wh.filter) { + try { + filterNode = parseFilter(wh.filter); + } catch (error) { + if (error instanceof FilterParseError) { + throw new ServiceValidationError( + `Webhook "${wh.id}" has an invalid filter: ${error.message}` + ); + } + throw error; + } + } + const filterData = { + filter: wh.filter ?? null, + filterAst: filterNode ? (filterNode as unknown as Prisma.InputJsonValue) : Prisma.DbNull, + filterAstVersion: filterNode ? FILTER_AST_VERSION : null, + }; + + // Validate a session target's startOn like the route filter: a bad predicate fails the deploy, not ingest. + if (wh.routingTarget.type === "session" && wh.routingTarget.startOn) { + try { + parseFilter(wh.routingTarget.startOn); + } catch (error) { + if (error instanceof FilterParseError) { + throw new ServiceValidationError( + `Webhook "${wh.id}" has an invalid startOn: ${error.message}` + ); + } + throw error; + } + } + + const found = existing.find((e) => e.handlerWebhookId === wh.id); + if (found) { + await webhookPrisma.webhookEndpoint.update({ + where: { id: found.id }, + data: { + source: wh.source, + routingTarget: wh.routingTarget as unknown as Prisma.InputJsonValue, + verifierArtifact: wh.verifierArtifact as unknown as Prisma.InputJsonValue, + secretProvisioning: wh.secretProvisioning ?? "either", + metadata: (wh.metadata ?? {}) as unknown as Prisma.InputJsonValue, + ...(found.manuallyDeactivatedAt === null ? { status: "ACTIVE" as const } : {}), + ...filterData, + }, + }); + } else { + const { id, friendlyId } = WebhookEndpointId.generate(); + await webhookPrisma.webhookEndpoint.create({ + data: { + id, + friendlyId, + opaqueId: generateOpaqueId(), // CSPRNG, NOT a friendlyId + organizationId: environment.organizationId, + projectId: environment.projectId, + runtimeEnvironmentId: environment.id, + environmentType: environment.type, + endpointTenantId: "", + endpointExternalRef: "", + source: wh.source, + handlerWebhookId: wh.id, + routingTarget: wh.routingTarget as unknown as Prisma.InputJsonValue, + verifierArtifact: wh.verifierArtifact as unknown as Prisma.InputJsonValue, + secretProvisioning: wh.secretProvisioning ?? "either", + metadata: (wh.metadata ?? {}) as unknown as Prisma.InputJsonValue, + status: "ACTIVE", + ...filterData, + }, + }); + } + } + + if (missing.size > 0) { + await webhookPrisma.webhookEndpoint.updateMany({ + where: { + runtimeEnvironmentId: environment.id, + endpointTenantId: "", + endpointExternalRef: "", + handlerWebhookId: { in: boundedIn(Array.from(missing)) }, + }, + data: { status: "INACTIVE" }, + }); + } +} + export async function syncDeclarativeSchedules( tasks: TaskResource[], worker: BackgroundWorker, diff --git a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts index 66a17bb9f6..305ee45ce2 100644 --- a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts +++ b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts @@ -13,11 +13,13 @@ import { createBackgroundFiles, createWorkerResources, syncDeclarativeSchedules, + syncDeclarativeWebhooks, } from "./createBackgroundWorker.server"; import { findOrCreateBackgroundWorker } from "./createDeploymentBackgroundWorkerV4/findOrCreateBackgroundWorker.server"; import { TimeoutDeploymentService } from "./timeoutDeployment.server"; import { recordDeploymentOutcome } from "./recordDeploymentOutcome.server"; import { env } from "~/env.server"; +import { webhookPrisma } from "~/db.server"; export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService { private readonly _taskMetaCache: TaskMetadataCache; @@ -228,6 +230,29 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService { throw serviceError; } + const [webhooksError] = await tryCatch( + syncDeclarativeWebhooks( + body.metadata.webhooks, + backgroundWorker, + environment, + this._prisma, + webhookPrisma + ) + ); + + if (webhooksError) { + logger.error("Error syncing declarative webhooks", { error: webhooksError }); + + const serviceError = + webhooksError instanceof ServiceValidationError + ? webhooksError + : new ServiceValidationError("Error syncing declarative webhooks"); + + await this.#failBackgroundWorkerDeployment(deployment, serviceError, environment); + + throw serviceError; + } + // Guarded BUILDING → DEPLOYING transition. `updateMany` for optimistic concurrency control const { count: updatedCount } = await this._prisma.workerDeployment.updateMany({ where: { diff --git a/apps/webapp/app/v3/services/finalizeDeployment.server.ts b/apps/webapp/app/v3/services/finalizeDeployment.server.ts index 98ef7a73bd..fd559aff30 100644 --- a/apps/webapp/app/v3/services/finalizeDeployment.server.ts +++ b/apps/webapp/app/v3/services/finalizeDeployment.server.ts @@ -123,7 +123,7 @@ export class FinalizeDeploymentService extends BaseService { } ); - await updateEnvConcurrencyLimits(authenticatedEnv); + await updateEnvConcurrencyLimits(authenticatedEnv, undefined, this._prisma); } catch (err) { logger.error("Failed to publish WORKER_CREATED event", { err }); } diff --git a/apps/webapp/app/v3/services/pauseEnvironment.server.ts b/apps/webapp/app/v3/services/pauseEnvironment.server.ts index af9edff856..e10f342743 100644 --- a/apps/webapp/app/v3/services/pauseEnvironment.server.ts +++ b/apps/webapp/app/v3/services/pauseEnvironment.server.ts @@ -118,7 +118,9 @@ export class PauseEnvironmentService extends WithRunEngine { logger.debug("PauseEnvironmentService: resuming environment", { environmentId: environment.id, }); - await updateEnvConcurrencyLimits(environment); + // `environment` was read before the update above, so its `paused` is stale. The helper + // resolves the current state itself - hand it the client that wrote the resume. + await updateEnvConcurrencyLimits(environment, undefined, this._prisma); } } catch (error) { await this._prisma.runtimeEnvironment.update({ diff --git a/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts b/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts index 567e8269d3..cf72873b37 100644 --- a/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts +++ b/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts @@ -3,12 +3,16 @@ import cronstrue from "cronstrue"; import { nanoid } from "nanoid"; import { generateFriendlyId } from "../friendlyIdentifiers"; import { type UpsertSchedule } from "../schedules"; -import { calculateNextScheduledTimestampFromNow } from "../utils/calculateNextSchedule.server"; import { BaseService, ServiceValidationError } from "./baseService.server"; import { CheckScheduleService } from "./checkSchedule.server"; import { scheduleEngine } from "../scheduleEngine.server"; -import { formatScheduleWindow, normalizeScheduleWindow } from "../scheduleWindow.server"; +import { + calculateNextScheduleRunTimes, + formatScheduleWindow, + normalizeScheduleWindow, +} from "../scheduleWindow.server"; import { scheduleWhereClause } from "~/models/schedules.server"; +import { env } from "~/env.server"; export type UpsertTaskScheduleServiceOptions = UpsertSchedule; @@ -81,7 +85,7 @@ export class UpsertTaskScheduleService extends BaseService { }, }); - return this.#createReturnObject(scheduleRecord, instances); + return this.#createReturnObject(scheduleRecord, instances, schedule.environments[0]); } async #createNewSchedule( @@ -237,7 +241,27 @@ export class UpsertTaskScheduleService extends BaseService { return { scheduleRecord }; } - #createReturnObject(taskSchedule: TaskSchedule, instances: InstanceWithEnvironment[]) { + #createReturnObject( + taskSchedule: TaskSchedule, + instances: InstanceWithEnvironment[], + environmentId: string + ) { + const instance = instances.find((instance) => instance.environmentId === environmentId); + if (!instance) { + throw new ServiceValidationError("Failed to find the schedule instance"); + } + + const [nextRun] = calculateNextScheduleRunTimes({ + cron: taskSchedule.generatorExpression, + timezone: taskSchedule.timezone, + deduplicationKey: taskSchedule.deduplicationKey, + environmentId: instance.environmentId, + schedulePhase: instance.schedulePhase, + phaseSecret: env.ENCRYPTION_KEY, + windowDurationSeconds: taskSchedule.windowDurationSeconds, + windowPercentage: taskSchedule.windowPercentage, + }); + return { id: taskSchedule.friendlyId, type: taskSchedule.type, @@ -251,10 +275,8 @@ export class UpsertTaskScheduleService extends BaseService { cronDescription: taskSchedule.generatorDescription, timezone: taskSchedule.timezone, window: formatScheduleWindow(taskSchedule), - nextRun: calculateNextScheduledTimestampFromNow( - taskSchedule.generatorExpression, - taskSchedule.timezone - ), + nextRun: nextRun.nominalAt, + nextRunEffectiveAt: nextRun.effectiveAt, environments: instances.map((instance) => ({ id: instance.environment.id, shortcode: instance.environment.shortcode, diff --git a/apps/webapp/app/v3/transactionResilience.server.ts b/apps/webapp/app/v3/transactionResilience.server.ts new file mode 100644 index 0000000000..ae5678c987 --- /dev/null +++ b/apps/webapp/app/v3/transactionResilience.server.ts @@ -0,0 +1,100 @@ +import { TokenBucketRetryBudget, type TransactionStartRetryConfig } from "@trigger.dev/database"; +import { env } from "~/env.server"; +import { logger } from "~/services/logger.server"; + +/** + * Resolved transaction-resilience config for one writer pool. Each pool gets its own + * {@link TransactionStartRetryConfig} (with its OWN token bucket, so a storm on one pool cannot + * drain another's retry budget) plus the `maxWait` applied when that pool opens a transaction. + * Env is read here at the app boundary (IoC); the library never reads env. + * + * Kept out of `db.server` on purpose: `db.server` is mocked wholesale by ~150 tests, and a new + * export there breaks every mock that does not list it. Both `db.server` and `runStore.server` + * import these from here instead. + */ +export type TransactionResilienceConfig = { + maxWait: number; + startRetry: TransactionStartRetryConfig; +}; + +function resolveTransactionResilience( + pool: "control-plane" | "run-ops" | "run-ops-legacy", + overrides: { + maxWaitMs?: number; + enabled?: boolean; + maxAttempts?: number; + backoffMinMs?: number; + backoffMaxMs?: number; + budgetPerSec?: number; + budgetBurst?: number; + } +): TransactionResilienceConfig { + const budgetPerSec = + overrides.budgetPerSec ?? env.DATABASE_TRANSACTION_START_RETRY_BUDGET_PER_SEC; + const budgetBurst = overrides.budgetBurst ?? env.DATABASE_TRANSACTION_START_RETRY_BUDGET_BURST; + return { + maxWait: Math.max(0, overrides.maxWaitMs ?? env.DATABASE_TRANSACTION_MAX_WAIT_MS), + startRetry: { + options: { + enabled: overrides.enabled ?? env.DATABASE_TRANSACTION_START_RETRY_ENABLED, + maxAttempts: overrides.maxAttempts ?? env.DATABASE_TRANSACTION_START_RETRY_MAX_ATTEMPTS, + backoffMinMs: overrides.backoffMinMs ?? env.DATABASE_TRANSACTION_START_RETRY_BACKOFF_MIN_MS, + backoffMaxMs: overrides.backoffMaxMs ?? env.DATABASE_TRANSACTION_START_RETRY_BACKOFF_MAX_MS, + }, + budget: new TokenBucketRetryBudget({ ratePerSec: budgetPerSec, burst: budgetBurst }), + onRetry: ({ attempt, delayMs }) => + logger.warn("retrying transaction start after acquisition failure", { + pool, + attempt, + delayMs, + }), + }, + }; +} + +export const controlPlaneTransactionResilience = resolveTransactionResilience("control-plane", {}); + +export const runOpsTransactionResilience = resolveTransactionResilience("run-ops", { + maxWaitMs: env.RUN_OPS_DATABASE_TRANSACTION_MAX_WAIT_MS, + enabled: env.RUN_OPS_DATABASE_TRANSACTION_START_RETRY_ENABLED, + maxAttempts: env.RUN_OPS_DATABASE_TRANSACTION_START_RETRY_MAX_ATTEMPTS, + backoffMinMs: env.RUN_OPS_DATABASE_TRANSACTION_START_RETRY_BACKOFF_MIN_MS, + backoffMaxMs: env.RUN_OPS_DATABASE_TRANSACTION_START_RETRY_BACKOFF_MAX_MS, + budgetPerSec: env.RUN_OPS_DATABASE_TRANSACTION_START_RETRY_BUDGET_PER_SEC, + budgetBurst: env.RUN_OPS_DATABASE_TRANSACTION_START_RETRY_BUDGET_BURST, +}); + +export const runOpsLegacyTransactionResilience = resolveTransactionResilience("run-ops-legacy", { + maxWaitMs: env.RUN_OPS_LEGACY_DATABASE_TRANSACTION_MAX_WAIT_MS, + enabled: env.RUN_OPS_LEGACY_DATABASE_TRANSACTION_START_RETRY_ENABLED, + maxAttempts: env.RUN_OPS_LEGACY_DATABASE_TRANSACTION_START_RETRY_MAX_ATTEMPTS, + backoffMinMs: env.RUN_OPS_LEGACY_DATABASE_TRANSACTION_START_RETRY_BACKOFF_MIN_MS, + backoffMaxMs: env.RUN_OPS_LEGACY_DATABASE_TRANSACTION_START_RETRY_BACKOFF_MAX_MS, + budgetPerSec: env.RUN_OPS_LEGACY_DATABASE_TRANSACTION_START_RETRY_BUDGET_PER_SEC, + budgetBurst: env.RUN_OPS_LEGACY_DATABASE_TRANSACTION_START_RETRY_BUDGET_BURST, +}); + +const transactionResilienceByClient = new WeakMap(); + +/** + * Associate a writer client with its pool's resilience config. Returns the client for inline use at + * construction. Kept here (not in db.server) so nothing new lands on db.server's wholesale-mocked + * export surface. + */ +export function registerTransactionResilience( + client: T, + resilience: TransactionResilienceConfig +): T { + transactionResilienceByClient.set(client, resilience); + return client; +} + +/** + * The resilience config registered for a writer client, or the control-plane config as a safe + * fallback. Derives resilience from the ACTUAL client identity rather than an assumed routing role, + * so run-ops clients aliased onto the control-plane pool (split flag off) correctly get the + * control-plane config instead of a run-ops override. + */ +export function resilienceForClient(client: object): TransactionResilienceConfig { + return transactionResilienceByClient.get(client) ?? controlPlaneTransactionResilience; +} diff --git a/apps/webapp/app/v3/webhookEngine.server.ts b/apps/webapp/app/v3/webhookEngine.server.ts new file mode 100644 index 0000000000..d58a89919b --- /dev/null +++ b/apps/webapp/app/v3/webhookEngine.server.ts @@ -0,0 +1,285 @@ +import { WebhookEngine } from "@internal/webhook-engine"; +import type { WebhookDeliverTaskErrorType } from "@internal/webhook-engine"; +import { tryCatch } from "@trigger.dev/core/utils"; +import { z } from "zod"; +import { prisma, webhookPrisma } from "~/db.server"; +import { env } from "~/env.server"; +import { findEnvironmentById } from "~/models/runtimeEnvironment.server"; +import { logger } from "~/services/logger.server"; +import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; +import { + ensureRunForSession, + type SessionTriggerConfig, +} from "~/services/realtime/sessionRunManager.server"; +import { findOrCreateSession, findSessionByExternalId } from "~/services/realtime/sessions.server"; +import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; +import { + claimSessionStreamPart, + drainSessionStreamWaitpoints, + releaseSessionStreamPart, +} from "~/services/sessionStreamWaitpointCache.server"; +import { getSecretStore } from "~/services/secrets/secretStore.server"; +import { singleton } from "~/utils/singleton"; +import { engine as runEngine } from "./runEngine.server"; +import { ServiceValidationError } from "./services/common.server"; +import { TriggerTaskService } from "./services/triggerTask.server"; +import { meter, tracer } from "./tracer.server"; + +export const webhookEngine = singleton("WebhookEngine", createWebhookEngine); + +export type { WebhookEngine }; + +// The plaintext signing secret is stored under the "DATABASE" SecretStore +// provider as { secret: string } (same shape as environment variables). +const SigningSecretSchema = z.object({ secret: z.string() }); + +function createWebhookEngine() { + // The engine owns the webhook tables, so it runs on the webhook DB client. The signing-secret + // store stays on the main client below (SecretStore is control-plane, not part of the split). + const secretStore = getSecretStore("DATABASE", { prismaClient: prisma }); + + const engine = new WebhookEngine({ + prisma: webhookPrisma, + logLevel: env.WEBHOOK_ENGINE_LOG_LEVEL, + disabled: env.WEBHOOK_ENABLED !== "1", + redis: { + host: env.WEBHOOK_WORKER_REDIS_HOST ?? "localhost", + port: env.WEBHOOK_WORKER_REDIS_PORT ?? 6379, + username: env.WEBHOOK_WORKER_REDIS_USERNAME, + password: env.WEBHOOK_WORKER_REDIS_PASSWORD, + keyPrefix: "webhook:", + enableAutoPipelining: true, + ...(env.WEBHOOK_WORKER_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }), + }, + worker: { + concurrency: env.WEBHOOK_WORKER_CONCURRENCY_LIMIT, + workers: env.WEBHOOK_WORKER_CONCURRENCY_WORKERS, + tasksPerWorker: env.WEBHOOK_WORKER_CONCURRENCY_TASKS_PER_WORKER, + pollIntervalMs: env.WEBHOOK_WORKER_POLL_INTERVAL, + shutdownTimeoutMs: env.WEBHOOK_WORKER_SHUTDOWN_TIMEOUT_MS, + disabled: env.WEBHOOK_ENABLED !== "1" || env.WEBHOOK_WORKER_ENABLED !== "true", + }, + partitions: { + ensureSchedule: env.WEBHOOK_PARTITION_ENSURE_SCHEDULE, + ensureJitterInMs: env.WEBHOOK_PARTITION_ENSURE_JITTER_MS, + lookaheadDays: env.WEBHOOK_PARTITION_LOOKAHEAD_DAYS, + retentionDays: env.WEBHOOK_PARTITION_RETENTION_DAYS, + }, + frontGate: { + defaultTtlSeconds: env.WEBHOOK_FRONT_GATE_DEFAULT_TTL_SECONDS, + maxTtlSeconds: env.WEBHOOK_FRONT_GATE_MAX_TTL_SECONDS, + }, + endpointCache: { + ttlMs: env.WEBHOOK_ENDPOINT_CACHE_TTL_MS, + maxSize: env.WEBHOOK_ENDPOINT_CACHE_MAX_SIZE, + }, + tracer, + meter, + resolveSigningSecret: async (key) => { + const value = await secretStore.getSecret(SigningSecretSchema, key); + // Fail closed: an unset/empty secret returns undefined so ingest rejects. + return value?.secret || undefined; + }, + triggerTask: async ({ + environmentId, + taskId, + idempotencyKey, + idempotencyKeyExpiresAt, + payload, + headers, + identityTags, + endpointMetadata, + }) => { + try { + const environment = await findEnvironmentById(environmentId); + if (!environment) { + return { success: false, errorType: "NOT_FOUND", error: "Environment not found" }; + } + + const triggerService = new TriggerTaskService(); + + const result = await triggerService.call( + taskId, + environment, + { + // The webhook task run receives a { event, headers } envelope; the SDK's webhook() + // run unwraps it into onEvent({ event, headers }). + payload: { event: payload, headers }, + options: { + tags: identityTags, + metadata: (endpointMetadata as Record) ?? undefined, + }, + }, + { + idempotencyKey, + idempotencyKeyExpiresAt, + triggerSource: "webhook", + triggerAction: "trigger", + customIcon: "webhook", + } + ); + + return { success: !!result, runId: result?.run.id }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + let errorType: WebhookDeliverTaskErrorType = "SYSTEM_ERROR"; + + if ( + error instanceof ServiceValidationError && + errorMessage.includes("queue size limit for this environment has been reached") + ) { + errorType = "QUEUE_LIMIT"; + } + + return { success: false, error: errorMessage, errorType }; + } + }, + // Route a verified delivery to a session: find-or-create it, then append a webhook action to `.in`. + // The run boots on a preload payload (so onChatStart fires), then reads the action from `.in`. + deliverToSession: async ({ + environmentId, + taskIdentifier, + externalId, + deliverAs, + actionType, + connectorId, + event, + source, + headers, + deliveryId, + triggerConfigTemplate, + isSessionStart, + }) => { + try { + const environment = await findEnvironmentById(environmentId); + if (!environment) { + return { success: false, errorType: "NOT_FOUND", error: "Environment not found" }; + } + + const template = (triggerConfigTemplate ?? {}) as Partial; + const triggerConfig: SessionTriggerConfig = { + ...template, + basePayload: { + messages: [], + trigger: "preload", + chatId: externalId, + ...(template.basePayload ?? {}), + }, + }; + + // Resume an existing session; otherwise only START one when the event is a session-start + // (startOn). Resume-only with no session yet -> ignore (no session, no run, no egress). + const existing = await findSessionByExternalId(environment, externalId); + if (!existing && !isSessionStart) { + return { + success: true, + skipped: true, + skippedReason: "startOn: not a session-start event", + }; + } + const { session, isCached } = existing + ? { session: existing, isCached: true } + : await findOrCreateSession({ + environment, + externalId, + type: "chat.agent", + taskIdentifier, + triggerConfig, + }); + + if (session.closedAt || (session.expiresAt && session.expiresAt.getTime() < Date.now())) { + return { success: false, error: "Session is closed or expired" }; + } + + // Boot / revive the run, then append the action. The run reads it from `.in`. + const ensureResult = await ensureRunForSession({ + session, + environment, + reason: isCached ? "continuation" : "initial", + }); + + const realtimeStream = getRealtimeStreamInstance(environment, "v2", { session }); + if (!(realtimeStream instanceof S2RealtimeStreams)) { + return { success: false, error: "Session channels require the S2 realtime backend" }; + } + + const addressingKey = session.externalId ?? session.friendlyId; + // "action" (chat.event) -> onAction envelope; "message" (channels) -> a turn whose message the + // run derives by applying the connector's inbound() to the raw event. + const payload = + deliverAs === "message" + ? { + chatId: externalId, + trigger: "submit-message", + channelEvent: { connectorId, event, source, headers, deliveryId }, + } + : { + chatId: externalId, + trigger: "action", + actionSource: "webhook", + action: { type: actionType, event, source, headers, deliveryId }, + }; + const part = JSON.stringify({ kind: "message", payload }); + + // deliveryId as the part id → a deliver-job retry re-claims the same id and skips a duplicate + // append. The S2 record is durable, so a run that boots later still reads it. + const wonClaim = await claimSessionStreamPart( + environment.id, + addressingKey, + "in", + deliveryId + ); + if (wonClaim) { + const [appendError] = await tryCatch( + realtimeStream.appendPartToSessionStream(part, deliveryId, addressingKey, "in") + ); + if (appendError) { + // Nothing landed — release the claim so a retry re-appends the same id. + await releaseSessionStreamPart(environment.id, addressingKey, "in", deliveryId); + // A ServiceValidationError (e.g. record too large) is terminal; anything else is transient. + if (appendError instanceof ServiceValidationError) { + return { success: false, error: appendError.message }; + } + throw appendError; + } + } + + // Wake any `.in` waitpoints the run registered (best-effort; the record is durable in S2). + const [drainError, waitpointIds] = await tryCatch( + drainSessionStreamWaitpoints(environment.id, addressingKey, "in") + ); + if (drainError) { + logger.error("deliverToSession: failed to drain session waitpoints", { + externalId, + error: drainError, + }); + } else if (waitpointIds && waitpointIds.length > 0) { + await Promise.all( + waitpointIds.map((waitpointId) => + tryCatch( + runEngine.completeWaitpoint({ + id: waitpointId, + output: { value: part, type: "application/json", isError: false }, + }) + ) + ) + ); + } + + return { success: true, runId: ensureResult.runId }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + let errorType: WebhookDeliverTaskErrorType = "SYSTEM_ERROR"; + if ( + error instanceof ServiceValidationError && + errorMessage.includes("queue size limit for this environment has been reached") + ) { + errorType = "QUEUE_LIMIT"; + } + return { success: false, error: errorMessage, errorType }; + } + }, + }); + + return engine; +} diff --git a/apps/webapp/package.json b/apps/webapp/package.json index a9a670f77c..28abe146de 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -4,7 +4,7 @@ "version": "1.0.0", "sideEffects": false, "scripts": { - "build": "run-s build:** && pnpm run upload:sourcemaps", + "build": "pnpm run build:remix && pnpm run build:server && pnpm run build:otlpworker && pnpm run build:sentry && pnpm run upload:sourcemaps", "build:remix": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" remix vite:build", "build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build --sourcemap", "build:otlpworker": "esbuild --platform=node --format=cjs --bundle ./app/v3/otlpTransformWorker.ts --outfile=build/otlpTransformWorker.cjs --sourcemap", @@ -17,9 +17,11 @@ "start:local": "cross-env node --max-old-space-size=8192 ./build/server.js", "typecheck": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsc --noEmit -p ./tsconfig.check.json", "guard:runops-legacy": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsx ./scripts/runOpsLegacyGuard.ts", + "guard:fk-cascade-index": "tsx ./scripts/fkCascadeIndexGuard.ts", "db:seed": "tsx seed.ts", "db:seed:ai-spans": "tsx seed-ai-spans.mts", "db:seed:queue-metrics": "tsx seed-queue-metrics.mts", + "db:seed:webhooks": "tsx seed-webhook-deliveries.ts", "upload:sourcemaps": "bash ./upload-sourcemaps.sh", "test": "vitest --no-file-parallelism", "eval:dev": "evalite watch" @@ -65,6 +67,8 @@ "@internal/schedule-engine": "workspace:*", "@internal/tracing": "workspace:*", "@internal/tsql": "workspace:*", + "@internal/webhook-engine": "workspace:*", + "@internal/webhook-sources": "workspace:*", "@internationalized/date": "^3.5.1", "@jsonhero/schema-infer": "^0.1.5", "@kapaai/react-sdk": "^0.1.3", @@ -166,7 +170,7 @@ "marked": "^4.0.18", "match-sorter": "^6.3.4", "morgan": "^1.10.0", - "nanoid": "3.3.8", + "nanoid": "3.3.18", "neverthrow": "^8.2.0", "non.geist": "^1.0.2", "octokit": "^3.2.1", @@ -205,7 +209,7 @@ "semver": "^7.5.0", "simplur": "^3.0.1", "slug": "^6.0.0", - "socket.io": "4.7.4", + "socket.io": "4.8.3", "socket.io-adapter": "^2.5.4", "sonner": "^1.0.3", "sql-formatter": "^15.4.10", @@ -260,10 +264,9 @@ "autoevals": "^0.0.130", "css-loader": "^6.10.0", "datepicker": "link:@types/@react-aria/datepicker", - "engine.io": "^6.5.4", + "engine.io": "^6.6.7", "esbuild": "^0.15.10", "evalite": "1.0.0-beta.16", - "npm-run-all": "^4.1.5", "postcss-import": "^16.0.1", "postcss-loader": "^8.1.1", "rimraf": "^6.0.1", diff --git a/apps/webapp/scripts/fk-cascade-index-baseline.json b/apps/webapp/scripts/fk-cascade-index-baseline.json new file mode 100644 index 0000000000..cd2592f6d8 --- /dev/null +++ b/apps/webapp/scripts/fk-cascade-index-baseline.json @@ -0,0 +1,365 @@ +{ + "_comment": "Accepted unindexed cascade/SetNull FK columns. Each is either a soft-deleted parent (cascade never fires) or an accepted risk. Adding a NEW relation here should be a deliberate choice with a reason in the PR. Prefer adding the index instead.", + "violations": [ + { + "key": "control-plane:ApiKey.createdBy", + "onDelete": "SetNull", + "fkColumns": ["createdByUserId"] + }, + { + "key": "control-plane:AuthorizationCode.personalAccessToken", + "onDelete": "Cascade", + "fkColumns": ["personalAccessTokenId"] + }, + { + "key": "control-plane:BackgroundWorker.workerGroup", + "onDelete": "SetNull", + "fkColumns": ["workerGroupId"] + }, + { + "key": "control-plane:BackgroundWorkerTask.file", + "onDelete": "Cascade", + "fkColumns": ["fileId"] + }, + { + "key": "control-plane:BackgroundWorkerTask.queue", + "onDelete": "SetNull", + "fkColumns": ["queueId"] + }, + { + "key": "control-plane:BulkActionGroup.project", + "onDelete": "Cascade", + "fkColumns": ["projectId"] + }, + { + "key": "control-plane:BulkActionGroup.user", + "onDelete": "SetNull", + "fkColumns": ["userId"] + }, + { + "key": "control-plane:BulkActionItem.destinationRun", + "onDelete": "Cascade", + "fkColumns": ["destinationRunId"] + }, + { + "key": "control-plane:BulkActionItem.sourceRun", + "onDelete": "Cascade", + "fkColumns": ["sourceRunId"] + }, + { + "key": "control-plane:Checkpoint.project", + "onDelete": "Cascade", + "fkColumns": ["projectId"] + }, + { + "key": "control-plane:Checkpoint.runtimeEnvironment", + "onDelete": "Cascade", + "fkColumns": ["runtimeEnvironmentId"] + }, + { + "key": "control-plane:CheckpointRestoreEvent.attempt", + "onDelete": "Cascade", + "fkColumns": ["attemptId"] + }, + { + "key": "control-plane:CheckpointRestoreEvent.project", + "onDelete": "Cascade", + "fkColumns": ["projectId"] + }, + { + "key": "control-plane:CheckpointRestoreEvent.runtimeEnvironment", + "onDelete": "Cascade", + "fkColumns": ["runtimeEnvironmentId"] + }, + { + "key": "control-plane:CustomerQuery.environment", + "onDelete": "Cascade", + "fkColumns": ["environmentId"] + }, + { + "key": "control-plane:CustomerQuery.project", + "onDelete": "Cascade", + "fkColumns": ["projectId"] + }, + { + "key": "control-plane:CustomerQuery.user", + "onDelete": "SetNull", + "fkColumns": ["userId"] + }, + { + "key": "control-plane:ErrorGroupState.organization", + "onDelete": "Cascade", + "fkColumns": ["organizationId"] + }, + { + "key": "control-plane:ErrorGroupState.project", + "onDelete": "Cascade", + "fkColumns": ["projectId"] + }, + { + "key": "control-plane:LlmPrice.pricingTier", + "onDelete": "Cascade", + "fkColumns": ["pricingTierId"] + }, + { + "key": "control-plane:MetricsDashboard.organization", + "onDelete": "Cascade", + "fkColumns": ["organizationId"] + }, + { + "key": "control-plane:MetricsDashboard.owner", + "onDelete": "SetNull", + "fkColumns": ["ownerId"] + }, + { + "key": "control-plane:OrganizationIntegration.organization", + "onDelete": "Cascade", + "fkColumns": ["organizationId"] + }, + { + "key": "control-plane:OrganizationIntegration.tokenReference", + "onDelete": "Cascade", + "fkColumns": ["tokenReferenceId"] + }, + { + "key": "control-plane:OrganizationProjectIntegration.organizationIntegration", + "onDelete": "Cascade", + "fkColumns": ["organizationIntegrationId"] + }, + { + "key": "control-plane:OrgMember.user", + "onDelete": "Cascade", + "fkColumns": ["userId"] + }, + { + "key": "control-plane:OrgMemberInvite.inviter", + "onDelete": "Cascade", + "fkColumns": ["inviterId"] + }, + { + "key": "control-plane:PlatformNotification.organization", + "onDelete": "Cascade", + "fkColumns": ["organizationId"] + }, + { + "key": "control-plane:PlatformNotification.project", + "onDelete": "Cascade", + "fkColumns": ["projectId"] + }, + { + "key": "control-plane:PlatformNotification.user", + "onDelete": "Cascade", + "fkColumns": ["userId"] + }, + { + "key": "control-plane:PlatformNotificationInteraction.user", + "onDelete": "Cascade", + "fkColumns": ["userId"] + }, + { + "key": "control-plane:PlaygroundConversation.project", + "onDelete": "Cascade", + "fkColumns": ["projectId"] + }, + { + "key": "control-plane:PlaygroundConversation.run", + "onDelete": "SetNull", + "fkColumns": ["runId"] + }, + { + "key": "control-plane:Project.organization", + "onDelete": "Cascade", + "fkColumns": ["organizationId"] + }, + { + "key": "control-plane:ProjectAlert.environment", + "onDelete": "Cascade", + "fkColumns": ["environmentId"] + }, + { + "key": "control-plane:ProjectAlert.project", + "onDelete": "Cascade", + "fkColumns": ["projectId"] + }, + { + "key": "control-plane:ProjectAlert.workerDeployment", + "onDelete": "Cascade", + "fkColumns": ["workerDeploymentId"] + }, + { + "key": "control-plane:ProjectAlertChannel.integration", + "onDelete": "SetNull", + "fkColumns": ["integrationId"] + }, + { + "key": "control-plane:ProjectAlertStorage.project", + "onDelete": "Cascade", + "fkColumns": ["projectId"] + }, + { + "key": "control-plane:Prompt.organization", + "onDelete": "Cascade", + "fkColumns": ["organizationId"] + }, + { + "key": "control-plane:RuntimeEnvironment.currentSession", + "onDelete": "SetNull", + "fkColumns": ["currentSessionId"] + }, + { + "key": "control-plane:RuntimeEnvironment.orgMember", + "onDelete": "SetNull", + "fkColumns": ["orgMemberId"] + }, + { + "key": "control-plane:RuntimeEnvironmentSession.environment", + "onDelete": "Cascade", + "fkColumns": ["environmentId"] + }, + { + "key": "control-plane:TaskIdentifier.currentWorker", + "onDelete": "SetNull", + "fkColumns": ["currentWorkerId"] + }, + { + "key": "control-plane:TaskIdentifier.project", + "onDelete": "Cascade", + "fkColumns": ["projectId"] + }, + { + "key": "control-plane:TaskQueue.project", + "onDelete": "Cascade", + "fkColumns": ["projectId"] + }, + { + "key": "control-plane:TaskRun.batch", + "onDelete": "SetNull", + "fkColumns": ["batchId"] + }, + { + "key": "control-plane:TaskRun.parentTaskRunAttempt", + "onDelete": "SetNull", + "fkColumns": ["parentTaskRunAttemptId"] + }, + { + "key": "control-plane:TaskRun.project", + "onDelete": "Cascade", + "fkColumns": ["projectId"] + }, + { + "key": "control-plane:TaskRun.rootTaskRun", + "onDelete": "SetNull", + "fkColumns": ["rootTaskRunId"] + }, + { + "key": "control-plane:TaskRunAttempt.backgroundWorker", + "onDelete": "Cascade", + "fkColumns": ["backgroundWorkerId"] + }, + { + "key": "control-plane:TaskRunAttempt.backgroundWorkerTask", + "onDelete": "Cascade", + "fkColumns": ["backgroundWorkerTaskId"] + }, + { + "key": "control-plane:TaskRunAttempt.queue", + "onDelete": "Cascade", + "fkColumns": ["queueId"] + }, + { + "key": "control-plane:TaskRunAttempt.runtimeEnvironment", + "onDelete": "Cascade", + "fkColumns": ["runtimeEnvironmentId"] + }, + { + "key": "control-plane:TaskRunCheckpoint.project", + "onDelete": "Cascade", + "fkColumns": ["projectId"] + }, + { + "key": "control-plane:TaskRunCheckpoint.runtimeEnvironment", + "onDelete": "Cascade", + "fkColumns": ["runtimeEnvironmentId"] + }, + { + "key": "control-plane:TaskRunNumberCounter.environment", + "onDelete": "Cascade", + "fkColumns": ["environmentId"] + }, + { + "key": "control-plane:TaskRunWaitpoint.project", + "onDelete": "Cascade", + "fkColumns": ["projectId"] + }, + { + "key": "control-plane:Waitpoint.project", + "onDelete": "Cascade", + "fkColumns": ["projectId"] + }, + { + "key": "control-plane:WaitpointTag.project", + "onDelete": "Cascade", + "fkColumns": ["projectId"] + }, + { + "key": "control-plane:WorkerDeployment.triggeredBy", + "onDelete": "SetNull", + "fkColumns": ["triggeredById"] + }, + { + "key": "control-plane:WorkerDeploymentPromotion.deployment", + "onDelete": "Cascade", + "fkColumns": ["deploymentId"] + }, + { + "key": "control-plane:WorkerInstance.deployment", + "onDelete": "SetNull", + "fkColumns": ["deploymentId"] + }, + { + "key": "control-plane:WorkerInstance.environment", + "onDelete": "Cascade", + "fkColumns": ["environmentId"] + }, + { + "key": "control-plane:WorkerInstance.organization", + "onDelete": "Cascade", + "fkColumns": ["organizationId"] + }, + { + "key": "control-plane:WorkerInstance.project", + "onDelete": "Cascade", + "fkColumns": ["projectId"] + }, + { + "key": "control-plane:WorkerInstanceGroup.organization", + "onDelete": "Cascade", + "fkColumns": ["organizationId"] + }, + { + "key": "control-plane:WorkerInstanceGroup.project", + "onDelete": "Cascade", + "fkColumns": ["projectId"] + }, + { + "key": "run-ops:CheckpointRestoreEvent.attempt", + "onDelete": "Cascade", + "fkColumns": ["attemptId"] + }, + { + "key": "run-ops:TaskRun.batch", + "onDelete": "SetNull", + "fkColumns": ["batchId"] + }, + { + "key": "run-ops:TaskRun.parentTaskRunAttempt", + "onDelete": "SetNull", + "fkColumns": ["parentTaskRunAttemptId"] + }, + { + "key": "run-ops:TaskRun.rootTaskRun", + "onDelete": "SetNull", + "fkColumns": ["rootTaskRunId"] + } + ] +} diff --git a/apps/webapp/scripts/fkCascadeIndexGuard.ts b/apps/webapp/scripts/fkCascadeIndexGuard.ts new file mode 100644 index 0000000000..558f0439c9 --- /dev/null +++ b/apps/webapp/scripts/fkCascadeIndexGuard.ts @@ -0,0 +1,328 @@ +/** + * FK-cascade index guard — a schema-aware fence against the class of bug fixed in + * #4554, #4555, #4588: a relation with `onDelete: Cascade | SetNull` whose child FK column + * has no index, so every parent delete fires a cascade that sequentially scans the whole + * child table. + * + * Rule: for every relation field carrying `onDelete: Cascade | SetNull` (which always sits on + * the child side, alongside `fields: [...]`), the FIRST FK scalar must be the LEADING column + * of some index on that model (`@@index`, `@@unique`, `@@id`, or a field-level `@id`/`@unique`). + * A leading FK column lets the cascade's `WHERE fk = $1` use the index instead of a seq scan. + * + * Not every unindexed cascade FK is a live bug: when the parent is only ever SOFT-deleted, the + * cascade never fires, so the missing index is harmless. The guard CANNOT tell hard- from + * soft-delete — that lives in application code (`parent.delete()` vs `parent.update({ deletedAt })`), + * not in the schema, and a `deletedAt` column proves neither direction. So the guard makes no + * such judgment: it flags every unindexed cascade FK uniformly and — exactly like + * runOpsLegacyGuard — carries a BASELINE of the currently accepted ones. Only violations NOT in + * the baseline fail `--check`. The point is the forcing function: a new cascade FK stops CI and + * makes a human answer "is the parent ever hard-deleted?" — add the index if yes, regenerate the + * baseline with a reason if no. + * + * A baseline entry is matched on its key AND its onDelete action AND its ordered fkColumns, so + * changing a relation's FK column or flipping Cascade/SetNull re-triggers the guard rather than + * silently inheriting the old acceptance. `--check` also fails on STALE baseline entries whose + * fingerprint no longer appears in the schema (the FK got indexed or removed), so the baseline + * can't rot: a fixed entry must be pruned by regenerating, otherwise a later change that removes + * the index would be silently re-accepted by the leftover entry. + * + * Modes (mirrors guard:runops-legacy): + * tsx ./scripts/fkCascadeIndexGuard.ts # regenerate the baseline + * tsx ./scripts/fkCascadeIndexGuard.ts --check # CI gate: exit 1 on any un-baselined violation + */ +import * as fs from "node:fs"; +import * as path from "node:path"; + +function findRepoRoot(start: string): string { + let dir = path.resolve(start); + for (;;) { + if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) return dir; + const parent = path.dirname(dir); + if (parent === dir) + throw new Error("Could not locate repo root (pnpm-workspace.yaml not found)"); + dir = parent; + } +} + +const REPO_ROOT = findRepoRoot(process.cwd()); + +const SCHEMAS = [ + { + label: "control-plane", + file: path.join(REPO_ROOT, "internal-packages", "database", "prisma", "schema.prisma"), + }, + { + label: "run-ops", + file: path.join(REPO_ROOT, "internal-packages", "run-ops-database", "prisma", "schema.prisma"), + }, +]; + +const BASELINE_PATH = path.join( + REPO_ROOT, + "apps", + "webapp", + "scripts", + "fk-cascade-index-baseline.json" +); + +type Violation = { + key: string; + schema: string; + model: string; + relationField: string; + fkColumns: string[]; + onDelete: string; +}; + +function fingerprint(key: string, onDelete: string, fkColumns: string[]): string { + return `${key}::${onDelete}::${fkColumns.join(",")}`; +} + +function scrubLine(line: string): { code: string; masked: string } { + let code = ""; + let masked = ""; + let inString = false; + let escaped = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inString) { + code += ch; + masked += " "; + if (escaped) escaped = false; + else if (ch === "\\") escaped = true; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') { + inString = true; + code += ch; + masked += " "; + continue; + } + if (ch === "/" && line[i + 1] === "/") break; + code += ch; + masked += ch; + } + return { code, masked }; +} + +function toLogicalLines(body: string): string[] { + const out: string[] = []; + let buf = ""; + let depth = 0; + for (const raw of body.split("\n")) { + const { code, masked } = scrubLine(raw); + const trimmed = code.trim(); + if (trimmed === "") continue; + buf = buf === "" ? trimmed : `${buf} ${trimmed}`; + for (const ch of masked) { + if (ch === "(" || ch === "[") depth++; + else if (ch === ")" || ch === "]") depth = Math.max(0, depth - 1); + } + if (depth === 0) { + out.push(buf); + buf = ""; + } + } + if (buf !== "") out.push(buf); + return out; +} + +function leadingColumn(bracketBody: string): string | null { + const first = bracketBody.split(",")[0]?.trim(); + if (!first) return null; + const m = /^([A-Za-z_][A-Za-z0-9_]*)/.exec(first); + return m ? m[1] : null; +} + +function allColumns(bracketBody: string): string[] { + return bracketBody + .split(",") + .map((c) => /^\s*([A-Za-z_][A-Za-z0-9_]*)/.exec(c)?.[1]) + .filter((c): c is string => Boolean(c)); +} + +function scanSchema(label: string, file: string): Violation[] { + const text = fs.readFileSync(file, "utf8"); + const violations: Violation[] = []; + + const modelRe = /^model\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{([\s\S]*?)^\}/gm; + let mm: RegExpExecArray | null; + while ((mm = modelRe.exec(text))) { + const model = mm[1]; + const lines = toLogicalLines(mm[2]); + + const leadingIndexed = new Set(); + for (const line of lines) { + const block = /^@@(index|unique|id)\(\s*\[([^\]]*)\]/.exec(line); + if (block) { + const lead = leadingColumn(block[2]); + if (lead) leadingIndexed.add(lead); + continue; + } + const fieldDecl = /^([A-Za-z_][A-Za-z0-9_]*)\s+\S+.*@(id|unique)\b/.exec(line); + if (fieldDecl && !line.startsWith("@@")) { + leadingIndexed.add(fieldDecl[1]); + } + } + + for (const line of lines) { + if (!line.includes("@relation(")) continue; + const onDelete = /onDelete:\s*(Cascade|SetNull)/.exec(line); + if (!onDelete) continue; + const fields = /fields:\s*\[([^\]]*)\]/.exec(line); + if (!fields) continue; + const fkColumns = allColumns(fields[1]); + const lead = fkColumns[0]; + if (!lead) continue; + const relationField = /^([A-Za-z_][A-Za-z0-9_]*)/.exec(line)?.[1] ?? "?"; + + if (!leadingIndexed.has(lead)) { + violations.push({ + key: `${label}:${model}.${relationField}`, + schema: label, + model, + relationField, + fkColumns, + onDelete: onDelete[1], + }); + } + } + } + return violations; +} + +function serializeBaseline(comment: string, violations: Violation[]): string { + const lines: string[] = ["{", ` "_comment": ${JSON.stringify(comment)},`, ` "violations": [`]; + violations.forEach((v, i) => { + const trailer = i < violations.length - 1 ? "," : ""; + lines.push( + " {", + ` "key": ${JSON.stringify(v.key)},`, + ` "onDelete": ${JSON.stringify(v.onDelete)},`, + ` "fkColumns": ${JSON.stringify(v.fkColumns)}`, + ` }${trailer}` + ); + }); + lines.push(" ]", "}"); + return lines.join("\n") + "\n"; +} + +type BaselineEntry = { key: string; onDelete: string; fkColumns: string[] }; + +function loadBaseline(): BaselineEntry[] { + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8")); + } catch { + console.error(`baseline is not valid JSON: ${BASELINE_PATH}. Regenerate it without --check.`); + process.exit(2); + } + const violations = (parsed as { violations?: unknown }).violations; + if (!Array.isArray(violations)) { + console.error(`baseline is missing a "violations" array: ${BASELINE_PATH}.`); + process.exit(2); + } + const entries: BaselineEntry[] = []; + for (const v of violations) { + const entry = v as { key?: unknown; onDelete?: unknown; fkColumns?: unknown }; + if ( + typeof entry.key !== "string" || + (entry.onDelete !== "Cascade" && entry.onDelete !== "SetNull") || + !Array.isArray(entry.fkColumns) || + entry.fkColumns.length === 0 || + !entry.fkColumns.every((c) => typeof c === "string") + ) { + console.error(`baseline has a malformed entry: ${JSON.stringify(v)}`); + process.exit(2); + } + entries.push({ + key: entry.key, + onDelete: entry.onDelete, + fkColumns: entry.fkColumns as string[], + }); + } + return entries; +} + +function main() { + const check = process.argv.includes("--check"); + + const all: Violation[] = []; + for (const s of SCHEMAS) { + if (!fs.existsSync(s.file)) { + console.error(`schema not found: ${s.file}`); + process.exit(2); + } + all.push(...scanSchema(s.label, s.file)); + } + all.sort((a, b) => a.key.localeCompare(b.key)); + + if (!check) { + const comment = + "Accepted unindexed cascade/SetNull FK columns. Each is either a soft-deleted parent " + + "(cascade never fires) or an accepted risk. Adding a NEW relation here should be a " + + "deliberate choice with a reason in the PR. Prefer adding the index instead."; + fs.writeFileSync(BASELINE_PATH, serializeBaseline(comment, all)); + console.log(`Wrote baseline with ${all.length} accepted unindexed cascade FK(s).`); + console.log(` -> ${path.relative(REPO_ROOT, BASELINE_PATH)}`); + return; + } + + if (!fs.existsSync(BASELINE_PATH)) { + console.error(`baseline missing: ${BASELINE_PATH}. Run without --check to generate it.`); + process.exit(2); + } + const baselineEntries = loadBaseline(); + const baselinedFps = new Set( + baselineEntries.map((e) => fingerprint(e.key, e.onDelete, e.fkColumns)) + ); + const currentFps = new Set(all.map((v) => fingerprint(v.key, v.onDelete, v.fkColumns))); + + const fresh = all.filter((v) => !baselinedFps.has(fingerprint(v.key, v.onDelete, v.fkColumns))); + const stale = baselineEntries.filter( + (e) => !currentFps.has(fingerprint(e.key, e.onDelete, e.fkColumns)) + ); + + if (fresh.length === 0 && stale.length === 0) { + console.log(`fk-cascade-index guard: OK (${baselinedFps.size} baselined, 0 new, 0 stale).`); + return; + } + + if (fresh.length > 0) { + console.error( + `\nfk-cascade-index guard: ${fresh.length} new unindexed cascade FK column(s).\n` + + `Each fires a full sequential scan of the child table on every parent delete.\n` + ); + for (const v of fresh) { + console.error( + ` ${v.schema}: ${v.model}.${v.relationField} ` + + `(onDelete: ${v.onDelete}, fk: [${v.fkColumns.join(", ")}])` + ); + } + console.error( + `\nFix: add @@index([${fresh[0].fkColumns[0]}]) (or a composite leading with it) to the ` + + `child model, in its own migration with CREATE INDEX CONCURRENTLY IF NOT EXISTS.\n` + + `If the parent is only ever soft-deleted (cascade never fires), regenerate the baseline ` + + `and explain why in the PR.\n` + ); + } + + if (stale.length > 0) { + console.error( + `\nfk-cascade-index guard: ${stale.length} stale baseline entr${stale.length === 1 ? "y" : "ies"} ` + + `no longer present in the schema (now indexed or removed):\n` + ); + for (const e of stale) { + console.error(` ${e.key} (onDelete: ${e.onDelete}, fk: [${e.fkColumns.join(", ")}])`); + } + console.error( + `\nRegenerate the baseline so a later change can't silently re-accept these:\n` + + ` pnpm --filter webapp run guard:fk-cascade-index\n` + ); + } + + process.exit(1); +} + +main(); diff --git a/apps/webapp/seed-queue-metrics.mts b/apps/webapp/seed-queue-metrics.mts index 32b3e89b4d..836afd5f77 100644 --- a/apps/webapp/seed-queue-metrics.mts +++ b/apps/webapp/seed-queue-metrics.mts @@ -1,11 +1,13 @@ import { boundedIn } from "@trigger.dev/database"; import { ClickHouse } from "@internal/clickhouse"; import type { QueueMetricsRawV1Input } from "@internal/clickhouse"; -// App modules compile to CommonJS under tsx, so import them as default bindings. +// App modules compile to CommonJS under tsx, so these intentionally use default bindings. +// oxlint-disable import/default import dbServer from "./app/db.server"; import organizationServer from "./app/models/organization.server"; import projectServer from "./app/models/project.server"; import friendlyIdentifiers from "./app/v3/friendlyIdentifiers"; +// oxlint-enable import/default const { prisma } = dbServer; const { createOrganization } = organizationServer; diff --git a/apps/webapp/seed-webhook-deliveries.ts b/apps/webapp/seed-webhook-deliveries.ts new file mode 100644 index 0000000000..436a7eaa9a --- /dev/null +++ b/apps/webapp/seed-webhook-deliveries.ts @@ -0,0 +1,599 @@ +import "dotenv/config"; +import { randomBytes, createHash } from "node:crypto"; +import { nanoid } from "nanoid"; +import { prisma, webhookPrisma } from "./app/db.server"; + +/** + * Seed a rich, stable spread of webhook endpoints and deliveries for design work. + * + * Attaches several endpoints (varied providers, verifier schemes, and status) and a + * spread of deliveries across the last two weeks (every delivery status, realistic + * payloads + headers, a mix of test and live) to an existing DEVELOPMENT environment. + * Writes the delivery rows to both Postgres (the source of every displayed field) and + * ClickHouse (which orders and paginates the Deliveries list). + * + * By default it targets the first DEVELOPMENT environment the local user can see; set + * WEBHOOK_SEED_PROJECT to a project name to pick a specific one. + * + * Re-runnable: it clears that environment's webhook deliveries first, so each run yields + * the same clean dataset. Stale ClickHouse rows self-hide because the list drops any + * ordered id whose Postgres row is gone. + * + * pnpm --filter webapp run db:seed:webhooks + * pnpm --filter webapp run db:seed:webhooks -- 60 # deliveries per endpoint + */ + +const PER_ENDPOINT = Number.parseInt(process.argv[2] ?? "", 10) || 45; +const SPREAD_DAYS = 14; +const DAY_MS = 86_400_000; + +type Scheme = "hmac" | "shared-secret" | "url-secret" | "asymmetric"; + +type EndpointSpec = { + source: string; + handlerWebhookId: string; + scheme: Scheme; + verifierArtifact: unknown; + secretProvisioning: "provider" | "integrator" | "either"; + hasSecret: boolean; + status: "ACTIVE" | "INACTIVE"; + tenantId: string; + externalRef: string; + events: Array<{ type: string; body: () => Record }>; + signatureHeader: string; + userAgent: string; +}; + +function hmacArtifact(preset: string, header: string, extra: Record = {}) { + return { + kind: "preset" as const, + preset, + config: { + scheme: "hmac" as const, + algorithm: "sha256" as const, + encoding: "hex" as const, + signatureHeader: header, + signingString: "raw" as const, + ...extra, + }, + }; +} + +const ENDPOINTS: EndpointSpec[] = [ + { + source: "stripe", + handlerWebhookId: "stripe-webhook", + scheme: "hmac", + signatureHeader: "Stripe-Signature", + userAgent: "Stripe/1.0 (+https://stripe.com/docs/webhooks)", + verifierArtifact: hmacArtifact("stripe", "Stripe-Signature", { + signature: { itemSeparator: ",", fieldSeparator: "=", field: "v1", trim: true }, + timestamp: { + source: { from: "signatureField", field: "t" }, + unit: "seconds", + toleranceSeconds: 300, + }, + signingString: { + template: "{t}.{body}", + vars: { t: { from: "signatureField", field: "t" } }, + }, + }), + secretProvisioning: "provider", + hasSecret: true, + status: "ACTIVE", + tenantId: "", + externalRef: "", + events: [ + { + type: "payment_intent.succeeded", + body: () => + stripeEvent("payment_intent.succeeded", { + amount: 4200, + currency: "usd", + status: "succeeded", + }), + }, + { + type: "charge.refunded", + body: () => + stripeEvent("charge.refunded", { amount: 4200, currency: "usd", refunded: true }), + }, + { + type: "customer.subscription.updated", + body: () => + stripeEvent("customer.subscription.updated", { status: "active", plan: "pro_monthly" }), + }, + { + type: "invoice.paid", + body: () => stripeEvent("invoice.paid", { amount_paid: 9900, currency: "usd" }), + }, + ], + }, + { + source: "github", + handlerWebhookId: "github-webhook", + scheme: "hmac", + signatureHeader: "X-Hub-Signature-256", + userAgent: "GitHub-Hookshot/a1b2c3", + verifierArtifact: hmacArtifact("github", "X-Hub-Signature-256"), + secretProvisioning: "integrator", + hasSecret: true, + status: "ACTIVE", + tenantId: "", + externalRef: "", + events: [ + { + type: "push", + body: () => ({ + ref: "refs/heads/main", + commits: [{ id: nanoid(), message: "fix: tidy up" }], + pusher: { name: "octocat" }, + }), + }, + { + type: "pull_request", + body: () => ({ + action: "opened", + number: 42, + pull_request: { title: "Add webhooks", user: { login: "octocat" } }, + }), + }, + { + type: "issues", + body: () => ({ + action: "opened", + issue: { number: 7, title: "Docs typo", user: { login: "hubot" } }, + }), + }, + { + type: "star", + body: () => ({ + action: "created", + starred_at: new Date().toISOString(), + sender: { login: "fan" }, + }), + }, + ], + }, + { + source: "slack", + handlerWebhookId: "slack-channel", + scheme: "hmac", + signatureHeader: "X-Slack-Signature", + userAgent: "Slackbot 1.0 (+https://api.slack.com/robots)", + verifierArtifact: hmacArtifact("slack", "X-Slack-Signature", { + timestamp: { + source: { from: "header", name: "X-Slack-Request-Timestamp" }, + unit: "seconds", + toleranceSeconds: 300, + }, + signingString: { + template: "v0:{t}:{body}", + vars: { t: { from: "header", name: "X-Slack-Request-Timestamp" } }, + }, + }), + secretProvisioning: "provider", + hasSecret: true, + status: "ACTIVE", + tenantId: "T0288ANLG", + externalRef: "app_mention", + events: [ + { + type: "app_mention", + body: () => ({ + type: "event_callback", + event: { + type: "app_mention", + text: "<@U123> ship it", + user: "U0G9QF9C6", + channel: "C0288ANLG", + }, + }), + }, + { + type: "message", + body: () => ({ + type: "event_callback", + event: { type: "message", text: "any update?", user: "U0G9QF9C6", channel: "C0288ANLG" }, + }), + }, + ], + }, + { + source: "svix", + handlerWebhookId: "svix-webhook", + scheme: "hmac", + signatureHeader: "webhook-signature", + userAgent: "Svix-Webhooks/1.4", + verifierArtifact: hmacArtifact("svix", "webhook-signature", { + encoding: "base64", + signature: { itemSeparator: " ", fieldSeparator: ",", field: "v1" }, + secret: { encoding: "base64", stripPrefix: "whsec_" }, + }), + secretProvisioning: "provider", + hasSecret: false, + status: "ACTIVE", + tenantId: "", + externalRef: "", + events: [ + { + type: "invoice.created", + body: () => ({ type: "invoice.created", data: { id: `in_${nanoid()}`, total: 1200 } }), + }, + { + type: "message.sent", + body: () => ({ + type: "message.sent", + data: { id: `msg_${nanoid()}`, to: "user@example.com" }, + }), + }, + ], + }, + { + source: "discord", + handlerWebhookId: "discord-interactions", + scheme: "asymmetric", + signatureHeader: "X-Signature-Ed25519", + userAgent: "Discord-Interactions/1.0 (+https://discord.com)", + verifierArtifact: { + kind: "preset" as const, + preset: "discord", + config: { + scheme: "asymmetric" as const, + algorithm: "ed25519" as const, + encoding: "hex" as const, + signatureHeader: "X-Signature-Ed25519", + timestamp: { + source: { from: "header" as const, name: "X-Signature-Timestamp" }, + unit: "seconds" as const, + }, + signingString: { + template: "{t}{body}", + vars: { t: { from: "header" as const, name: "X-Signature-Timestamp" } }, + }, + publicKeyEncoding: "raw-hex" as const, + }, + }, + secretProvisioning: "provider", + hasSecret: true, + status: "ACTIVE", + tenantId: "", + externalRef: "", + events: [ + { + type: "INTERACTION", + body: () => ({ type: 2, data: { name: "deploy" }, member: { user: { username: "dev" } } }), + }, + ], + }, + { + source: "custom", + handlerWebhookId: "orders-webhook", + scheme: "shared-secret", + signatureHeader: "X-Webhook-Token", + userAgent: "acme-orders/2.3", + verifierArtifact: { + kind: "config" as const, + config: { + scheme: "shared-secret" as const, + placement: "header" as const, + fieldName: "X-Webhook-Token", + }, + }, + secretProvisioning: "either", + hasSecret: true, + status: "INACTIVE", + tenantId: "acct_9f2", + externalRef: "orders", + events: [ + { + type: "order.created", + body: () => ({ + event: "order.created", + orderId: `ord_${nanoid()}`, + total: 129.99, + currency: "USD", + }), + }, + { + type: "order.fulfilled", + body: () => ({ event: "order.fulfilled", orderId: `ord_${nanoid()}`, carrier: "ups" }), + }, + ], + }, +]; + +function stripeEvent(type: string, data: Record): Record { + return { + id: `evt_${nanoid()}`, + object: "event", + type, + created: Math.floor(Date.now() / 1000), + data: { object: { id: `obj_${nanoid()}`, ...data } }, + }; +} + +const STATUS_WEIGHTS: Array<[string, number]> = [ + ["SUCCEEDED", 68], + ["FAILED", 12], + ["FILTERED", 11], + ["PROCESSING", 4], + ["PENDING", 5], +]; + +const FAIL_REASONS = [ + "Signature verification failed", + "Signing secret not set for endpoint", + "Timestamp outside tolerance window", +]; + +function pickStatus(): string { + const total = STATUS_WEIGHTS.reduce((s, [, w]) => s + w, 0); + let r = Math.random() * total; + for (const [status, w] of STATUS_WEIGHTS) { + if ((r -= w) <= 0) return status; + } + return "SUCCEEDED"; +} + +function biasedCreatedAt(now: number): Date { + const r = Math.random() ** 1.7; + return new Date(now - r * SPREAD_DAYS * DAY_MS); +} + +function dayPartitionName(d: Date): { name: string; lo: string; hi: string } { + const floor = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); + const hi = new Date(floor.getTime() + DAY_MS); + const y = floor.getUTCFullYear(); + const m = String(floor.getUTCMonth() + 1).padStart(2, "0"); + const day = String(floor.getUTCDate()).padStart(2, "0"); + return { + name: `WebhookDelivery_${y}_${m}_${day}`, + lo: floor.toISOString(), + hi: hi.toISOString(), + }; +} + +async function main() { + const user = await prisma.user.findUnique({ where: { email: "local@trigger.dev" } }); + if (!user) { + console.error("User local@trigger.dev not found. Run `pnpm run db:seed` first."); + process.exit(1); + } + + const projectName = process.env.WEBHOOK_SEED_PROJECT; + const runtimeEnv = await prisma.runtimeEnvironment.findFirst({ + where: { + type: "DEVELOPMENT", + organization: { members: { some: { userId: user.id } } }, + ...(projectName ? { project: { name: projectName } } : {}), + }, + include: { project: true, organization: true }, + orderBy: { createdAt: "asc" }, + }); + if (!runtimeEnv) { + console.error( + projectName + ? `No DEVELOPMENT environment found for project "${projectName}". Run \`pnpm run db:seed\` first.` + : "No DEVELOPMENT environment found. Run `pnpm run db:seed` first." + ); + process.exit(1); + } + const org = runtimeEnv.organization; + const project = runtimeEnv.project; + console.log(`Seeding into ${org.title} / ${project.name} (env ${runtimeEnv.slug})`); + + const scope = { + organizationId: org.id, + projectId: project.id, + runtimeEnvironmentId: runtimeEnv.id, + environmentType: runtimeEnv.type, + }; + + console.log("Clearing previous demo deliveries..."); + await webhookPrisma.webhookDelivery.deleteMany({ + where: { runtimeEnvironmentId: runtimeEnv.id }, + }); + + const endpoints: Array<{ spec: EndpointSpec; id: string; friendlyId: string }> = []; + for (const spec of ENDPOINTS) { + const friendlyId = `wh_${nanoid()}`; + const created = await webhookPrisma.webhookEndpoint.upsert({ + where: { + runtimeEnvironmentId_handlerWebhookId_endpointTenantId_endpointExternalRef: { + runtimeEnvironmentId: runtimeEnv.id, + handlerWebhookId: spec.handlerWebhookId, + endpointTenantId: spec.tenantId, + endpointExternalRef: spec.externalRef, + }, + }, + update: { + status: spec.status, + verifierArtifact: spec.verifierArtifact as object, + secretProvisioning: spec.secretProvisioning, + }, + create: { + friendlyId, + opaqueId: randomBytes(16).toString("base64url"), + ...scope, + endpointTenantId: spec.tenantId, + endpointExternalRef: spec.externalRef, + source: spec.source, + handlerWebhookId: spec.handlerWebhookId, + routingTarget: { type: "task", taskId: spec.handlerWebhookId } as object, + verifierArtifact: spec.verifierArtifact as object, + secretProvisioning: spec.secretProvisioning, + signingSecretKey: spec.hasSecret ? `webhook:signing-secret:seed-${spec.source}` : null, + status: spec.status, + metadata: { seeded: true, provider: spec.source } as object, + }, + }); + endpoints.push({ spec, id: created.id, friendlyId: created.friendlyId }); + } + console.log(`Endpoints ready: ${endpoints.length}`); + + const now = Date.now(); + type Row = { + id: string; + friendlyId: string; + endpointId: string; + source: string; + status: string; + isTest: boolean; + externalDeliveryId: string; + parsedEvent: Record; + headers: Record; + errorMessage: string | null; + filterReason: string | null; + createdAt: Date; + processedAt: Date | null; + }; + + const rows: Row[] = []; + for (const ep of endpoints) { + for (let i = 0; i < PER_ENDPOINT; i++) { + const event = ep.spec.events[Math.floor(Math.random() * ep.spec.events.length)]; + const body = event.body(); + const status = + ep.spec.status === "INACTIVE" && Math.random() < 0.5 ? "FILTERED" : pickStatus(); + const createdAt = biasedCreatedAt(now); + const terminal = status !== "PENDING" && status !== "PROCESSING"; + const processedAt = terminal + ? new Date(createdAt.getTime() + 80 + Math.floor(Math.random() * 1100)) + : null; + const externalDeliveryId = `${ep.spec.source}_${nanoid()}`; + const headers: Record = { + "content-type": "application/json", + "user-agent": ep.spec.userAgent, + accept: "*/*", + [ep.spec.signatureHeader.toLowerCase()]: + status === "FAILED" ? "tampered" : `sig_${nanoid()}`, + }; + // friendlyId must be the id plus the prefix, matching WebhookDeliveryId. The detail + // lookup strips "whd_" and queries Postgres by `id`, so minting the two independently + // makes every seeded delivery's detail page 404. + const deliveryId = nanoid(); + rows.push({ + id: deliveryId, + friendlyId: `whd_${deliveryId}`, + endpointId: ep.id, + source: ep.spec.source, + status, + isTest: Math.random() < 0.15, + externalDeliveryId, + parsedEvent: body, + headers, + errorMessage: + status === "FAILED" + ? FAIL_REASONS[Math.floor(Math.random() * FAIL_REASONS.length)] + : null, + filterReason: + status === "FILTERED" + ? `event.type "${String(body.type ?? (body as { event?: string }).event ?? "unknown")}" did not match the endpoint filter` + : null, + createdAt, + processedAt, + }); + } + } + + const days = new Map(); + for (const r of rows) { + const p = dayPartitionName(r.createdAt); + days.set(p.name, { lo: p.lo, hi: p.hi }); + } + for (const [name, { lo, hi }] of days) { + await webhookPrisma.$executeRawUnsafe( + `CREATE TABLE IF NOT EXISTS "${name}" PARTITION OF "WebhookDelivery" FOR VALUES FROM ('${lo}') TO ('${hi}')` + ); + } + console.log(`Ensured ${days.size} daily partitions.`); + + for (let i = 0; i < rows.length; i += 500) { + const chunk = rows.slice(i, i + 500); + await webhookPrisma.webhookDelivery.createMany({ + data: chunk.map((r) => ({ + id: r.id, + friendlyId: r.friendlyId, + webhookEndpointId: r.endpointId, + ...scope, + externalDeliveryId: r.externalDeliveryId, + idempotencyKey: r.externalDeliveryId, + runId: null, + status: r.status as never, + isTest: r.isTest, + parsedEvent: r.parsedEvent as object, + headers: r.headers as object, + rawBodyHash: createHash("sha256").update(JSON.stringify(r.parsedEvent)).digest("hex"), + errorMessage: r.errorMessage, + filterReason: r.filterReason, + createdAt: r.createdAt, + updatedAt: r.processedAt ?? r.createdAt, + processedAt: r.processedAt, + })), + }); + } + console.log(`Inserted ${rows.length} deliveries into Postgres.`); + + const clickhouseUrl = + process.env.WEBHOOK_DELIVERIES_REPLICATION_CLICKHOUSE_URL ?? process.env.CLICKHOUSE_URL; + if (!clickhouseUrl) { + console.error( + "No ClickHouse URL (set WEBHOOK_DELIVERIES_REPLICATION_CLICKHOUSE_URL or CLICKHOUSE_URL)." + ); + process.exit(1); + } + const chUrl = new URL(clickhouseUrl); + const auth = chUrl.username + ? "Basic " + Buffer.from(`${chUrl.username}:${chUrl.password}`).toString("base64") + : undefined; + const chEndpoint = `${chUrl.protocol}//${chUrl.host}/`; + + const fmt = (d: Date) => d.toISOString().replace("T", " ").replace("Z", ""); + const ndjson = rows + .map((r) => { + const updated = r.processedAt ?? r.createdAt; + return JSON.stringify({ + environment_id: scope.runtimeEnvironmentId, + organization_id: scope.organizationId, + project_id: scope.projectId, + delivery_id: r.id, + webhook_endpoint_id: r.endpointId, + environment_type: scope.environmentType, + friendly_id: r.friendlyId, + external_delivery_id: r.externalDeliveryId, + run_id: "", + status: r.status, + is_test: r.isTest ? 1 : 0, + created_at: fmt(r.createdAt), + updated_at: fmt(updated), + _version: String(updated.getTime()), + _is_deleted: 0, + }); + }) + .join("\n"); + + const insertQuery = "INSERT INTO trigger_dev.webhook_deliveries_v1 FORMAT JSONEachRow"; + const chResponse = await fetch(`${chEndpoint}?query=${encodeURIComponent(insertQuery)}`, { + method: "POST", + headers: { "content-type": "application/x-ndjson", ...(auth ? { authorization: auth } : {}) }, + body: ndjson, + }); + if (!chResponse.ok) { + console.error(`ClickHouse insert failed (${chResponse.status}): ${await chResponse.text()}`); + process.exit(1); + } + console.log(`Inserted ${rows.length} delivery rows into ClickHouse.`); + + const port = process.env.REMIX_APP_PORT ?? process.env.PORT ?? "3030"; + console.log("\nDone."); + console.log( + `Deliveries: http://localhost:${port}/orgs/${org.slug}/projects/${project.slug}/env/${runtimeEnv.slug}/webhooks` + ); + process.exit(0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/webapp/server.ts b/apps/webapp/server.ts index 96d58e2551..8bc6eeb5db 100644 --- a/apps/webapp/server.ts +++ b/apps/webapp/server.ts @@ -145,8 +145,18 @@ async function startServer() { // log dominates log volume. HTTP_ACCESS_LOG_DISABLED suppresses successful // (2xx) access logs; non-2xx responses are always logged so errors stay visible. const suppressSuccessfulAccessLogs = process.env.HTTP_ACCESS_LOG_DISABLED === "1"; + // Strip the query string from webhook ingress URLs (they may carry a + // url-secret) before they reach the access log. Other paths pass through. + morgan.token("url-redacted", (req: any) => { + const url: string = req.originalUrl ?? req.url ?? ""; + if (url.startsWith("/webhooks/v1/ingest/")) { + const q = url.indexOf("?"); + return q === -1 ? url : url.slice(0, q); + } + return url; + }); app.use( - morgan("tiny", { + morgan(":method :url-redacted :status :res[content-length] - :response-time ms", { skip: (_req, res) => suppressSuccessfulAccessLogs && res.statusCode >= 200 && res.statusCode < 300, }) @@ -189,6 +199,8 @@ async function startServer() { const runWithHttpContext: RunWithHttpContextFunction = build.entry.module.runWithHttpContext; const tenantContextMiddleware: RequestHandler = build.entry.module.tenantContextMiddleware; const dashboardAgentBodyCap: RequestHandler = build.entry.module.dashboardAgentBodyCap; + const webhookIngressIpRateLimiter: RequestHandler = + build.entry.module.webhookIngressIpRateLimiter; app.use((req, res, next) => { // helpful headers: @@ -240,6 +252,7 @@ async function startServer() { app.use(deploymentRateLimiter); app.use(engineRateLimiter); app.use(otlpRateLimiter); + app.use(webhookIngressIpRateLimiter); app.use(tenantContextMiddleware); diff --git a/apps/webapp/test/components/webhookDeliveries/buildDeliveryTimelineItems.test.ts b/apps/webapp/test/components/webhookDeliveries/buildDeliveryTimelineItems.test.ts new file mode 100644 index 0000000000..f51a4a4f5a --- /dev/null +++ b/apps/webapp/test/components/webhookDeliveries/buildDeliveryTimelineItems.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { + buildDeliveryTimelineItems, + type BuildDeliveryTimelineInput, +} from "~/components/webhookDeliveries/v1/buildDeliveryTimelineItems"; + +const createdAt = new Date("2026-07-13T10:32:04.120Z"); +const processedAt = new Date("2026-07-13T10:32:05.320Z"); + +function baseDelivery(overrides: Partial): BuildDeliveryTimelineInput { + return { + status: "PENDING", + createdAt, + processedAt: null, + errorMessage: null, + filterReason: null, + run: null, + session: null, + ...overrides, + }; +} + +describe("buildDeliveryTimelineItems", () => { + it("PENDING renders Received + an in-flight routing line, no terminal node", () => { + const items = buildDeliveryTimelineItems(baseDelivery({ status: "PENDING" })); + + expect(items.map((i) => i.id)).toEqual(["received", "routing"]); + const line = items[1]; + expect(line.type).toBe("line"); + expect(line).toMatchObject({ from: createdAt, to: null, state: "inprogress" }); + }); + + it("PROCESSING is also in-flight (no terminal node)", () => { + const items = buildDeliveryTimelineItems(baseDelivery({ status: "PROCESSING" })); + + expect(items.map((i) => i.id)).toEqual(["received", "routing"]); + expect(items[1]).toMatchObject({ to: null, state: "inprogress" }); + }); + + it("SUCCEEDED renders a Delivered terminal node with the run/session target", () => { + const items = buildDeliveryTimelineItems( + baseDelivery({ + status: "SUCCEEDED", + processedAt, + run: { friendlyId: "run_2f4b" }, + session: { friendlyId: "sess_xyz", externalId: "cust_1" }, + }) + ); + + expect(items.map((i) => i.id)).toEqual(["received", "routing", "delivered"]); + expect(items[1]).toMatchObject({ from: createdAt, to: processedAt, state: "complete" }); + const terminal = items[2]; + expect(terminal).toMatchObject({ + type: "event", + title: "Delivered", + state: "complete", + date: processedAt, + target: { + run: { friendlyId: "run_2f4b" }, + session: { friendlyId: "sess_xyz", externalId: "cust_1" }, + }, + }); + }); + + it("FAILED renders a Failed terminal node carrying the error message", () => { + const items = buildDeliveryTimelineItems( + baseDelivery({ status: "FAILED", processedAt, errorMessage: "queue limit exceeded" }) + ); + + expect(items.map((i) => i.id)).toEqual(["received", "routing", "failed"]); + expect(items[1]).toMatchObject({ state: "error" }); + expect(items[2]).toMatchObject({ + title: "Failed", + state: "error", + note: "queue limit exceeded", + }); + }); + + it("FILTERED renders a dimmed Filtered node with the reason and no run target", () => { + const items = buildDeliveryTimelineItems( + baseDelivery({ + status: "FILTERED", + processedAt, + filterReason: "action != opened", + }) + ); + + expect(items.map((i) => i.id)).toEqual(["received", "routing", "filtered"]); + expect(items[1]).toMatchObject({ state: "delayed", variant: "light" }); + const terminal = items[2]; + expect(terminal).toMatchObject({ + type: "event", + title: "Filtered", + state: "delayed", + note: "action != opened", + }); + expect((terminal as { target?: unknown }).target).toBeUndefined(); + }); + + it("Received is always the first node and marked complete", () => { + for (const status of ["PENDING", "PROCESSING", "SUCCEEDED", "FAILED", "FILTERED"] as const) { + const items = buildDeliveryTimelineItems(baseDelivery({ status, processedAt })); + expect(items[0]).toMatchObject({ id: "received", title: "Received", state: "complete" }); + } + }); +}); diff --git a/apps/webapp/test/dashboardAgentWatchQueueName.test.ts b/apps/webapp/test/dashboardAgentWatchQueueName.test.ts index ecee4d3ee1..66f9b2262a 100644 --- a/apps/webapp/test/dashboardAgentWatchQueueName.test.ts +++ b/apps/webapp/test/dashboardAgentWatchQueueName.test.ts @@ -180,8 +180,16 @@ async function createFor(seeded: Seeded, spec: WatchSpec, chatId?: string) { } async function persistedQueueName(created: { watchId?: string }) { - const watch = await getWatch(ctx.agentDb, { id: created.watchId! }); - return (watch?.spec as { queue: string }).queue; + if (!created.watchId) { + throw new Error("Expected the created watch to have an ID"); + } + + const watch = await getWatch(ctx.agentDb, { id: created.watchId }); + if (!watch) { + throw new Error(`Expected watch ${created.watchId} to exist`); + } + + return (watch.spec as { queue: string }).queue; } /** The queue as `QueueRetrievePresenter` hands it to the page: the prefix already stripped. */ diff --git a/apps/webapp/test/deliveryIdBounds.test.ts b/apps/webapp/test/deliveryIdBounds.test.ts new file mode 100644 index 0000000000..11b867f801 --- /dev/null +++ b/apps/webapp/test/deliveryIdBounds.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from "vitest"; +import { WebhookDeliveryId } from "@trigger.dev/core/v3/isomorphic"; +import { + createdAtMsBounds, + deliveryIdsCreatedAtBounds, +} from "../app/services/webhookDeliveriesRepository/deliveryIdBounds"; + +function idAt(iso: string): string { + vi.setSystemTime(new Date(iso)); + return WebhookDeliveryId.generate().friendlyId; +} + +describe("deliveryIdsCreatedAtBounds", () => { + it("returns undefined for an empty set", () => { + expect(deliveryIdsCreatedAtBounds([])).toBeUndefined(); + }); + + it("returns a zero-width span for a single id (gte == lte == its mint time)", () => { + vi.useFakeTimers(); + try { + const at = "2026-08-11T10:00:00.000Z"; + const friendlyId = idAt(at); + const bounds = deliveryIdsCreatedAtBounds([friendlyId]); + expect(bounds?.gte.toISOString()).toBe(at); + expect(bounds?.lte.toISOString()).toBe(at); + } finally { + vi.useRealTimers(); + } + }); + + it("spans the earliest and latest mint times across ids, regardless of input order", () => { + vi.useFakeTimers(); + try { + const early = idAt("2026-08-09T00:00:00.000Z"); + const mid = idAt("2026-08-10T12:00:00.000Z"); + const late = idAt("2026-08-11T23:59:59.000Z"); + const bounds = deliveryIdsCreatedAtBounds([mid, late, early]); + expect(bounds?.gte.toISOString()).toBe("2026-08-09T00:00:00.000Z"); + expect(bounds?.lte.toISOString()).toBe("2026-08-11T23:59:59.000Z"); + } finally { + vi.useRealTimers(); + } + }); + + it("returns undefined when an id fails to decode, so the caller skips pruning", () => { + expect(deliveryIdsCreatedAtBounds(["whd_notavaliddeliveryid"])).toBeUndefined(); + }); +}); + +describe("createdAtMsBounds", () => { + it("returns undefined for an empty set", () => { + expect(createdAtMsBounds([])).toBeUndefined(); + }); + + it("returns a zero-width span for a single value", () => { + const bounds = createdAtMsBounds([1_000]); + expect(bounds?.gte.getTime()).toBe(1_000); + expect(bounds?.lte.getTime()).toBe(1_000); + }); + + it("spans the smallest and largest value regardless of input order", () => { + const bounds = createdAtMsBounds([50, 10, 30, 90, 40]); + expect(bounds?.gte.getTime()).toBe(10); + expect(bounds?.lte.getTime()).toBe(90); + }); + + it("handles a large input without a stack overflow (unlike Math.min(...spread))", () => { + const values = Array.from({ length: 300_000 }, (_, i) => i); + const bounds = createdAtMsBounds(values); + expect(bounds?.gte.getTime()).toBe(0); + expect(bounds?.lte.getTime()).toBe(299_999); + }); +}); diff --git a/apps/webapp/test/engine/dequeueWorkerVersionFreshness.test.ts b/apps/webapp/test/engine/dequeueWorkerVersionFreshness.test.ts new file mode 100644 index 0000000000..da906e8940 --- /dev/null +++ b/apps/webapp/test/engine/dequeueWorkerVersionFreshness.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, onTestFinished, vi } from "vitest"; + +vi.mock("~/db.server", () => ({ + prisma: {}, + $replica: {}, + runOpsNewPrisma: {}, + runOpsLegacyPrisma: {}, +})); + +vi.mock("~/v3/runOpsMigration/splitMode.server", () => ({ isSplitEnabled: async () => false })); + +vi.mock("~/services/platform.v3.server", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + getEntitlement: vi.fn(), + }; +}); + +import { RunEngine } from "@internal/run-engine"; +import { setupAuthenticatedEnvironment, setupBackgroundWorker } from "@internal/run-engine/tests"; +import { assertNonNullable, containerTest } from "@internal/testcontainers"; +import { trace } from "@opentelemetry/api"; +import { setTimeout } from "node:timers/promises"; +import type { PrismaClient, PrismaReplicaClient } from "@trigger.dev/database"; +import { IdempotencyKeyConcern } from "~/runEngine/concerns/idempotencyKeys.server"; +import { DefaultQueueManager } from "~/runEngine/concerns/queues.server"; +import { RunEngineTriggerTaskService } from "../../app/runEngine/services/triggerTask.server"; +import { ControlPlaneCache } from "~/v3/runOpsMigration/controlPlaneCache.server"; +import { ControlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; +import { RunEngineControlPlaneResolver } from "~/v3/runOpsMigration/runEngineControlPlaneResolver.server"; +import { + MockPayloadProcessor, + MockTraceEventConcern, + MockTriggerTaskValidator, +} from "./triggerTaskTestHelpers"; + +vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 }); + +function buildEngine(prisma: PrismaClient, redisOptions: any) { + const appResolver = new ControlPlaneResolver({ + controlPlanePrimary: prisma, + controlPlaneReplica: prisma as unknown as PrismaReplicaClient, + cache: new ControlPlaneCache(), + splitEnabled: () => true, + workerVersionFreshReadEnabled: () => true, + }); + + return new RunEngine({ + prisma, + worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 }, + queue: { redis: redisOptions }, + runLock: { redis: redisOptions }, + machines: { + defaultMachine: "small-1x", + machines: { + "small-1x": { name: "small-1x" as const, cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 }, + }, + baseCostInCents: 0.0005, + }, + tracer: trace.getTracer("test", "0.0.0"), + controlPlaneResolver: new RunEngineControlPlaneResolver(appResolver), + }); +} + +function buildTriggerService(engine: RunEngine, prisma: PrismaClient) { + return new RunEngineTriggerTaskService({ + engine, + prisma, + payloadProcessor: new MockPayloadProcessor(), + queueConcern: new DefaultQueueManager(prisma, engine), + idempotencyKeyConcern: new IdempotencyKeyConcern(prisma, engine, new MockTraceEventConcern()), + validator: new MockTriggerTaskValidator(), + traceEventConcern: new MockTraceEventConcern(), + tracer: trace.getTracer("test", "0.0.0"), + metadataMaximumSize: 1024 * 1024 * 1, + }); +} + +describe("Dequeue worker-version dispatch freshness end-to-end (TRI-13291)", () => { + containerTest( + "a run triggered after a mid-stream promotion dequeues onto the NEWLY-promoted version", + async ({ prisma, redisOptions }) => { + const engine = buildEngine(prisma as unknown as PrismaClient, redisOptions); + onTestFinished(() => engine.quit()); + + const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const taskIdentifier = "fresh-task"; + + const v1 = await setupBackgroundWorker(engine, environment, taskIdentifier); + const triggerService = buildTriggerService(engine, prisma as unknown as PrismaClient); + + const run1 = await triggerService.call({ + taskId: taskIdentifier, + environment, + body: { payload: { n: 1 } }, + }); + assertNonNullable(run1); + + await engine.runQueue.processMasterQueueForEnvironment(environment.id, 10); + await setTimeout(500); + + const run1Row = await prisma.taskRun.findUniqueOrThrow({ where: { id: run1.run.id } }); + const dequeued1 = await engine.dequeueFromWorkerQueue({ + consumerId: "c1", + workerQueue: run1Row.workerQueue!, + }); + expect(dequeued1.length).toBe(1); + assertNonNullable(dequeued1[0]); + expect(dequeued1[0].run.id).toBe(run1.run.id); + expect(dequeued1[0].backgroundWorker.id).toBe(v1.worker.id); + expect(dequeued1[0].backgroundWorker.version).toBe(v1.worker.version); + + const v2 = await setupBackgroundWorker(engine, environment, taskIdentifier); + expect(v2.worker.id).not.toBe(v1.worker.id); + expect(v2.worker.version).not.toBe(v1.worker.version); + + const currentPromotion = await prisma.workerDeploymentPromotion.findFirstOrThrow({ + where: { environmentId: environment.id, label: "current" }, + include: { deployment: true }, + }); + expect(currentPromotion.deployment.workerId).toBe(v2.worker.id); + + const run2 = await triggerService.call({ + taskId: taskIdentifier, + environment, + body: { payload: { n: 2 } }, + }); + assertNonNullable(run2); + + await engine.runQueue.processMasterQueueForEnvironment(environment.id, 10); + await setTimeout(500); + + const run2Row = await prisma.taskRun.findUniqueOrThrow({ where: { id: run2.run.id } }); + const dequeued2 = await engine.dequeueFromWorkerQueue({ + consumerId: "c2", + workerQueue: run2Row.workerQueue!, + }); + expect(dequeued2.length).toBe(1); + assertNonNullable(dequeued2[0]); + expect(dequeued2[0].run.id).toBe(run2.run.id); + + expect(dequeued2[0].backgroundWorker.id).toBe(v2.worker.id); + expect(dequeued2[0].backgroundWorker.version).toBe(v2.worker.version); + expect(dequeued2[0].deployment.id).toBe(v2.deployment.id); + expect(dequeued2[0].image).toContain(v2.worker.version); + } + ); +}); diff --git a/apps/webapp/test/envConcurrencyLimitPause.server.test.ts b/apps/webapp/test/envConcurrencyLimitPause.server.test.ts new file mode 100644 index 0000000000..7bb67c8134 --- /dev/null +++ b/apps/webapp/test/envConcurrencyLimitPause.server.test.ts @@ -0,0 +1,217 @@ +import { RunEngine } from "@internal/run-engine"; +import { containerTest } from "@internal/testcontainers"; +import { trace } from "@opentelemetry/api"; +import type { PrismaClient } from "@trigger.dev/database"; +import type { RedisOptions } from "ioredis"; +import { describe, expect, onTestFinished, vi } from "vitest"; +import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { + createRuntimeEnvironment, + createTestOrgProjectWithMember, + uniqueId, +} from "./fixtures/environmentVariablesFixtures"; + +vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 }); + +// test/setup.ts replaces the app's engine singleton with a no-op for every webapp suite, which +// would make any assertion about the RunQueue limits vacuous. Every test in this file asserts on +// real RunQueue state, so put a real RunEngine - built on the test's own Redis container - back +// behind the singleton. No test here uses the no-op default. +const { engineHolder } = vi.hoisted(() => ({ + engineHolder: { current: undefined as any }, +})); + +vi.mock("~/v3/runEngine.server", () => ({ + engine: new Proxy({} as Record, { + get: (_target, prop) => engineHolder.current?.[prop as string], + }), +})); + +function useEngine(prisma: PrismaClient, redisOptions: RedisOptions) { + const engine = new RunEngine({ + prisma, + worker: { redis: redisOptions, disabled: true }, + queue: { redis: redisOptions, masterQueueConsumersDisabled: true }, + runLock: { redis: redisOptions }, + machines: { + defaultMachine: "small-1x", + machines: { + "small-1x": { name: "small-1x" as const, cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 }, + }, + baseCostInCents: 0.0001, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + engineHolder.current = engine; + onTestFinished(async () => { + engineHolder.current = undefined; + await engine.quit(); + }); + + return engine; +} + +// The import chain reaches module-level singletons that throw at load time when +// REDIS_HOST/REDIS_PORT are unset (autoIncrementCounter via triggerTaskV1), so the env must point +// at the redis container BEFORE the modules are imported. Hence dynamic imports; vitest runs each +// file in its own fork, so the env mutation cannot leak into other suites. +async function loadServices(redisOptions: RedisOptions) { + process.env.REDIS_HOST = redisOptions.host; + process.env.REDIS_PORT = String(redisOptions.port); + process.env.REDIS_TLS_DISABLED = "true"; + const [{ updateEnvConcurrencyLimits }, { PauseEnvironmentService }, runtimeEnvironment] = + await Promise.all([ + import("~/v3/runQueue.server"), + import("~/v3/services/pauseEnvironment.server"), + import("~/models/runtimeEnvironment.server"), + ]); + return { + updateEnvConcurrencyLimits, + PauseEnvironmentService, + authIncludeBase: runtimeEnvironment.authIncludeBase, + toAuthenticated: runtimeEnvironment.toAuthenticated, + }; +} + +type Loaded = Awaited>; + +async function authEnv( + loaded: Loaded, + prisma: PrismaClient, + environmentId: string +): Promise { + const row = await prisma.runtimeEnvironment.findFirstOrThrow({ + where: { id: environmentId }, + include: loaded.authIncludeBase, + }); + return loaded.toAuthenticated(row); +} + +async function seedProductionEnv(prisma: PrismaClient, maximumConcurrencyLimit: number) { + const { organization, project } = await createTestOrgProjectWithMember(prisma); + const environment = await createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "PRODUCTION", + slug: uniqueId("prod"), + }); + + await prisma.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { maximumConcurrencyLimit }, + }); + + return { organization, project, environment }; +} + +// An unset RunQueue limit reads back as the engine default (10), so neither the 0 nor the 17 +// assertions below can pass just because a push never happened. +describe("updateEnvConcurrencyLimits", () => { + containerTest( + "clamps to 0 when the environment is paused, even though the caller's copy says otherwise", + async ({ prisma, redisOptions }) => { + const loaded = await loadServices(redisOptions); + const engine = useEngine(prisma, redisOptions); + + const { environment } = await seedProductionEnv(prisma, 17); + // What an argument-less caller holds: an environment read when the request authenticated, + // before the pause landed (finalizing a deployment, registering a background worker). + const atAuthTime = await authEnv(loaded, prisma, environment.id); + expect(atAuthTime.paused).toBe(false); + + await prisma.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { paused: true }, + }); + + await loaded.updateEnvConcurrencyLimits(atAuthTime, undefined, prisma); + + // The 0 limit is the only thing stopping dequeues, so the real limit must not go back in. + expect(await engine.runQueue.getEnvConcurrencyLimit(atAuthTime)).toBe(0); + } + ); + + containerTest( + "pushes the real limit for a running environment", + async ({ prisma, redisOptions }) => { + const loaded = await loadServices(redisOptions); + const engine = useEngine(prisma, redisOptions); + + const { environment } = await seedProductionEnv(prisma, 17); + const env = await authEnv(loaded, prisma, environment.id); + + await loaded.updateEnvConcurrencyLimits(env, undefined, prisma); + + expect(await engine.runQueue.getEnvConcurrencyLimit(env)).toBe(17); + } + ); + + containerTest( + "restores the real limit when the environment was resumed while the request was in flight", + async ({ prisma, redisOptions }) => { + const loaded = await loadServices(redisOptions); + const engine = useEngine(prisma, redisOptions); + + const { environment } = await seedProductionEnv(prisma, 17); + await prisma.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { paused: true }, + }); + + // Captured while paused, then resumed before the push. Trusting this copy would write 0 over + // the restored limit and leave the env stalled with `paused: false` and nothing to fix it. + const whilePaused = await authEnv(loaded, prisma, environment.id); + expect(whilePaused.paused).toBe(true); + + await prisma.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { paused: false }, + }); + + await loaded.updateEnvConcurrencyLimits(whilePaused, undefined, prisma); + + expect(await engine.runQueue.getEnvConcurrencyLimit(whilePaused)).toBe(17); + } + ); + + containerTest( + "an explicit limit wins over the stored pause state", + async ({ prisma, redisOptions }) => { + const loaded = await loadServices(redisOptions); + const engine = useEngine(prisma, redisOptions); + + const { environment } = await seedProductionEnv(prisma, 17); + await prisma.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { paused: true }, + }); + const env = await authEnv(loaded, prisma, environment.id); + + // How billing-limit converge restores a limit as it unpauses: the caller decides, no read. + await loaded.updateEnvConcurrencyLimits(env, 9, prisma); + + expect(await engine.runQueue.getEnvConcurrencyLimit(env)).toBe(9); + } + ); + + containerTest( + "a pause writes 0 and a resume restores the limit", + async ({ prisma, redisOptions }) => { + const loaded = await loadServices(redisOptions); + const engine = useEngine(prisma, redisOptions); + + const { environment } = await seedProductionEnv(prisma, 17); + const service = new loaded.PauseEnvironmentService(prisma); + const env = await authEnv(loaded, prisma, environment.id); + + expect(await service.call(env, "paused")).toEqual({ success: true, state: "paused" }); + expect(await engine.runQueue.getEnvConcurrencyLimit(env)).toBe(0); + + // The service holds an environment read before its own resume update, so `env.paused` is + // stale here too. + expect(await service.call(env, "resumed")).toEqual({ success: true, state: "resumed" }); + expect(await engine.runQueue.getEnvConcurrencyLimit(env)).toBe(17); + } + ); +}); diff --git a/apps/webapp/test/helpers/sessionStream.ts b/apps/webapp/test/helpers/sessionStream.ts index 085c3cc4ed..9e0f4e7c86 100644 --- a/apps/webapp/test/helpers/sessionStream.ts +++ b/apps/webapp/test/helpers/sessionStream.ts @@ -274,7 +274,7 @@ export async function collectSessionOut( if (!gotNew) { await new Promise((r) => setTimeout(r, 100)); } - } while (true); + } while (true); // oxlint-disable-line no-constant-condition return { parts, durationMs: performance.now() - started, subscription }; } diff --git a/apps/webapp/test/readBodyWithCap.test.ts b/apps/webapp/test/readBodyWithCap.test.ts new file mode 100644 index 0000000000..2ed7be0c40 --- /dev/null +++ b/apps/webapp/test/readBodyWithCap.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { readBodyWithCap } from "~/utils/readBodyWithCap.server"; + +function streamingRequest(chunks: Uint8Array[]): Request { + const body = new ReadableStream({ + pull(controller) { + const next = chunks.shift(); + if (next) controller.enqueue(next); + else controller.close(); + }, + }); + return new Request("https://example.com/ingest", { + method: "POST", + body, + // @ts-expect-error duplex isn't in the lib types yet but Node requires it for a stream body + duplex: "half", + }); +} + +const chunk = (n: number) => new Uint8Array(n).fill(122); + +describe("readBodyWithCap", () => { + it("returns the full body when under the cap", async () => { + const bytes = await readBodyWithCap(streamingRequest([chunk(100), chunk(100)]), 1024); + expect(bytes).not.toBeNull(); + expect(bytes!.byteLength).toBe(200); + }); + + it("returns null when the streamed body exceeds the cap (no full buffering)", async () => { + const bytes = await readBodyWithCap( + streamingRequest([chunk(1024), chunk(1024), chunk(1024)]), + 2048 + ); + expect(bytes).toBeNull(); + }); + + it("treats an empty body as zero bytes", async () => { + const bytes = await readBodyWithCap( + new Request("https://example.com/ingest", { method: "POST" }), + 1024 + ); + expect(bytes).not.toBeNull(); + expect(bytes!.byteLength).toBe(0); + }); +}); diff --git a/apps/webapp/test/runTimestamps.test.ts b/apps/webapp/test/runTimestamps.test.ts new file mode 100644 index 0000000000..30298a23ee --- /dev/null +++ b/apps/webapp/test/runTimestamps.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { runTriggeredAt } from "~/v3/runTimestamps"; + +describe("runTriggeredAt", () => { + const createdAt = new Date("2026-08-14T08:30:00.000Z"); + const queueTimestamp = new Date("2026-08-14T08:38:25.000Z"); + + it("uses the effective queue timestamp for scheduled runs", () => { + expect( + runTriggeredAt({ + createdAt, + queueTimestamp, + scheduleId: "schedule_123", + }) + ).toEqual(queueTimestamp); + }); + + it("preserves the creation time for non-scheduled delayed runs", () => { + expect( + runTriggeredAt({ + createdAt, + queueTimestamp, + scheduleId: null, + }) + ).toEqual(createdAt); + }); + + it("falls back to the creation time when a scheduled run has no queue timestamp", () => { + expect( + runTriggeredAt({ + createdAt, + queueTimestamp: null, + scheduleId: "schedule_123", + }) + ).toEqual(createdAt); + }); +}); diff --git a/apps/webapp/test/sanitizeSessionInput.server.test.ts b/apps/webapp/test/sanitizeSessionInput.server.test.ts new file mode 100644 index 0000000000..f1599e8335 --- /dev/null +++ b/apps/webapp/test/sanitizeSessionInput.server.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { stripClientWebhookActionSource } from "~/services/realtime/sanitizeSessionInput.server"; + +const record = (payload: Record) => JSON.stringify({ kind: "message", payload }); + +describe("stripClientWebhookActionSource", () => { + it("removes a client-forged webhook actionSource so the action is validated normally", () => { + const forged = record({ + chatId: "c1", + trigger: "action", + actionSource: "webhook", + action: { type: "refund", amount: 9999 }, + }); + + const cleaned = JSON.parse(stripClientWebhookActionSource(forged)); + expect(cleaned.payload.actionSource).toBeUndefined(); + expect(cleaned.payload.action).toEqual({ type: "refund", amount: 9999 }); + expect(cleaned.payload.trigger).toBe("action"); + }); + + it("leaves a non-webhook actionSource untouched", () => { + const part = record({ trigger: "action", actionSource: "client", action: { type: "ping" } }); + expect(stripClientWebhookActionSource(part)).toBe(part); + }); + + it("leaves a normal message part untouched (fast path, no parse)", () => { + const part = record({ trigger: "submit-message", message: { role: "user", parts: [] } }); + expect(stripClientWebhookActionSource(part)).toBe(part); + }); + + it("leaves a malformed part untouched", () => { + const part = '{"kind":"message","payload":{"actionSource":"webhook"'; + expect(stripClientWebhookActionSource(part)).toBe(part); + }); +}); diff --git a/apps/webapp/test/scheduleWindow.test.ts b/apps/webapp/test/scheduleWindow.test.ts index afc4a0c088..df19bb8919 100644 --- a/apps/webapp/test/scheduleWindow.test.ts +++ b/apps/webapp/test/scheduleWindow.test.ts @@ -1,5 +1,7 @@ +import { SCHEDULE_PHASE_DENOMINATOR } from "@internal/schedule-engine"; import { describe, expect, it } from "vitest"; import { + calculateNextScheduleRunTimes, formatScheduleWindow, normalizeScheduleWindow, validateScheduleWindowSyntax, @@ -62,4 +64,47 @@ describe("schedule window persistence", () => { it("accepts an absolute window independently of the cron interval", () => { expect(validateScheduleWindowSyntax("30m")).toEqual({ valid: true }); }); + + it("calculates stable nominal and effective times", () => { + const [first, second] = calculateNextScheduleRunTimes({ + cron: "*/5 * * * *", + timezone: "UTC", + deduplicationKey: "five-minute-task", + environmentId: "env_123", + schedulePhase: SCHEDULE_PHASE_DENOMINATOR / 2, + phaseSecret: "test-secret", + windowDurationSeconds: null, + windowPercentage: 30, + from: new Date("2026-08-11T09:59:00.000Z"), + count: 2, + }); + + expect(first).toEqual({ + nominalAt: new Date("2026-08-11T10:00:00.000Z"), + effectiveAt: new Date("2026-08-11T10:00:45.000Z"), + }); + expect(second).toEqual({ + nominalAt: new Date("2026-08-11T10:05:00.000Z"), + effectiveAt: new Date("2026-08-11T10:05:45.000Z"), + }); + }); + + it("derives a stable phase when one has not been persisted", () => { + const input = { + cron: "0 * * * *", + timezone: "UTC", + deduplicationKey: "hourly-task", + environmentId: "env_123", + schedulePhase: null, + phaseSecret: "test-secret", + windowDurationSeconds: null, + windowPercentage: null, + from: new Date("2026-08-11T09:59:00.000Z"), + }; + + expect(calculateNextScheduleRunTimes(input)).toEqual(calculateNextScheduleRunTimes(input)); + expect(calculateNextScheduleRunTimes(input)[0].effectiveAt.getTime()).toBeGreaterThanOrEqual( + calculateNextScheduleRunTimes(input)[0].nominalAt.getTime() + ); + }); }); diff --git a/apps/webapp/test/schedules-api.e2e.full.test.ts b/apps/webapp/test/schedules-api.e2e.full.test.ts index 2924c30e10..a2f6ab72e3 100644 --- a/apps/webapp/test/schedules-api.e2e.full.test.ts +++ b/apps/webapp/test/schedules-api.e2e.full.test.ts @@ -29,15 +29,26 @@ describe("Schedules API windows", () => { timezone: "UTC", window: "30%", }); + expectAssignedTime(created, 18 * 60_000); const retrieveResponse = await server.webapp.fetch(`/api/v1/schedules/${created.id}`, { headers: authHeaders(apiKey), }); expect(retrieveResponse.status).toBe(200); - await expect(retrieveResponse.json()).resolves.toMatchObject({ + const retrieved = await retrieveResponse.json(); + expect(retrieved).toMatchObject({ id: created.id, window: "30%", }); + expectAssignedTime(retrieved, 18 * 60_000); + + const listResponse = await server.webapp.fetch("/api/v1/schedules", { + headers: authHeaders(apiKey), + }); + expect(listResponse.status).toBe(200); + const listed = await listResponse.json(); + expect(listed.data[0]).toMatchObject({ id: created.id }); + expectAssignedTime(listed.data[0], 18 * 60_000); const updateResponse = await server.webapp.fetch(`/api/v1/schedules/${created.id}`, { method: "PUT", @@ -49,10 +60,12 @@ describe("Schedules API windows", () => { }), }); expect(updateResponse.status).toBe(200); - await expect(updateResponse.json()).resolves.toMatchObject({ + const updated = await updateResponse.json(); + expect(updated).toMatchObject({ id: created.id, window: "2h", }); + expectAssignedTime(updated, 2 * 60 * 60_000); const clearResponse = await server.webapp.fetch(`/api/v1/schedules/${created.id}`, { method: "PUT", @@ -67,6 +80,26 @@ describe("Schedules API windows", () => { expect(cleared.id).toBe(created.id); expect(cleared).not.toHaveProperty("window"); + const deactivateResponse = await server.webapp.fetch( + `/api/v1/schedules/${created.id}/deactivate`, + { method: "POST", headers: authHeaders(apiKey) } + ); + expect(deactivateResponse.status).toBe(200); + await expect(deactivateResponse.json()).resolves.toMatchObject({ + active: false, + nextRun: null, + nextRunEffectiveAt: null, + }); + + const activateResponse = await server.webapp.fetch(`/api/v1/schedules/${created.id}/activate`, { + method: "POST", + headers: authHeaders(apiKey), + }); + expect(activateResponse.status).toBe(200); + const activated = await activateResponse.json(); + expect(activated.active).toBe(true); + expectAssignedTime(activated, 60_000); + const stored = await server.prisma.taskSchedule.findUniqueOrThrow({ where: { friendlyId: created.id }, select: { windowDurationSeconds: true, windowPercentage: true }, @@ -111,14 +144,9 @@ describe("Schedules API windows", () => { const { apiKey, project, environment } = await seedTestEnvironment(server.prisma); await seedScheduledTask(server.prisma, project.id, environment.id); - const invalidRequests = [ - { window: 30, expectedStatus: 400 }, - { window: "30.5%", expectedStatus: 422 }, - { window: "1d", expectedStatus: 422 }, - { window: "25h", expectedStatus: 422 }, - ]; + const invalidWindows = [30, "30.5%", "1d", "25h"]; - for (const [index, { window, expectedStatus }] of invalidRequests.entries()) { + for (const [index, window] of invalidWindows.entries()) { const response = await server.webapp.fetch("/api/v1/schedules", { method: "POST", headers: authHeaders(apiKey), @@ -130,12 +158,22 @@ describe("Schedules API windows", () => { }), }); - expect(response.status).toBe(expectedStatus); - await expect(response.json()).resolves.toHaveProperty("error"); + expect(response.status).toBe(400); } }); }); +function expectAssignedTime( + schedule: { nextRun: string; nextRunEffectiveAt: string }, + maximumDelayMs: number +) { + const nominalAt = new Date(schedule.nextRun).getTime(); + const effectiveAt = new Date(schedule.nextRunEffectiveAt).getTime(); + + expect(effectiveAt).toBeGreaterThanOrEqual(nominalAt); + expect(effectiveAt).toBeLessThan(nominalAt + maximumDelayMs); +} + function authHeaders(apiKey: string) { return { Authorization: `Bearer ${apiKey}`, diff --git a/apps/webapp/test/syncDeclarativeWebhooks.test.ts b/apps/webapp/test/syncDeclarativeWebhooks.test.ts new file mode 100644 index 0000000000..0c97e8dcd5 --- /dev/null +++ b/apps/webapp/test/syncDeclarativeWebhooks.test.ts @@ -0,0 +1,224 @@ +import { containerTest } from "@internal/testcontainers"; +import type { WebhookResource } from "@trigger.dev/core/v3"; +import type { BackgroundWorker, PrismaClient } from "@trigger.dev/database"; +import { describe, expect, vi } from "vitest"; +import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { syncDeclarativeWebhooks } from "~/v3/services/createBackgroundWorker.server"; + +vi.setConfig({ testTimeout: 60_000 }); + +type WorkerArg = Parameters[1]; +const noWorker = {} as unknown as WorkerArg; + +async function seedProjectWithEnv(prisma: PrismaClient) { + const slug = `sdw_${Math.random().toString(36).slice(2, 10)}`; + const organization = await prisma.organization.create({ data: { title: slug, slug } }); + const project = await prisma.project.create({ + data: { name: slug, slug, organizationId: organization.id, externalRef: slug }, + }); + const environment = await prisma.runtimeEnvironment.create({ + data: { + slug: "prod", + type: "PRODUCTION", + projectId: project.id, + organizationId: organization.id, + apiKey: `tr_prod_${slug}`, + pkApiKey: `pk_prod_${slug}`, + shortcode: `p${slug.slice(0, 5)}`, + }, + }); + return { organization, project, environment }; +} + +async function seedWorkerWithTask( + prisma: PrismaClient, + project: { id: string }, + environment: { id: string }, + taskSlug: string +): Promise { + const suffix = Math.random().toString(36).slice(2, 10); + const worker = await prisma.backgroundWorker.create({ + data: { + friendlyId: `worker_${suffix}`, + contentHash: `hash_${suffix}`, + version: "20260101.1", + metadata: {}, + projectId: project.id, + runtimeEnvironmentId: environment.id, + }, + }); + await prisma.backgroundWorkerTask.create({ + data: { + friendlyId: `task_${suffix}`, + slug: taskSlug, + filePath: `src/trigger/${taskSlug}.ts`, + workerId: worker.id, + projectId: project.id, + runtimeEnvironmentId: environment.id, + }, + }); + return worker; +} + +async function seedEndpoint( + prisma: PrismaClient, + base: { organizationId: string; projectId: string; runtimeEnvironmentId: string }, + handlerWebhookId: string, + status: "ACTIVE" | "INACTIVE", + manuallyDeactivatedAt: Date | null = null +) { + const suffix = Math.random().toString(36).slice(2, 10); + return prisma.webhookEndpoint.create({ + data: { + friendlyId: `wh_${suffix}`, + opaqueId: `op_${suffix}${Math.random().toString(36).slice(2, 10)}`, + organizationId: base.organizationId, + projectId: base.projectId, + runtimeEnvironmentId: base.runtimeEnvironmentId, + environmentType: "PRODUCTION", + source: "stripe", + handlerWebhookId, + routingTarget: { type: "task", taskId: "handle-stripe" }, + verifierArtifact: { kind: "bundle", bundleUrl: "https://example.test/v.js", hash: "h" }, + status, + manuallyDeactivatedAt, + }, + }); +} + +function makeWebhookResource(id: string, taskId: string): WebhookResource { + return { + id, + filePath: `src/trigger/${id}.ts`, + source: "stripe", + verifierArtifact: { kind: "bundle", bundleUrl: "https://example.test/v.js", hash: "h" }, + routingTarget: { type: "task", taskId }, + }; +} + +const asEnv = (env: unknown) => env as AuthenticatedEnvironment; + +describe("syncDeclarativeWebhooks status reconciliation", () => { + containerTest( + "an absent webhooks list (older client) does not deactivate existing endpoints", + async ({ prisma }) => { + const { organization, project, environment } = await seedProjectWithEnv(prisma); + const endpoint = await seedEndpoint( + prisma, + { + organizationId: organization.id, + projectId: project.id, + runtimeEnvironmentId: environment.id, + }, + "declared-webhook", + "ACTIVE" + ); + + await syncDeclarativeWebhooks(undefined, noWorker, asEnv(environment), prisma, prisma); + + const after = await prisma.webhookEndpoint.findUniqueOrThrow({ where: { id: endpoint.id } }); + expect(after.status).toBe("ACTIVE"); + } + ); + + containerTest( + "an explicit empty list deactivates endpoints that are no longer declared", + async ({ prisma }) => { + const { organization, project, environment } = await seedProjectWithEnv(prisma); + const endpoint = await seedEndpoint( + prisma, + { + organizationId: organization.id, + projectId: project.id, + runtimeEnvironmentId: environment.id, + }, + "declared-webhook", + "ACTIVE" + ); + + await syncDeclarativeWebhooks([], noWorker, asEnv(environment), prisma, prisma); + + const after = await prisma.webhookEndpoint.findUniqueOrThrow({ where: { id: endpoint.id } }); + expect(after.status).toBe("INACTIVE"); + } + ); + + containerTest( + "a redeploy does not re-activate an endpoint disabled via the API", + async ({ prisma }) => { + const { organization, project, environment } = await seedProjectWithEnv(prisma); + const worker = await seedWorkerWithTask(prisma, project, environment, "handle-stripe"); + const endpoint = await seedEndpoint( + prisma, + { + organizationId: organization.id, + projectId: project.id, + runtimeEnvironmentId: environment.id, + }, + "declared-webhook", + "INACTIVE", + new Date() + ); + + await syncDeclarativeWebhooks( + [makeWebhookResource("declared-webhook", "handle-stripe")], + worker, + asEnv(environment), + prisma, + prisma + ); + + const after = await prisma.webhookEndpoint.findUniqueOrThrow({ where: { id: endpoint.id } }); + expect(after.status).toBe("INACTIVE"); + expect(after.manuallyDeactivatedAt).not.toBeNull(); + } + ); + + containerTest( + "a redeploy re-activates an endpoint auto-deactivated when it was removed then re-declared", + async ({ prisma }) => { + const { organization, project, environment } = await seedProjectWithEnv(prisma); + const worker = await seedWorkerWithTask(prisma, project, environment, "handle-stripe"); + const endpoint = await seedEndpoint( + prisma, + { + organizationId: organization.id, + projectId: project.id, + runtimeEnvironmentId: environment.id, + }, + "declared-webhook", + "INACTIVE", + null + ); + + await syncDeclarativeWebhooks( + [makeWebhookResource("declared-webhook", "handle-stripe")], + worker, + asEnv(environment), + prisma, + prisma + ); + + const after = await prisma.webhookEndpoint.findUniqueOrThrow({ where: { id: endpoint.id } }); + expect(after.status).toBe("ACTIVE"); + } + ); + + containerTest("a newly declared webhook creates an active endpoint", async ({ prisma }) => { + const { project, environment } = await seedProjectWithEnv(prisma); + const worker = await seedWorkerWithTask(prisma, project, environment, "handle-stripe"); + + await syncDeclarativeWebhooks( + [makeWebhookResource("brand-new-webhook", "handle-stripe")], + worker, + asEnv(environment), + prisma, + prisma + ); + + const created = await prisma.webhookEndpoint.findFirst({ + where: { runtimeEnvironmentId: environment.id, handlerWebhookId: "brand-new-webhook" }, + }); + expect(created?.status).toBe("ACTIVE"); + }); +}); diff --git a/apps/webapp/test/v3/runOpsMigration/controlPlaneResolver.server.test.ts b/apps/webapp/test/v3/runOpsMigration/controlPlaneResolver.server.test.ts index af734ca1f7..08b383afbc 100644 --- a/apps/webapp/test/v3/runOpsMigration/controlPlaneResolver.server.test.ts +++ b/apps/webapp/test/v3/runOpsMigration/controlPlaneResolver.server.test.ts @@ -224,7 +224,7 @@ heteroPostgresTest( // --- resolveWorkerVersion --------------------------------------------------- heteroPostgresTest( - "resolveWorkerVersion (pinned) returns worker/tasks/queues and caches it", + "resolveWorkerVersion (pinned) returns worker/tasks/queues and reads fresh each call", async ({ prisma14 }) => { const { environment, project } = await seedControlPlane(prisma14); const { worker, task, queue } = await seedWorker(prisma14, { @@ -250,6 +250,37 @@ heteroPostgresTest( const readsAfterFirst = reads(); expect(readsAfterFirst).toBeGreaterThanOrEqual(1); + const second = await resolver.resolveWorkerVersion({ + environmentId: environment.id, + backgroundWorkerId: worker.id, + }); + expect(second?.worker.id).toBe(worker.id); + expect(reads()).toBeGreaterThan(readsAfterFirst); + } +); + +heteroPostgresTest( + "resolveWorkerVersion (pinned) serves from cache when the kill-switch is off", + async ({ prisma14 }) => { + const { environment, project } = await seedControlPlane(prisma14); + const { worker } = await seedWorker(prisma14, { + projectId: project.id, + environmentId: environment.id, + }); + const { client: counting, reads } = countQueries(prisma14); + const resolver = new ControlPlaneResolver({ + controlPlaneReplica: counting, + controlPlanePrimary: counting, + cache: new ControlPlaneCache(), + splitEnabled: () => true, + workerVersionFreshReadEnabled: () => false, + }); + + await resolver.resolveWorkerVersion({ + environmentId: environment.id, + backgroundWorkerId: worker.id, + }); + const readsAfterFirst = reads(); const second = await resolver.resolveWorkerVersion({ environmentId: environment.id, backgroundWorkerId: worker.id, @@ -283,7 +314,7 @@ heteroPostgresTest( const second = await resolver.resolveWorkerVersion({ environmentId: environment.id }); expect(second?.worker.id).toBe(worker.id); - expect(reads()).toBe(readsAfterFirst); + expect(reads()).toBeGreaterThan(readsAfterFirst); } ); diff --git a/apps/webapp/vite.config.ts b/apps/webapp/vite.config.ts index 2dcdf2d47c..56ddae17c0 100644 --- a/apps/webapp/vite.config.ts +++ b/apps/webapp/vite.config.ts @@ -1,7 +1,42 @@ +import { readFileSync } from "node:fs"; import { vitePlugin as remix } from "@remix-run/dev"; -import { defaultClientConditions, defaultServerConditions, defineConfig } from "vite"; +import { defaultClientConditions, defaultServerConditions, defineConfig, type Plugin } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; +const packageVersions: Record = Object.fromEntries( + ["core", "trigger-sdk"].map((pkg) => [ + pkg, + ( + JSON.parse( + readFileSync(new URL(`../../packages/${pkg}/package.json`, import.meta.url), "utf-8") + ) as { version: string } + ).version, + ]) +); + +/** + * The `@triggerdotdev/source` condition resolves workspace packages to TS source, where + * each VERSION constant is the "0.0.0" placeholder (scripts/updateVersion.ts stamps the + * real version, but only in dist output, which this bundle never reads). Stamp the version + * modules during bundling so VERSION carries the real package version everywhere it's used. + */ +function stampPackageVersions(): Plugin { + return { + name: "stamp-package-versions", + transform(code, id) { + const match = id + .split("?")[0] + .match(/packages[/\\](core|trigger-sdk)[/\\]src[/\\]version\.ts$/); + if (match) { + return { + code: code.replace('"0.0.0"', JSON.stringify(packageVersions[match[1]])), + map: null, + }; + } + }, + }; +} + export default defineConfig({ plugins: [ remix({ @@ -10,6 +45,7 @@ export default defineConfig({ serverBuildFile: "index.mjs", }), tsconfigPaths(), + stampPackageVersions(), ], resolve: { // Resolve workspace packages to TS source (same condition the CLI uses) diff --git a/base-images/README.md b/base-images/README.md index 78f63a552a..88e568e50a 100644 --- a/base-images/README.md +++ b/base-images/README.md @@ -14,7 +14,10 @@ images derived from these behave like their upstream bases. ## Tags and pinning Tags are mutable and rebuilt in place on demand; each rebuild picks up Debian -security updates published up to its snapshot date. The +security updates published up to its snapshot date. Every publish also pushes +an immutable per-publish tag (snapshot timestamp plus commit, e.g. `22-bookworm-20260812-000000-45444a7`) so previously +published digests stay tag-referenced; never delete these, since shipped CLI +releases pin their digests. The runtime itself (the node or bun binaries from the upstream base) only moves when the base digests in `images.json` are bumped. When bumping a base digest, keep the snapshot at least as new as the upstream image's own archive diff --git a/internal-packages/clickhouse/schema/038_add_task_events_inserted_at_index.sql b/internal-packages/clickhouse/schema/038_add_task_events_inserted_at_index.sql new file mode 100644 index 0000000000..b9e756b2da --- /dev/null +++ b/internal-packages/clickhouse/schema/038_add_task_events_inserted_at_index.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- Source index supporting closed projector windows on newly written parts. +-- ADD INDEX only defines the index for new parts and does not materialize existing parts. +ALTER TABLE trigger_dev.task_events_v2 + ADD INDEX IF NOT EXISTS idx_inserted_at_projector inserted_at TYPE minmax GRANULARITY 1; + +-- +goose Down +ALTER TABLE trigger_dev.task_events_v2 + DROP INDEX IF EXISTS idx_inserted_at_projector; diff --git a/internal-packages/clickhouse/schema/039_create_webhook_deliveries_v1.sql b/internal-packages/clickhouse/schema/039_create_webhook_deliveries_v1.sql new file mode 100644 index 0000000000..40730b5592 --- /dev/null +++ b/internal-packages/clickhouse/schema/039_create_webhook_deliveries_v1.sql @@ -0,0 +1,31 @@ +-- +goose Up + +CREATE TABLE IF NOT EXISTS trigger_dev.webhook_deliveries_v1 +( + environment_id String, + organization_id String, + project_id String, + delivery_id String, + webhook_endpoint_id String, + + environment_type LowCardinality(String), + friendly_id String, + + external_delivery_id String DEFAULT '', + run_id String DEFAULT '', + + status LowCardinality(String), + + is_test UInt8 DEFAULT 0, + + created_at DateTime64(3), + updated_at DateTime64(3), + + _version UInt64, + _is_deleted UInt8 DEFAULT 0 +) +ENGINE = ReplacingMergeTree(_version, _is_deleted) +PARTITION BY toYYYYMM(created_at) +ORDER BY (organization_id, project_id, environment_id, created_at, delivery_id) +TTL toDateTime(created_at) + INTERVAL 60 DAY +SETTINGS ttl_only_drop_parts = 1, materialize_ttl_recalculate_only = 1; diff --git a/internal-packages/clickhouse/src/index.ts b/internal-packages/clickhouse/src/index.ts index 49d2d7c02b..bfdaededcc 100644 --- a/internal-packages/clickhouse/src/index.ts +++ b/internal-packages/clickhouse/src/index.ts @@ -48,6 +48,12 @@ import { getSessionsQueryBuilder, insertSessionsCompactArrays, } from "./sessions.js"; +import { + getWebhookDeliveriesQueryBuilder, + getWebhookDeliveriesCountQueryBuilder, + getWebhookDeliveriesGroupedCountQueryBuilder, + insertWebhookDeliveriesCompactArrays, +} from "./webhookDeliveries.js"; import { getGlobalModelMetrics, getGlobalModelComparison, @@ -79,6 +85,7 @@ export type * from "./queueMetrics.js"; export type * from "./llmModelAggregates.js"; export type * from "./errors.js"; export type * from "./sessions.js"; +export type * from "./webhookDeliveries.js"; export type * from "./client/queryBuilder.js"; // Re-export column constants, indices, and type-safe accessors @@ -93,6 +100,11 @@ export { } from "./taskRuns.js"; export { SESSION_COLUMNS, SESSION_INDEX, getSessionField } from "./sessions.js"; +export { + WEBHOOK_DELIVERY_COLUMNS, + WEBHOOK_DELIVERY_INDEX, + getWebhookDeliveryField, +} from "./webhookDeliveries.js"; // TSQL query execution export { @@ -301,6 +313,15 @@ export class ClickHouse { }; } + get webhookDeliveries() { + return { + insertCompactArrays: insertWebhookDeliveriesCompactArrays(this.writer), + queryBuilder: getWebhookDeliveriesQueryBuilder(this.reader), + countQueryBuilder: getWebhookDeliveriesCountQueryBuilder(this.reader), + groupedCountQueryBuilder: getWebhookDeliveriesGroupedCountQueryBuilder(this.reader), + }; + } + get taskEventsV2() { return { insert: insertTaskEventsV2(this.writer), diff --git a/internal-packages/clickhouse/src/webhookDeliveries.ts b/internal-packages/clickhouse/src/webhookDeliveries.ts new file mode 100644 index 0000000000..399ea0e1a7 --- /dev/null +++ b/internal-packages/clickhouse/src/webhookDeliveries.ts @@ -0,0 +1,155 @@ +import type { ClickHouseSettings } from "@clickhouse/client"; +import { z } from "zod"; +import type { ClickhouseReader, ClickhouseWriter } from "./client/types.js"; + +export const WebhookDeliveryV1 = z.object({ + environment_id: z.string(), + organization_id: z.string(), + project_id: z.string(), + delivery_id: z.string(), + webhook_endpoint_id: z.string(), + environment_type: z.string(), + friendly_id: z.string(), + external_delivery_id: z.string().default(""), + run_id: z.string().default(""), + status: z.string(), + is_test: z.number().int().default(0), + created_at: z.number().int(), + updated_at: z.number().int(), + _version: z.string(), + _is_deleted: z.number().int().default(0), +}); +export type WebhookDeliveryV1 = z.input; + +export const WEBHOOK_DELIVERY_COLUMNS = [ + "environment_id", + "organization_id", + "project_id", + "delivery_id", + "webhook_endpoint_id", + "environment_type", + "friendly_id", + "external_delivery_id", + "run_id", + "status", + "is_test", + "created_at", + "updated_at", + "_version", + "_is_deleted", +] as const; + +export type WebhookDeliveryColumnName = (typeof WEBHOOK_DELIVERY_COLUMNS)[number]; + +export const WEBHOOK_DELIVERY_INDEX = Object.fromEntries( + WEBHOOK_DELIVERY_COLUMNS.map((col, idx) => [col, idx]) +) as { readonly [K in WebhookDeliveryColumnName]: number }; + +export type WebhookDeliveryFieldTypes = { + environment_id: string; + organization_id: string; + project_id: string; + delivery_id: string; + webhook_endpoint_id: string; + environment_type: string; + friendly_id: string; + external_delivery_id: string; + run_id: string; + status: string; + is_test: number; + created_at: number; + updated_at: number; + _version: string; + _is_deleted: number; +}; + +export type WebhookDeliveryInsertArray = [ + environment_id: string, + organization_id: string, + project_id: string, + delivery_id: string, + webhook_endpoint_id: string, + environment_type: string, + friendly_id: string, + external_delivery_id: string, + run_id: string, + status: string, + is_test: number, + created_at: number, + updated_at: number, + _version: string, + _is_deleted: number, +]; + +export function getWebhookDeliveryField( + row: WebhookDeliveryInsertArray, + field: K +): WebhookDeliveryFieldTypes[K] { + return row[WEBHOOK_DELIVERY_INDEX[field]] as WebhookDeliveryFieldTypes[K]; +} + +export function insertWebhookDeliveriesCompactArrays( + ch: ClickhouseWriter, + settings?: ClickHouseSettings +) { + return ch.insertCompactRaw({ + name: "insertWebhookDeliveriesCompactArrays", + table: "trigger_dev.webhook_deliveries_v1", + columns: WEBHOOK_DELIVERY_COLUMNS, + settings, // no enable_json_type + }); +} + +export const WebhookDeliveryV1QueryResult = z.object({ + delivery_id: z.string(), + created_at_ms: z.number().int(), +}); +export type WebhookDeliveryV1QueryResult = z.infer; + +export function getWebhookDeliveriesQueryBuilder( + ch: ClickhouseReader, + settings?: ClickHouseSettings +) { + return ch.queryBuilder({ + name: "getWebhookDeliveries", + baseQuery: + "SELECT delivery_id, toUnixTimestamp64Milli(created_at) AS created_at_ms FROM trigger_dev.webhook_deliveries_v1 FINAL", + schema: WebhookDeliveryV1QueryResult, + settings, + }); +} + +export function getWebhookDeliveriesCountQueryBuilder( + ch: ClickhouseReader, + settings?: ClickHouseSettings +) { + return ch.queryBuilder({ + name: "getWebhookDeliveriesCount", + baseQuery: "SELECT count() as count FROM trigger_dev.webhook_deliveries_v1 FINAL", + schema: z.object({ count: z.number().int() }), + settings, + }); +} + +export const WebhookDeliveryGroupedCountResult = z.object({ + webhook_endpoint_id: z.string(), + count: z.number().int(), +}); +export type WebhookDeliveryGroupedCountResult = z.infer; + +// Per-endpoint delivery counts in one query (caller adds the scope/period WHERE + GROUP BY). Uses +// count(DISTINCT delivery_id) instead of FINAL: it dedupes the ReplacingMergeTree version rows (one +// per status transition) without the full-merge cost of FINAL, which matters when counting per +// endpoint for a list. +export function getWebhookDeliveriesGroupedCountQueryBuilder( + ch: ClickhouseReader, + settings?: ClickHouseSettings +) { + return ch.queryBuilder({ + name: "getWebhookDeliveriesGroupedCount", + baseQuery: + "SELECT webhook_endpoint_id, count(DISTINCT delivery_id) AS count FROM trigger_dev.webhook_deliveries_v1", + schema: WebhookDeliveryGroupedCountResult, + settings, + }); +} diff --git a/internal-packages/database/prisma/migrations/20260622120730_add_webhook_trigger_source/migration.sql b/internal-packages/database/prisma/migrations/20260622120730_add_webhook_trigger_source/migration.sql new file mode 100644 index 0000000000..6bdb947370 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260622120730_add_webhook_trigger_source/migration.sql @@ -0,0 +1,5 @@ +-- AlterEnum +-- Standalone enum-only migration (Q5): the new value is committed before any +-- table migration or code references it, so the wrapped-transaction restriction +-- on using a freshly added enum value never bites. +ALTER TYPE "public"."TaskTriggerSource" ADD VALUE 'WEBHOOK'; diff --git a/internal-packages/database/prisma/migrations/20260622120731_add_webhook_endpoint_and_delivery/migration.sql b/internal-packages/database/prisma/migrations/20260622120731_add_webhook_endpoint_and_delivery/migration.sql new file mode 100644 index 0000000000..27115888e7 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260622120731_add_webhook_endpoint_and_delivery/migration.sql @@ -0,0 +1,76 @@ +-- CreateEnum +CREATE TYPE "public"."WebhookEndpointStatus" AS ENUM ('ACTIVE', 'INACTIVE', 'DELETING'); + +-- CreateEnum +CREATE TYPE "public"."WebhookDeliveryStatus" AS ENUM ('PENDING', 'PROCESSING', 'SUCCEEDED', 'FAILED'); + +-- NOTE (Q5): ALTER TYPE "TaskTriggerSource" ADD VALUE 'WEBHOOK' lives in the earlier +-- 20260622120730_add_webhook_trigger_source migration, not here. + +-- CreateTable +CREATE TABLE "public"."WebhookEndpoint" ( + "id" TEXT NOT NULL, + "friendlyId" TEXT NOT NULL, + "opaqueId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "runtimeEnvironmentId" TEXT NOT NULL, + "environmentType" "public"."RuntimeEnvironmentType" NOT NULL, + "endpointTenantId" TEXT NOT NULL DEFAULT '', + "endpointExternalRef" TEXT NOT NULL DEFAULT '', + "source" TEXT NOT NULL, + "handlerWebhookId" TEXT NOT NULL, + "routingTarget" JSONB NOT NULL, + "verifierArtifact" JSONB NOT NULL, + "metadata" JSONB NOT NULL DEFAULT '{}', + "signingSecretKey" TEXT, + "status" "public"."WebhookEndpointStatus" NOT NULL DEFAULT 'ACTIVE', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WebhookEndpoint_pkey" PRIMARY KEY ("id") +); + +-- CreateTable (HAND-EDITED: PARTITION BY RANGE clause; clone of TaskEventPartitioned migration.sql:54) +CREATE TABLE "public"."WebhookDelivery" ( + "id" TEXT NOT NULL, + "friendlyId" TEXT NOT NULL, + "webhookEndpointId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "runtimeEnvironmentId" TEXT NOT NULL, + "environmentType" "public"."RuntimeEnvironmentType" NOT NULL, + "externalDeliveryId" TEXT NOT NULL, + "idempotencyKey" TEXT NOT NULL, + "runId" TEXT, + "status" "public"."WebhookDeliveryStatus" NOT NULL DEFAULT 'PENDING', + "isTest" BOOLEAN NOT NULL DEFAULT false, + "parsedEvent" JSONB, + "rawBodyHash" TEXT, + "errorMessage" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "processedAt" TIMESTAMP(3), + + CONSTRAINT "WebhookDelivery_pkey" PRIMARY KEY ("id","createdAt") +) PARTITION BY RANGE ("createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "WebhookEndpoint_friendlyId_key" ON "public"."WebhookEndpoint"("friendlyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WebhookEndpoint_opaqueId_key" ON "public"."WebhookEndpoint"("opaqueId"); + +-- CreateIndex +CREATE INDEX "WebhookEndpoint_runtimeEnvironmentId_source_idx" ON "public"."WebhookEndpoint"("runtimeEnvironmentId", "source"); + +-- CreateIndex +CREATE INDEX "WebhookEndpoint_projectId_createdAt_idx" ON "public"."WebhookEndpoint"("projectId", "createdAt" DESC); + +-- CreateIndex +CREATE UNIQUE INDEX "WebhookEndpoint_runtimeEnvironmentId_handlerWebhookId_endpo_key" ON "public"."WebhookEndpoint"("runtimeEnvironmentId", "handlerWebhookId", "endpointTenantId", "endpointExternalRef"); + +-- CreateIndex (on the PARENT; auto-propagates to every child at PARTITION OF time) +CREATE INDEX "WebhookDelivery_webhookEndpointId_createdAt_idx" ON "public"."WebhookDelivery"("webhookEndpointId", "createdAt" DESC); + +-- No DEFAULT partition on purpose: retention drops use DETACH ... CONCURRENTLY, which a default forbids. diff --git a/internal-packages/database/prisma/migrations/20260622130000_add_webhook_delivery_headers/migration.sql b/internal-packages/database/prisma/migrations/20260622130000_add_webhook_delivery_headers/migration.sql new file mode 100644 index 0000000000..88e702fd05 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260622130000_add_webhook_delivery_headers/migration.sql @@ -0,0 +1,4 @@ +-- Add the inbound request headers to webhook deliveries. Surfaced to the webhook task via +-- onEvent({ headers }). Nullable + additive; the ADD COLUMN on the RANGE-partitioned parent +-- cascades to all child partitions automatically. +ALTER TABLE "WebhookDelivery" ADD COLUMN "headers" JSONB; diff --git a/internal-packages/database/prisma/migrations/20260622140000_add_webhook_endpoint_secret_provisioning/migration.sql b/internal-packages/database/prisma/migrations/20260622140000_add_webhook_endpoint_secret_provisioning/migration.sql new file mode 100644 index 0000000000..49ef2aa160 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260622140000_add_webhook_endpoint_secret_provisioning/migration.sql @@ -0,0 +1,3 @@ +-- Who supplies the signing secret/key for a webhook endpoint ("provider" | "integrator" | "either"). +-- Drives the dashboard Connect UI (paste vs generate). Synced from the declared source. +ALTER TABLE "WebhookEndpoint" ADD COLUMN "secretProvisioning" TEXT NOT NULL DEFAULT 'either'; diff --git a/internal-packages/database/prisma/migrations/20260630120000_add_webhook_delivery_filter/migration.sql b/internal-packages/database/prisma/migrations/20260630120000_add_webhook_delivery_filter/migration.sql new file mode 100644 index 0000000000..b2166ea824 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260630120000_add_webhook_delivery_filter/migration.sql @@ -0,0 +1,11 @@ +-- Webhook delivery filters: a server-side predicate gates routing (not receipt). A verified delivery +-- that doesn't match becomes a FILTERED row (no run/session) with the reason recorded; the compiled +-- filter AST is stored on the endpoint and evaluated at ingest. + +ALTER TYPE "WebhookDeliveryStatus" ADD VALUE 'FILTERED'; + +ALTER TABLE "WebhookDelivery" ADD COLUMN "filterReason" TEXT; + +ALTER TABLE "WebhookEndpoint" ADD COLUMN "filter" TEXT; +ALTER TABLE "WebhookEndpoint" ADD COLUMN "filterAst" JSONB; +ALTER TABLE "WebhookEndpoint" ADD COLUMN "filterAstVersion" INTEGER; diff --git a/internal-packages/database/prisma/migrations/20260809120000_add_webhook_endpoint_manually_deactivated_at/migration.sql b/internal-packages/database/prisma/migrations/20260809120000_add_webhook_endpoint_manually_deactivated_at/migration.sql new file mode 100644 index 0000000000..c518f792d6 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260809120000_add_webhook_endpoint_manually_deactivated_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."WebhookEndpoint" ADD COLUMN "manuallyDeactivatedAt" TIMESTAMP(3); diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 3803e74c7f..44392406aa 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -766,6 +766,102 @@ enum TaskTriggerSource { STANDARD SCHEDULED AGENT + WEBHOOK +} + +model WebhookEndpoint { + id String @id @default(cuid()) + friendlyId String @unique // wh_... (WebhookEndpointId IdUtil "wh") + + /// URL key. CSPRNG, NOT a friendlyId: crypto.randomBytes(16) base64url. + /// Generated by the create path (deploy-sync / P2 API), NOT by @default. + opaqueId String @unique // global unique (Q2): ingress resolves the endpoint by opaqueId alone + + // scope (plain columns, NO @relation -> no FK, matching TaskEventPartitioned) + organizationId String + projectId String + runtimeEnvironmentId String + environmentType RuntimeEnvironmentType // denormalized from the env at deploy-sync (immutable per endpoint), Q3 + + // tenant scope. "" sentinel on declared (P1); P2 dynamic create fills these. + // Sentinel not null so the 4-column @@unique actually bites. + endpointTenantId String @default("") + endpointExternalRef String @default("") + + source String // provider tag e.g. "stripe","slack","github" + handlerWebhookId String // declared webhook() id (string ref, GOLDEN LAW, no relation) + routingTarget Json // RoutingTarget tagged union ({ type: "task" } | { type: "session" }) + verifierArtifact Json // VerifierArtifact tagged union (config|preset in P1) + filter String? // source filter DSL string (display/round-trip) + filterAst Json? // compiled FilterAst, evaluated at ingest; null = route all + filterAstVersion Int? // re-parse `filter` on a format bump + metadata Json @default("{}") // arbitrary user metadata; flows into the webhook task + + // who supplies the secret/key; drives the Connect UI (paste vs generate). From the source. + secretProvisioning String @default("either") // "provider" | "integrator" | "either" + + /// SecretReference.key string. Plain String, NO @relation -> no FK to SecretReference. + signingSecretKey String? + + status WebhookEndpointStatus @default(ACTIVE) + /// When an operator disabled the endpoint via the dashboard/API. Null means the declarative sync + /// owns the status: a redeploy that re-declares a previously-removed (auto-deactivated) webhook + /// reactivates it. Non-null means the operator disabled it, so the sync leaves the status alone. + manuallyDeactivatedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([runtimeEnvironmentId, handlerWebhookId, endpointTenantId, endpointExternalRef]) // deploy-sync key + @@index([runtimeEnvironmentId, source]) + @@index([projectId, createdAt(sort: Desc)]) +} + +enum WebhookEndpointStatus { + ACTIVE + INACTIVE + DELETING +} + +model WebhookDelivery { + id String @default(cuid()) + friendlyId String // whd_... (WebhookDeliveryId IdUtil "whd"). Uniqueness scoped by the composite PK. + + // plain columns, NO @relation -> no FK + webhookEndpointId String + organizationId String + projectId String + runtimeEnvironmentId String + environmentType RuntimeEnvironmentType + + externalDeliveryId String // provider id (Stripe event id, GitHub X-GitHub-Delivery) + idempotencyKey String // = externalDeliveryId; passed to Run Engine when routing to a task + runId String? // the triggered run, if task target + + status WebhookDeliveryStatus @default(PENDING) + + /// Set from the x-trigger-test ingress header; marks console/test-send deliveries so the list can filter them. + isTest Boolean @default(false) + + parsedEvent Json? // size-capped snapshot of the verified event (full event lives in ClickHouse) + headers Json? // inbound request headers, surfaced to the webhook task via onEvent({ headers }) + rawBodyHash String? // sha256 of raw bytes; cheap P2 replay anchor + errorMessage String? + filterReason String? // why a FILTERED delivery was not routed (failing clause + actual value) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Q14: real column; the status transitions update it, maps straight to CH updated_at + processedAt DateTime? + + @@id([id, createdAt]) // composite PK: the partition key MUST be in every PK/unique on a partitioned table + @@index([webhookEndpointId, createdAt(sort: Desc)]) // dashboard recent-deliveries; also partition-pruned +} + +enum WebhookDeliveryStatus { + PENDING + PROCESSING + SUCCEEDED + FAILED + FILTERED } model PlaygroundConversation { diff --git a/internal-packages/database/src/transaction.test.ts b/internal-packages/database/src/transaction.test.ts new file mode 100644 index 0000000000..f29297757d --- /dev/null +++ b/internal-packages/database/src/transaction.test.ts @@ -0,0 +1,340 @@ +import { describe, expect, it } from "vitest"; +import { + $transaction, + isTransactionAcquisitionError, + TokenBucketRetryBudget, + UNLIMITED_RETRY_BUDGET, + withTransactionStartRetry, + type TransactionStartRetryConfig, +} from "./transaction"; + +function acquisitionError() { + return { code: "P2028", message: "Unable to start a transaction in the given time." }; +} + +function poolTimeoutError() { + return { + code: "P2024", + message: "Timed out fetching a new connection from the connection pool.", + }; +} + +function inTxP2028() { + return { code: "P2028", message: "Transaction API error: Transaction already closed." }; +} + +const noSleep = (_ms: number) => Promise.resolve(); + +function config( + overrides?: Partial +): TransactionStartRetryConfig { + return { + options: { + enabled: true, + maxAttempts: 2, + backoffMinMs: 50, + backoffMaxMs: 250, + ...overrides, + }, + sleep: noSleep, + random: () => 0, + }; +} + +function counter() { + let calls = 0; + return { + get calls() { + return calls; + }, + tick() { + calls += 1; + }, + }; +} + +describe("isTransactionAcquisitionError", () => { + it("is true only for P2028 raised at acquisition", () => { + expect(isTransactionAcquisitionError(acquisitionError())).toBe(true); + }); + + it("is false for P2024 (pool exhaustion — retrying makes it worse)", () => { + expect(isTransactionAcquisitionError(poolTimeoutError())).toBe(false); + }); + + it("is false for a P2028 raised inside a running transaction", () => { + expect(isTransactionAcquisitionError(inTxP2028())).toBe(false); + }); + + it("is true for the driver-adapter acquire timeout (pre-BEGIN, no code)", () => { + expect( + isTransactionAcquisitionError({ message: "Timeout exceeded when trying to connect" }) + ).toBe(true); + expect( + isTransactionAcquisitionError(new Error("timeout exceeded when trying to connect")) + ).toBe(true); + }); + + it("is false for non-Prisma errors", () => { + expect(isTransactionAcquisitionError(new Error("boom"))).toBe(false); + expect(isTransactionAcquisitionError(undefined)).toBe(false); + }); +}); + +describe("withTransactionStartRetry", () => { + it("runs once on success", async () => { + const c = counter(); + const run = async () => { + c.tick(); + return "ok"; + }; + await expect(withTransactionStartRetry(run, config())).resolves.toBe("ok"); + expect(c.calls).toBe(1); + }); + + it("retries an acquisition failure then succeeds", async () => { + const c = counter(); + const run = async () => { + c.tick(); + if (c.calls === 1) throw acquisitionError(); + return "ok"; + }; + await expect(withTransactionStartRetry(run, config())).resolves.toBe("ok"); + expect(c.calls).toBe(2); + }); + + it("does NOT retry P2024", async () => { + const c = counter(); + const err = poolTimeoutError(); + const run = async () => { + c.tick(); + throw err; + }; + await expect(withTransactionStartRetry(run, config())).rejects.toBe(err); + expect(c.calls).toBe(1); + }); + + it("stops after maxAttempts total attempts", async () => { + const c = counter(); + const run = async () => { + c.tick(); + throw acquisitionError(); + }; + await expect(withTransactionStartRetry(run, config({ maxAttempts: 3 }))).rejects.toMatchObject({ + code: "P2028", + }); + expect(c.calls).toBe(3); + }); + + it("runs once when disabled", async () => { + const c = counter(); + const run = async () => { + c.tick(); + throw acquisitionError(); + }; + await expect(withTransactionStartRetry(run, config({ enabled: false }))).rejects.toMatchObject({ + code: "P2028", + }); + expect(c.calls).toBe(1); + }); + + it("does not retry when the budget is exhausted", async () => { + const c = counter(); + const budgetChecks = counter(); + const run = async () => { + c.tick(); + throw acquisitionError(); + }; + const budget = { + tryConsume() { + budgetChecks.tick(); + return false; + }, + }; + await expect( + withTransactionStartRetry(run, { ...config({ maxAttempts: 5 }), budget }) + ).rejects.toMatchObject({ code: "P2028" }); + expect(c.calls).toBe(1); + expect(budgetChecks.calls).toBe(1); + }); + + it("does not retry when canRetry() is false (callback already entered)", async () => { + const c = counter(); + const run = async () => { + c.tick(); + throw acquisitionError(); + }; + await expect( + withTransactionStartRetry(run, config({ maxAttempts: 5 }), () => false) + ).rejects.toMatchObject({ code: "P2028" }); + expect(c.calls).toBe(1); + }); + + it("sleeps a jittered delay within [min, max]", async () => { + const c = counter(); + const delays: number[] = []; + const run = async () => { + c.tick(); + if (c.calls === 1) throw acquisitionError(); + return "ok"; + }; + await withTransactionStartRetry(run, { + options: { enabled: true, maxAttempts: 2, backoffMinMs: 50, backoffMaxMs: 250 }, + sleep: (ms) => { + delays.push(ms); + return Promise.resolve(); + }, + random: () => 0.5, + }); + expect(delays).toEqual([150]); + }); +}); + +describe("TokenBucketRetryBudget", () => { + it("allows up to burst then denies", () => { + const budget = new TokenBucketRetryBudget({ ratePerSec: 0, burst: 2, now: () => 1000 }); + expect(budget.tryConsume()).toBe(true); + expect(budget.tryConsume()).toBe(true); + expect(budget.tryConsume()).toBe(false); + }); + + it("refills over time", () => { + let now = 1000; + const budget = new TokenBucketRetryBudget({ ratePerSec: 10, burst: 1, now: () => now }); + expect(budget.tryConsume()).toBe(true); + expect(budget.tryConsume()).toBe(false); + now += 100; + expect(budget.tryConsume()).toBe(true); + }); +}); + +describe("$transaction startRetry wiring", () => { + function fakeClient(behavior: (call: number) => Promise) { + const c = counter(); + return { + client: { + $transaction: (fn: (tx: unknown) => Promise, _options?: unknown) => { + c.tick(); + return behavior(c.calls).then(() => fn({})); + }, + } as any, + get calls() { + return c.calls; + }, + }; + } + + it("retries a transaction start that fails with an acquisition error", async () => { + const fake = fakeClient((call) => + call === 1 ? Promise.reject(acquisitionError()) : Promise.resolve() + ); + const result = await $transaction( + fake.client, + async () => "done", + () => {}, + { startRetry: config() } + ); + expect(result).toBe("done"); + expect(fake.calls).toBe(2); + }); + + it("does not retry without a startRetry config", async () => { + const err = acquisitionError(); + const fake = fakeClient(() => Promise.reject(err)); + let captured: unknown; + await expect( + $transaction( + fake.client, + async () => "x", + (e) => { + captured = e; + }, + {} + ) + ).rejects.toBe(err); + expect(fake.calls).toBe(1); + expect(captured).toBe(err); + }); + + it("passes maxWait through to prisma.$transaction options", async () => { + let seenOptions: unknown; + const client = { + $transaction: (fn: (tx: unknown) => Promise, options?: unknown) => { + seenOptions = options; + return fn({}); + }, + } as any; + await $transaction( + client, + async () => "x", + () => {}, + { maxWait: 10000 } + ); + expect(seenOptions).toEqual({ maxWait: 10000 }); + }); + + it("does not let maxRetries retry an acquisition error while startRetry is active", async () => { + const fake = fakeClient(() => Promise.reject(acquisitionError())); + await expect( + $transaction( + fake.client, + async () => "x", + () => {}, + { startRetry: config({ maxAttempts: 2 }), maxRetries: 3 } + ) + ).rejects.toMatchObject({ code: "P2028" }); + expect(fake.calls).toBe(2); + }); + + it("falls back to maxRetries for acquisition errors when startRetry is disabled", async () => { + const fake = fakeClient(() => Promise.reject(acquisitionError())); + await expect( + $transaction( + fake.client, + async () => "x", + () => {}, + { startRetry: config({ enabled: false }), maxRetries: 3 } + ) + ).rejects.toMatchObject({ code: "P2028" }); + expect(fake.calls).toBe(4); + }); + + it("still lets maxRetries retry a serialization error (P2034)", async () => { + const serializationError = { code: "P2034", message: "write conflict / deadlock" }; + const fake = fakeClient(() => Promise.reject(serializationError)); + await expect( + $transaction( + fake.client, + async () => "x", + () => {}, + { maxRetries: 2 } + ) + ).rejects.toMatchObject({ code: "P2034" }); + expect(fake.calls).toBe(3); + }); + + it("does not retry an acquisition-shaped error thrown after the callback entered", async () => { + let calls = 0; + const prisma = { + $transaction: (fn: (tx: unknown) => Promise) => { + calls += 1; + return fn({}).then(() => { + throw acquisitionError(); + }); + }, + } as any; + await expect( + $transaction( + prisma, + async () => "x", + () => {}, + { startRetry: config() } + ) + ).rejects.toMatchObject({ code: "P2028" }); + expect(calls).toBe(1); + }); + + it("UNLIMITED_RETRY_BUDGET always consumes", () => { + expect(UNLIMITED_RETRY_BUDGET.tryConsume()).toBe(true); + }); +}); diff --git a/internal-packages/database/src/transaction.ts b/internal-packages/database/src/transaction.ts index 806ae60403..7df4a8ede6 100644 --- a/internal-packages/database/src/transaction.ts +++ b/internal-packages/database/src/transaction.ts @@ -18,6 +18,25 @@ export type PrismaClientOrTransaction = PrismaClient | PrismaTransactionClient; export type PrismaReplicaClient = Omit; +// Narrow client views for the webhook feature's tables, prepping it to run on a +// dedicated Postgres: control-plane models are absent, so touching one on the +// webhook client (or spanning both DBs in a $transaction) is a compile error. +export type WebhookDatabase = Pick< + PrismaClient, + | "webhookEndpoint" + | "webhookDelivery" + | "$transaction" + | "$queryRaw" + | "$queryRawUnsafe" + | "$executeRaw" + | "$executeRawUnsafe" +>; + +export type WebhookReplicaDatabase = Pick< + PrismaReplicaClient, + "webhookEndpoint" | "webhookDelivery" | "$queryRaw" | "$queryRawUnsafe" +>; + export { Decimal }; function isTransactionClient(prisma: PrismaClientOrTransaction): prisma is PrismaTransactionClient { @@ -59,6 +78,148 @@ export function isPrismaRaceConditionError(error: unknown): boolean { return error.code === "P2025"; } +const TRANSACTION_ACQUISITION_MESSAGE = /Unable to start a transaction in the given time/i; + +/** + * True for a connection-acquisition failure raised BEFORE any SQL ran, so there + * is nothing to undo and retrying it is safe. Two shapes: + * - the default engine's P2028 "Unable to start a transaction in the given time" + * (couldn't borrow a connection within `maxWait`), and + * - the pg driver adapter's "timeout exceeded when trying to connect" (the pg + * Pool couldn't hand out a connection within `connectionTimeoutMillis`). + * Deliberately narrower than {@link isPrismaRetriableError}: it excludes P2024 + * (pool exhausted) and P2028s raised from inside a running transaction. Callers + * additionally gate retries on the transaction callback not having entered, so a + * same-shaped error from a nested transaction never re-runs a side-effectful body. + */ +export function isTransactionAcquisitionError(error: unknown): boolean { + const message = (error as { message?: unknown })?.message; + + if (isPrismaKnownError(error) && error.code === "P2028") { + return typeof message === "string" && TRANSACTION_ACQUISITION_MESSAGE.test(message); + } + + return typeof message === "string" && ADAPTER_ACQUIRE_TIMEOUT.test(message); +} + +/** Retry tuning for transaction-start (P2028-at-acquisition) failures. */ +export type TransactionStartRetryOptions = { + /** Kill switch. When false, {@link withTransactionStartRetry} runs the thunk once. */ + enabled: boolean; + /** Total attempts including the first. `1` (or less) disables retrying. */ + maxAttempts: number; + /** Lower bound of the jittered backoff between attempts, in ms. */ + backoffMinMs: number; + /** Upper bound of the jittered backoff between attempts, in ms. */ + backoffMaxMs: number; +}; + +/** + * A shared rate limiter so a mass freeze (every request failing tx-start at + * once) cannot amplify into a retry storm. One instance is shared across all + * retrying call sites; `tryConsume` returns false when the budget is spent. + */ +export interface RetryBudget { + tryConsume(): boolean; +} + +/** Budget that never denies — the default when a caller supplies no budget. */ +export const UNLIMITED_RETRY_BUDGET: RetryBudget = { tryConsume: () => true }; + +/** + * Token-bucket {@link RetryBudget}: refills at `ratePerSec` tokens/second up to + * `burst`, one token per retry. `now` is injectable for tests. + */ +export class TokenBucketRetryBudget implements RetryBudget { + #tokens: number; + readonly #capacity: number; + readonly #refillPerMs: number; + readonly #now: () => number; + #lastRefillAt: number; + + constructor(options: { ratePerSec: number; burst: number; now?: () => number }) { + this.#capacity = Math.max(0, options.burst); + this.#refillPerMs = Math.max(0, options.ratePerSec) / 1000; + this.#now = options.now ?? Date.now; + this.#tokens = this.#capacity; + this.#lastRefillAt = this.#now(); + } + + tryConsume(): boolean { + const now = this.#now(); + const elapsed = Math.max(0, now - this.#lastRefillAt); + if (elapsed > 0) { + this.#tokens = Math.min(this.#capacity, this.#tokens + elapsed * this.#refillPerMs); + this.#lastRefillAt = now; + } + if (this.#tokens >= 1) { + this.#tokens -= 1; + return true; + } + return false; + } +} + +export type TransactionStartRetryConfig = { + options: TransactionStartRetryOptions; + /** Shared budget; defaults to {@link UNLIMITED_RETRY_BUDGET} when omitted. */ + budget?: RetryBudget; + /** Injectable for tests. */ + sleep?: (ms: number) => Promise; + /** Injectable for tests; returns [0, 1). */ + random?: () => number; + /** Observability hook fired just before each backoff sleep. */ + onRetry?: (info: { attempt: number; delayMs: number; error: unknown }) => void; +}; + +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Runs `run` and, ONLY when it throws a P2028-at-acquisition error + * ({@link isTransactionAcquisitionError}), retries it up to + * `options.maxAttempts` total attempts with jittered backoff, gated by the + * shared `budget`. Any other error (and an exhausted budget) rethrows + * immediately. When retrying is disabled the thunk runs exactly once. + */ +export async function withTransactionStartRetry( + run: () => Promise, + config?: TransactionStartRetryConfig, + canRetry?: () => boolean +): Promise { + if (!config || !config.options.enabled || config.options.maxAttempts <= 1) { + return run(); + } + + const { maxAttempts, backoffMinMs, backoffMaxMs } = config.options; + const budget = config.budget ?? UNLIMITED_RETRY_BUDGET; + const sleep = config.sleep ?? defaultSleep; + const random = config.random ?? Math.random; + + let attempt = 1; + while (true) { + try { + return await run(); + } catch (error) { + if ( + attempt >= maxAttempts || + (canRetry !== undefined && !canRetry()) || + !isTransactionAcquisitionError(error) || + !budget.tryConsume() + ) { + throw error; + } + const low = Math.max(0, Math.min(backoffMinMs, backoffMaxMs)); + const high = Math.max(low, backoffMaxMs); + const delayMs = Math.round(low + random() * (high - low)); + config.onRetry?.({ attempt, delayMs, error }); + await sleep(delayMs); + attempt += 1; + } + } +} + export type PrismaTransactionOptions = { /** The maximum amount of time (in ms) Prisma Client will wait to acquire a transaction from the database. The default value is 2000ms. */ maxWait?: number; @@ -77,6 +238,14 @@ export type PrismaTransactionOptions = { * See https://www.prisma.io/docs/orm/prisma-client/queries/transactions#transaction-timing-issues */ maxRetries?: number; + + /** + * When set, retry a transaction START that fails with P2028-at-acquisition + * (see {@link withTransactionStartRetry}). Config is threaded in from the app + * boundary (IoC); this library never reads env. Only wraps the top-level + * attempt, so it never nests with the `maxRetries` serialization retry. + */ + startRetry?: TransactionStartRetryConfig; }; export async function $transaction( @@ -90,11 +259,25 @@ export async function $transaction( return fn(prisma); } + const startRetry = attempt === 0 ? options?.startRetry : undefined; + const startRetryActive = + !!startRetry && startRetry.options.enabled && startRetry.options.maxAttempts > 1; + + let entered = false; try { - return await (prisma as PrismaClient).$transaction(fn, options); + return await withTransactionStartRetry( + () => + (prisma as PrismaClient).$transaction((tx) => { + entered = true; + return fn(tx as PrismaTransactionClient); + }, options), + startRetry, + () => !entered + ); } catch (error) { if ( isPrismaRetriableError(error) && + !(startRetryActive && isTransactionAcquisitionError(error)) && typeof options?.maxRetries === "number" && attempt < options.maxRetries ) { diff --git a/internal-packages/replication/src/client.ts b/internal-packages/replication/src/client.ts index a45831dc57..1a128a41e8 100644 --- a/internal-packages/replication/src/client.ts +++ b/internal-packages/replication/src/client.ts @@ -35,6 +35,12 @@ export interface LogicalReplicationClientOptions { * The name of the publication to use. */ publicationName: string; + /** + * Whether to create the publication with `publish_via_partition_root = true`. + * Required when the replicated table is a partitioned parent: without it, child + * partitions publish nothing and the slot stays silent (default: false). + */ + publishViaPartitionRoot?: boolean; /** * A connected Redis client instance for Redlock. */ @@ -616,12 +622,18 @@ export class LogicalReplicationClient { return true; } + const publicationWithOptions: string[] = []; + if (this.options.publicationActions) { + publicationWithOptions.push(`publish = '${this.options.publicationActions.join(", ")}'`); + } + if (this.options.publishViaPartitionRoot) { + publicationWithOptions.push("publish_via_partition_root = true"); + } + const [createError] = await tryCatch( this.client.query( `CREATE PUBLICATION "${this.options.publicationName}" FOR TABLE "${this.options.table}" ${ - this.options.publicationActions - ? `WITH (publish = '${this.options.publicationActions.join(", ")}')` - : "" + publicationWithOptions.length > 0 ? `WITH (${publicationWithOptions.join(", ")})` : "" };` ) ); diff --git a/internal-packages/run-engine/package.json b/internal-packages/run-engine/package.json index 516e6a1869..51bb0d6da8 100644 --- a/internal-packages/run-engine/package.json +++ b/internal-packages/run-engine/package.json @@ -29,7 +29,7 @@ "@trigger.dev/database": "workspace:*", "@internal/cache": "workspace:*", "assert-never": "^1.2.1", - "nanoid": "3.3.8", + "nanoid": "3.3.18", "redlock": "5.0.0-beta.2", "seedrandom": "^3.0.5", "zod": "3.25.76", diff --git a/internal-packages/run-engine/src/engine/controlPlaneResolver.ts b/internal-packages/run-engine/src/engine/controlPlaneResolver.ts index da5f7b0060..114095cc67 100644 --- a/internal-packages/run-engine/src/engine/controlPlaneResolver.ts +++ b/internal-packages/run-engine/src/engine/controlPlaneResolver.ts @@ -1,11 +1,8 @@ import type { BackgroundWorker, - BackgroundWorkerTask, Prisma, PrismaClient, RuntimeEnvironmentType, - TaskQueue, - WorkerDeployment, } from "@trigger.dev/database"; import { CURRENT_DEPLOYMENT_LABEL } from "@trigger.dev/core/v3/isomorphic"; import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment"; @@ -51,25 +48,113 @@ export type ResolvedEngineEnv = { */ export type ResolvedAuthenticatedEnv = AuthenticatedEnvironment & { git: Prisma.JsonValue | null }; +/** + * The BackgroundWorkerTask columns the dequeue resolve path actually reads. The worker's + * whole task set (~73 rows) is fetched for one matched task, so pulling the unread heavy + * JSON columns (`payloadSchema`, `config`, `queueConfig`, `description`) shipped ~62KB/query + * on the hottest control-plane read. `machineConfig`/`retryConfig` are read at dequeue and stay. + */ +export type ResolvedWorkerTask = { + id: string; + slug: string; + machineConfig: Prisma.JsonValue | null; + retryConfig: Prisma.JsonValue | null; + maxDurationInSeconds: number | null; +}; + +/** The `select` that yields a `ResolvedWorkerTask`. */ +export const resolvedWorkerTaskSelect = { + id: true, + slug: true, + machineConfig: true, + retryConfig: true, + maxDurationInSeconds: true, +} satisfies Prisma.BackgroundWorkerTaskSelect; + +/** + * The TaskQueue columns the dequeue resolve path uses: `id` and `name` (the matcher keys on + * `lockedQueueId`/`name`). Drops the unread `rateLimit` JSON and the concurrency/type scalars. + */ +export type ResolvedTaskQueue = { + id: string; + name: string; +}; + +/** The `select` that yields a `ResolvedTaskQueue`. */ +export const resolvedTaskQueueSelect = { + id: true, + name: true, +} satisfies Prisma.TaskQueueSelect; + +/** + * The WorkerDeployment columns the dequeue resolve path reads: `id`, `friendlyId`, + * `imageReference`, `imagePlatform`. Drops the unread heavy JSON columns (`externalBuildData`, + * `buildServerMetadata`, `errorData`, `git`) that this single-row read otherwise ships. + */ +export type ResolvedWorkerDeployment = { + id: string; + friendlyId: string; + imageReference: string | null; + imagePlatform: string; +}; + +/** The `select` that yields a `ResolvedWorkerDeployment`. */ +export const resolvedWorkerDeploymentSelect = { + id: true, + friendlyId: true, + imageReference: true, + imagePlatform: true, +} satisfies Prisma.WorkerDeploymentSelect; + /** Identical to dequeue's `WorkerDeploymentWithWorkerTasks`. */ export type ResolvedWorkerVersion = { worker: BackgroundWorker; - tasks: BackgroundWorkerTask[]; - queues: TaskQueue[]; - deployment: WorkerDeployment | null; + tasks: ResolvedWorkerTask[]; + queues: ResolvedTaskQueue[]; + deployment: ResolvedWorkerDeployment | null; +}; + +/** + * Optional dispatch filter threaded from the dequeue consumer. When present, the resolve fetches + * ONLY the task matching `taskIdentifier` and the queue matching `queue` (index point-lookups via + * `@@unique([workerId, slug])` / `@@unique([runtimeEnvironmentId, name])`) instead of the worker's + * whole task/queue set. Absent (any non-dequeue caller) keeps the full-set behaviour. + */ +export type WorkerVersionDispatchFilter = { + taskIdentifier?: string; + queue?: { lockedQueueId?: string | null; name: string }; }; export interface ControlPlaneResolver { resolveEnv(environmentId: string): Promise; resolveAuthenticatedEnv(environmentId: string): Promise; - resolveWorkerVersion(args: { - environmentId: string; - type: RuntimeEnvironmentType; - workerId?: string; - }): Promise; + resolveWorkerVersion( + args: { + environmentId: string; + type: RuntimeEnvironmentType; + workerId?: string; + } & WorkerVersionDispatchFilter + ): Promise; assertEnvExists(environmentId: string): Promise; } +type WorkerVersionWheres = { + taskWhere: { slug: string } | undefined; + queueWhere: { id: string } | { name: string } | undefined; +}; + +/** Build the nested-include `where`s for a dispatch filter (undefined = fetch the whole set). */ +export function workerVersionWheres(filter: WorkerVersionDispatchFilter): WorkerVersionWheres { + return { + taskWhere: filter.taskIdentifier ? { slug: filter.taskIdentifier } : undefined, + queueWhere: filter.queue + ? filter.queue.lockedQueueId + ? { id: filter.queue.lockedQueueId } + : { name: filter.queue.name } + : undefined, + }; +} + export class PassthroughControlPlaneResolver implements ControlPlaneResolver { readonly #prisma: PrismaClient; @@ -185,31 +270,39 @@ export class PassthroughControlPlaneResolver implements ControlPlaneResolver { // no cross-seam FK to replace (matches main, which dropped the TaskRun env FK). } - async resolveWorkerVersion(args: { - environmentId: string; - type: RuntimeEnvironmentType; - workerId?: string; - }): Promise { + async resolveWorkerVersion( + args: { + environmentId: string; + type: RuntimeEnvironmentType; + workerId?: string; + } & WorkerVersionDispatchFilter + ): Promise { const { environmentId, type, workerId } = args; + const wheres = workerVersionWheres(args); if (type === "DEVELOPMENT") { - return workerId ? this.#getWorkerById(workerId) : this.#getMostRecentWorker(environmentId); + return workerId + ? this.#getWorkerById(workerId, wheres) + : this.#getMostRecentWorker(environmentId, wheres); } return workerId - ? this.#getWorkerDeploymentFromWorker(workerId) - : this.#getManagedWorkerFromCurrentlyPromotedDeployment(environmentId); + ? this.#getWorkerDeploymentFromWorker(workerId, wheres) + : this.#getManagedWorkerFromCurrentlyPromotedDeployment(environmentId, wheres); } - async #getWorkerDeploymentFromWorker(workerId: string): Promise { + async #getWorkerDeploymentFromWorker( + workerId: string, + wheres: WorkerVersionWheres + ): Promise { const worker = await this.#prisma.backgroundWorker.findFirst({ where: { id: workerId, }, include: { - deployment: true, - tasks: true, - queues: true, + deployment: { select: resolvedWorkerDeploymentSelect }, + tasks: { where: wheres.taskWhere, select: resolvedWorkerTaskSelect }, + queues: { where: wheres.queueWhere, select: resolvedTaskQueueSelect }, }, }); @@ -225,14 +318,17 @@ export class PassthroughControlPlaneResolver implements ControlPlaneResolver { }; } - async #getMostRecentWorker(environmentId: string): Promise { + async #getMostRecentWorker( + environmentId: string, + wheres: WorkerVersionWheres + ): Promise { const worker = await this.#prisma.backgroundWorker.findFirst({ where: { runtimeEnvironmentId: environmentId, }, include: { - tasks: true, - queues: true, + tasks: { where: wheres.taskWhere, select: resolvedWorkerTaskSelect }, + queues: { where: wheres.queueWhere, select: resolvedTaskQueueSelect }, }, orderBy: [{ createdAt: "desc" }, { id: "desc" }], }); @@ -244,15 +340,18 @@ export class PassthroughControlPlaneResolver implements ControlPlaneResolver { return { worker, tasks: worker.tasks, queues: worker.queues, deployment: null }; } - async #getWorkerById(workerId: string): Promise { + async #getWorkerById( + workerId: string, + wheres: WorkerVersionWheres + ): Promise { const worker = await this.#prisma.backgroundWorker.findFirst({ where: { id: workerId, }, include: { - deployment: true, - tasks: true, - queues: true, + deployment: { select: resolvedWorkerDeploymentSelect }, + tasks: { where: wheres.taskWhere, select: resolvedWorkerTaskSelect }, + queues: { where: wheres.queueWhere, select: resolvedTaskQueueSelect }, }, }); @@ -269,7 +368,8 @@ export class PassthroughControlPlaneResolver implements ControlPlaneResolver { } async #getManagedWorkerFromCurrentlyPromotedDeployment( - environmentId: string + environmentId: string, + wheres: WorkerVersionWheres ): Promise { const promotion = await this.#prisma.workerDeploymentPromotion.findFirst({ where: { @@ -278,11 +378,13 @@ export class PassthroughControlPlaneResolver implements ControlPlaneResolver { }, include: { deployment: { - include: { + select: { + ...resolvedWorkerDeploymentSelect, + type: true, worker: { include: { - tasks: true, - queues: true, + tasks: { where: wheres.taskWhere, select: resolvedWorkerTaskSelect }, + queues: { where: wheres.queueWhere, select: resolvedTaskQueueSelect }, }, }, }, @@ -296,11 +398,17 @@ export class PassthroughControlPlaneResolver implements ControlPlaneResolver { if (promotion.deployment.type === "MANAGED") { // This is a run engine v2 deployment, so return it + const { worker } = promotion.deployment; return { - worker: promotion.deployment.worker, - tasks: promotion.deployment.worker.tasks, - queues: promotion.deployment.worker.queues, - deployment: promotion.deployment, + worker, + tasks: worker.tasks, + queues: worker.queues, + deployment: { + id: promotion.deployment.id, + friendlyId: promotion.deployment.friendlyId, + imageReference: promotion.deployment.imageReference, + imagePlatform: promotion.deployment.imagePlatform, + }, }; } @@ -311,11 +419,12 @@ export class PassthroughControlPlaneResolver implements ControlPlaneResolver { type: "MANAGED", }, orderBy: [{ createdAt: "desc" }, { id: "desc" }], - include: { + select: { + ...resolvedWorkerDeploymentSelect, worker: { include: { - tasks: true, - queues: true, + tasks: { where: wheres.taskWhere, select: resolvedWorkerTaskSelect }, + queues: { where: wheres.queueWhere, select: resolvedTaskQueueSelect }, }, }, }, @@ -329,7 +438,12 @@ export class PassthroughControlPlaneResolver implements ControlPlaneResolver { worker: latestV2Deployment.worker, tasks: latestV2Deployment.worker.tasks, queues: latestV2Deployment.worker.queues, - deployment: latestV2Deployment, + deployment: { + id: latestV2Deployment.id, + friendlyId: latestV2Deployment.friendlyId, + imageReference: latestV2Deployment.imageReference, + imagePlatform: latestV2Deployment.imagePlatform, + }, }; } } diff --git a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts index 8887c850a5..b1584cfb69 100644 --- a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts @@ -6,14 +6,16 @@ import { generateInternalId, getMaxDuration, SnapshotId } from "@trigger.dev/cor import { placementTag } from "@trigger.dev/core/v3/serverOnly"; import type { BackgroundWorker, - BackgroundWorkerTask, Prisma, PrismaClientOrTransaction, RuntimeEnvironmentType, - TaskQueue, - WorkerDeployment, } from "@trigger.dev/database"; import type { BillingCache } from "../billingCache.js"; +import type { + ResolvedTaskQueue, + ResolvedWorkerDeployment, + ResolvedWorkerTask, +} from "../controlPlaneResolver.js"; import { sendNotificationToWorker } from "../eventBus.js"; import { getMachinePreset } from "../machinePresets.js"; @@ -88,16 +90,16 @@ type RunWithBackgroundWorkerTasksResult = run: RunWithDequeueScalars; environmentType: RuntimeEnvironmentType; worker: BackgroundWorker; - task: BackgroundWorkerTask; - queue: TaskQueue; - deployment: WorkerDeployment | null; + task: ResolvedWorkerTask; + queue: ResolvedTaskQueue; + deployment: ResolvedWorkerDeployment | null; }; type WorkerDeploymentWithWorkerTasks = { worker: BackgroundWorker; - tasks: BackgroundWorkerTask[]; - queues: TaskQueue[]; - deployment: WorkerDeployment | null; + tasks: ResolvedWorkerTask[]; + queues: ResolvedTaskQueue[]; + deployment: ResolvedWorkerDeployment | null; }; export class DequeueSystem { @@ -893,6 +895,8 @@ export class DequeueSystem { environmentId: run.runtimeEnvironmentId, type: env.type, workerId: workerId ?? undefined, + taskIdentifier: run.taskIdentifier, + queue: { lockedQueueId: run.lockedQueueId, name: run.queue }, }); if (!workerWithTasks) { diff --git a/internal-packages/run-engine/src/engine/tests/dequeueSystem.controlPlaneResolver.test.ts b/internal-packages/run-engine/src/engine/tests/dequeueSystem.controlPlaneResolver.test.ts index 3ea56bde8a..3fce8def73 100644 --- a/internal-packages/run-engine/src/engine/tests/dequeueSystem.controlPlaneResolver.test.ts +++ b/internal-packages/run-engine/src/engine/tests/dequeueSystem.controlPlaneResolver.test.ts @@ -487,6 +487,102 @@ describe("DequeueSystem controlPlaneResolver (latest-v2 fallback + workerId bran ); }); +describe("DequeueSystem controlPlaneResolver (worker-task read shape)", () => { + /** + * The resolver must ship only the columns the dequeue path reads: for each task id/slug + + * machineConfig/retryConfig/maxDurationInSeconds; for the deployment id/friendlyId/ + * imageReference/imagePlatform; for each queue id/name. The heavy JSON columns it never reads + * (task payloadSchema/config/queueConfig/description, deployment externalBuildData/ + * buildServerMetadata/errorData/git, queue rateLimit) must NOT come back, so the hot + * control-plane read stops shipping the large per-query payload. + */ + containerTest( + "resolveWorkerVersion returns only the dequeue-read columns and drops the heavy JSON columns", + async ({ prisma }) => { + const taskSlug = "test-task"; + const cp = await seedControlPlane(prisma as unknown as PrismaClient, "slim", taskSlug); + + await prisma.backgroundWorkerTask.update({ + where: { id: cp.task.id }, + data: { + machineConfig: { preset: "small-2x" }, + retryConfig: { maxAttempts: 5, factor: 2, minTimeoutInMs: 100, maxTimeoutInMs: 1000 }, + maxDurationInSeconds: 120, + payloadSchema: { type: "object", properties: { message: { type: "string" } } }, + config: { type: "ai-sdk-chat" }, + queueConfig: { concurrencyLimit: 7 }, + description: "must not be shipped on the dequeue read", + }, + }); + + await prisma.workerDeployment.update({ + where: { id: cp.deployment.id }, + data: { + externalBuildData: { imageTag: "must-not-ship", buildId: "b1" }, + buildServerMetadata: { logs: "must-not-ship" }, + errorData: { message: "must-not-ship" }, + git: { commitSha: "deadbeef", must: "not-ship" }, + }, + }); + + await prisma.taskQueue.update({ + where: { id: cp.queue.id }, + data: { rateLimit: { limit: 10, must: "not-ship" } }, + }); + + const resolver = new PassthroughControlPlaneResolver({ + prisma: prisma as unknown as PrismaClient, + }); + + const version = await resolver.resolveWorkerVersion({ + environmentId: cp.environment.id, + type: "PRODUCTION", + }); + + assertNonNullable(version); + const task = version.tasks.find((t) => t.slug === taskSlug); + assertNonNullable(task); + + expect(task.id).toBe(cp.task.id); + expect(task.slug).toBe(taskSlug); + expect(task.machineConfig).toEqual({ preset: "small-2x" }); + expect(task.retryConfig).toMatchObject({ maxAttempts: 5 }); + expect(task.maxDurationInSeconds).toBe(120); + + expect("payloadSchema" in task).toBe(false); + expect("config" in task).toBe(false); + expect("queueConfig" in task).toBe(false); + expect("description" in task).toBe(false); + expect(Object.keys(task).sort()).toEqual([ + "id", + "machineConfig", + "maxDurationInSeconds", + "retryConfig", + "slug", + ]); + + assertNonNullable(version.deployment); + expect(version.deployment.id).toBe(cp.deployment.id); + expect("externalBuildData" in version.deployment).toBe(false); + expect("buildServerMetadata" in version.deployment).toBe(false); + expect("errorData" in version.deployment).toBe(false); + expect("git" in version.deployment).toBe(false); + expect(Object.keys(version.deployment).sort()).toEqual([ + "friendlyId", + "id", + "imagePlatform", + "imageReference", + ]); + + const queue = version.queues.find((q) => q.id === cp.queue.id); + assertNonNullable(queue); + expect(queue.name).toBe(cp.queueName); + expect("rateLimit" in queue).toBe(false); + expect(Object.keys(queue).sort()).toEqual(["id", "name"]); + } + ); +}); + describe("DequeueSystem controlPlaneResolver (single-DB passthrough)", () => { containerTest( "default passthrough dequeue is byte-identical (resolves env + worker version end-to-end)", diff --git a/internal-packages/run-engine/src/index.ts b/internal-packages/run-engine/src/index.ts index b385f5e753..d8eaadf649 100644 --- a/internal-packages/run-engine/src/index.ts +++ b/internal-packages/run-engine/src/index.ts @@ -18,6 +18,9 @@ export type { ResolvedEngineEnv, ResolvedAuthenticatedEnv, ResolvedWorkerVersion, + ResolvedWorkerTask, + ResolvedTaskQueue, + ResolvedWorkerDeployment, } from "./engine/controlPlaneResolver.js"; // Batch Queue exports diff --git a/internal-packages/run-store/src/PostgresRunStore.ts b/internal-packages/run-store/src/PostgresRunStore.ts index b3d24c5a65..d11d51fb2c 100644 --- a/internal-packages/run-store/src/PostgresRunStore.ts +++ b/internal-packages/run-store/src/PostgresRunStore.ts @@ -1,4 +1,4 @@ -import { Prisma, boundedIn } from "@trigger.dev/database"; +import { Prisma, boundedIn, withTransactionStartRetry } from "@trigger.dev/database"; import type { BatchTaskRun, BatchTaskRunItemStatus, @@ -6,6 +6,7 @@ import type { PrismaClientOrTransaction, TaskRun, TaskRunStatus, + TransactionStartRetryConfig, WaitpointTag, } from "@trigger.dev/database"; import type { @@ -109,6 +110,13 @@ export type PostgresRunStoreOptions = { readOnlyPrisma: RunOpsCapableClient; /** Defaults to `"legacy"` so existing callers/tests are unaffected. */ schemaVariant?: RunStoreSchemaVariant; + /** + * `maxWait` (ms) applied when THIS store opens its own transaction. Threaded from the app + * boundary (IoC). Undefined leaves Prisma's own default (2000ms), preserving test behavior. + */ + maxWait?: number; + /** Env-driven P2028-at-acquisition retry config, threaded from the app boundary (IoC). */ + transactionStartRetry?: TransactionStartRetryConfig; }; // A caller sub-select for a relation: `{ select?, include? }` or `true` for a bare `key: true`. @@ -629,6 +637,8 @@ export class PostgresRunStore implements RunStore { private readonly prisma: RunOpsCapableClient; private readonly readOnlyPrisma: RunOpsCapableClient; private readonly schemaVariant: RunStoreSchemaVariant; + private readonly maxWait?: number; + private readonly transactionStartRetry?: TransactionStartRetryConfig; constructor(options: PostgresRunStoreOptions) { // Normalize foreign (run-ops-generation) Prisma known-request-errors to the control-plane @@ -637,6 +647,8 @@ export class PostgresRunStore implements RunStore { this.prisma = wrapRunOpsClientForErrorNormalization(options.prisma); this.readOnlyPrisma = wrapRunOpsClientForErrorNormalization(options.readOnlyPrisma); this.schemaVariant = options.schemaVariant ?? "legacy"; + this.maxWait = options.maxWait; + this.transactionStartRetry = options.transactionStartRetry; } // The writer handle in read-client form, so the routing layer can honor a caller-passed client @@ -654,8 +666,18 @@ export class PostgresRunStore implements RunStore { _runId: string | undefined, fn: (store: RunStore, tx: PrismaClientOrTransaction) => Promise ): Promise { - return (this.prisma as RunOpsTransactionalClient).$transaction((tx) => - fn(this, tx as unknown as PrismaClientOrTransaction) + let entered = false; + return withTransactionStartRetry( + () => + (this.prisma as RunOpsTransactionalClient).$transaction( + (tx) => { + entered = true; + return fn(this, tx as unknown as PrismaClientOrTransaction); + }, + { maxWait: this.maxWait } + ), + this.transactionStartRetry, + () => !entered ); } @@ -674,9 +696,16 @@ export class PostgresRunStore implements RunStore { if (alreadyInTransaction) { return fn(tx); } - return (this.prisma as RunOpsTransactionalClient).$transaction( - (t) => fn(t as unknown as PrismaClientOrTransaction), - options + const txOptions = { maxWait: this.maxWait, ...options }; + let entered = false; + return withTransactionStartRetry( + () => + (this.prisma as RunOpsTransactionalClient).$transaction((t) => { + entered = true; + return fn(t as unknown as PrismaClientOrTransaction); + }, txOptions), + this.transactionStartRetry, + () => !entered ); } diff --git a/internal-packages/schedule-engine/package.json b/internal-packages/schedule-engine/package.json index 52a27a11e2..2545428c29 100644 --- a/internal-packages/schedule-engine/package.json +++ b/internal-packages/schedule-engine/package.json @@ -21,7 +21,6 @@ "@trigger.dev/database": "workspace:*", "cron-parser": "^4.9.0", "cronstrue": "^2.50.0", - "nanoid": "3.3.8", "zod": "3.25.76" }, "devDependencies": { diff --git a/internal-packages/schedule-engine/src/engine/scheduleTiming.ts b/internal-packages/schedule-engine/src/engine/scheduleTiming.ts index 003240e952..3b8931cef3 100644 --- a/internal-packages/schedule-engine/src/engine/scheduleTiming.ts +++ b/internal-packages/schedule-engine/src/engine/scheduleTiming.ts @@ -1,16 +1,19 @@ +import { + MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS, + parseScheduleWindow, + type NormalizedScheduleWindow, +} from "@trigger.dev/core/v3"; import { createHmac } from "node:crypto"; +export { MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS, parseScheduleWindow }; +export type { NormalizedScheduleWindow }; + export const SCHEDULE_PHASE_DENOMINATOR = 2_147_483_648; export const MAX_SCHEDULE_PHASE = SCHEDULE_PHASE_DENOMINATOR - 1; export const MINIMUM_SCHEDULE_RANGE_MS = 60_000; -export const MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS = 24 * 60 * 60; const PERCENTAGE_DENOMINATOR = 100; -export type NormalizedScheduleWindow = - | { type: "duration"; durationSeconds: number } - | { type: "percentage"; percentage: number }; - export type SchedulePhaseInput = { secret: string | Buffer; environmentId: string; @@ -28,42 +31,6 @@ export type EffectiveScheduleTime = { windowWasCappedToInterval: boolean; }; -/** - * Parses the public schedule-window syntax. - * - * Durations are non-negative whole minutes or hours up to 24 hours. - * Percentages are whole numbers from 0% through 100%. - */ -export function parseScheduleWindow(value: string): NormalizedScheduleWindow { - const durationMatch = /^(0|[1-9]\d*)([mh])$/.exec(value); - - if (durationMatch) { - const amount = Number(durationMatch[1]); - const unit = durationMatch[2] as "m" | "h"; - const unitSeconds = unit === "m" ? 60 : 3_600; - const durationSeconds = amount * unitSeconds; - - if ( - !Number.isSafeInteger(durationSeconds) || - durationSeconds > MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS - ) { - throw new RangeError("Schedule window duration cannot exceed 24 hours"); - } - - return { type: "duration", durationSeconds }; - } - - const percentageMatch = /^(0|[1-9]\d?|100)%$/.exec(value); - - if (percentageMatch) { - return { type: "percentage", percentage: Number(percentageMatch[1]) }; - } - - throw new TypeError( - 'Schedule window must be a whole duration such as "0m", "30m", or "24h", or a percentage such as "30%"' - ); -} - export function validateScheduleWindow(window: NormalizedScheduleWindow): void { if (window.type === "duration") { if ( diff --git a/internal-packages/testcontainers/package.json b/internal-packages/testcontainers/package.json index 291c1314c1..df0df141c6 100644 --- a/internal-packages/testcontainers/package.json +++ b/internal-packages/testcontainers/package.json @@ -22,7 +22,6 @@ "@internal/run-ops-database": "workspace:*", "@testcontainers/postgresql": "^11.14.0", "@testcontainers/redis": "^11.14.0", - "@trigger.dev/core": "workspace:*", "std-env": "^3.9.0", "testcontainers": "^11.14.0", "tinyexec": "^0.3.0" diff --git a/internal-packages/testcontainers/src/index.ts b/internal-packages/testcontainers/src/index.ts index e1cd3d25ae..cceadf6cb4 100644 --- a/internal-packages/testcontainers/src/index.ts +++ b/internal-packages/testcontainers/src/index.ts @@ -28,7 +28,12 @@ import { withContainerSetup, } from "./utils"; -export { assertNonNullable, createPostgresContainer } from "./utils"; +export { + assertNonNullable, + createPostgresContainer, + createStandalonePostgresContainer, +} from "./utils"; +export { OtelCollectorContainer, StartedOtelCollectorContainer } from "./otelCollector"; export { laggingReplica, type LaggingModel } from "./laggingReplica"; export { logCleanup }; export type { MinIOConnectionConfig }; diff --git a/internal-packages/testcontainers/src/otelCollector.ts b/internal-packages/testcontainers/src/otelCollector.ts new file mode 100644 index 0000000000..7df8db12ee --- /dev/null +++ b/internal-packages/testcontainers/src/otelCollector.ts @@ -0,0 +1,63 @@ +import type { StartedTestContainer } from "testcontainers"; +import { AbstractStartedContainer, GenericContainer, Wait } from "testcontainers"; + +const OTLP_HTTP_PORT = 4318; +const CONFIG_PATH = "/etc/otelcol-config.yaml"; + +const CONFIG = `receivers: + otlp: + protocols: + http: + endpoint: 0.0.0.0:${OTLP_HTTP_PORT} +exporters: + debug: {} +service: + telemetry: + logs: + level: WARN + pipelines: + traces: + receivers: [otlp] + exporters: [debug] + metrics: + receivers: [otlp] + exporters: [debug] + logs: + receivers: [otlp] + exporters: [debug] +`; + +export class OtelCollectorContainer extends GenericContainer { + constructor( + image = "otel/opentelemetry-collector-k8s:0.158.0@sha256:c09130a633196a5becee164411473a0932ecf223f94fda6dab5f22798ff9f376" + ) { + super(image); + this.withExposedPorts(OTLP_HTTP_PORT); + this.withCopyContentToContainer([{ content: CONFIG, target: CONFIG_PATH }]); + this.withCommand([`--config=${CONFIG_PATH}`]); + this.withWaitStrategy(Wait.forHttp("/v1/metrics", OTLP_HTTP_PORT).forStatusCode(405)); + this.withStartupTimeout(120_000); + } + + public override async start(): Promise { + return new StartedOtelCollectorContainer(await super.start()); + } +} + +export class StartedOtelCollectorContainer extends AbstractStartedContainer { + constructor(startedTestContainer: StartedTestContainer) { + super(startedTestContainer); + } + + public getPort(): number { + return super.getMappedPort(OTLP_HTTP_PORT); + } + + /** + * Base URL for OTLP/HTTP, without a signal path. + * Example: `http://localhost:32768` + */ + public getOtlpHttpUrl(): string { + return `http://${this.getHost()}:${this.getPort()}`; + } +} diff --git a/internal-packages/testcontainers/src/utils.ts b/internal-packages/testcontainers/src/utils.ts index 385b4113f5..fa5dd310cc 100644 --- a/internal-packages/testcontainers/src/utils.ts +++ b/internal-packages/testcontainers/src/utils.ts @@ -4,7 +4,6 @@ import { PostgreSqlContainer } from "@testcontainers/postgresql"; import type { StartedRedisContainer } from "@testcontainers/redis"; import { RedisContainer } from "@testcontainers/redis"; import { PrismaClient } from "@trigger.dev/database"; -import { tryCatch } from "@trigger.dev/core"; import Redis from "ioredis"; import path from "path"; import { isDebug } from "std-env"; @@ -16,6 +15,14 @@ import { ClickHouseContainer, runClickhouseMigrations } from "./clickhouse"; import { MinIOContainer } from "./minio"; import { getContainerMetadata, getTaskMetadata, logCleanup, logSetup } from "./logs"; +async function tryCatch(promise: Promise): Promise<[E, null] | [null, T]> { + try { + return [null, await promise]; + } catch (error) { + return [error as E, null]; + } +} + /** Returns the container's connection URI with the database path swapped to `database`. */ export function postgresUriWithDatabase(uri: string, database: string): string { const url = new URL(uri); @@ -161,6 +168,20 @@ export async function createPostgresContainer( return { url: container.getConnectionUri(), container, network }; } +/** + * A second schema-loaded Postgres on its own container (no shared network alias), for tests that + * exercise a split across two databases. The caller owns stopping the returned container. + */ +export async function createStandalonePostgresContainer() { + const container = await withCiResourceLimits(new PostgreSqlContainer("docker.io/postgres:14")) + .withCommand(["-c", "listen_addresses=*", "-c", "wal_level=logical"]) + .start(); + + await pushDatabaseSchema(container.getConnectionUri()); + + return { url: container.getConnectionUri(), container }; +} + export async function createClickHouseContainer(network: StartedNetwork) { const container = await withCiResourceLimits(new ClickHouseContainer()) .withNetwork(network) diff --git a/internal-packages/webhook-engine/package.json b/internal-packages/webhook-engine/package.json new file mode 100644 index 0000000000..ec09a4f27f --- /dev/null +++ b/internal-packages/webhook-engine/package.json @@ -0,0 +1,36 @@ +{ + "name": "@internal/webhook-engine", + "private": true, + "version": "0.0.1", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "type": "module", + "exports": { + ".": { + "@triggerdotdev/source": "./src/index.ts", + "import": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + } + }, + "dependencies": { + "@internal/redis": "workspace:*", + "@trigger.dev/redis-worker": "workspace:*", + "@internal/tracing": "workspace:*", + "@trigger.dev/core": "workspace:*", + "@trigger.dev/database": "workspace:*", + "zod": "3.25.76" + }, + "devDependencies": { + "@internal/testcontainers": "workspace:*", + "rimraf": "6.0.1" + }, + "scripts": { + "clean": "rimraf dist", + "typecheck": "tsc --noEmit -p tsconfig.build.json", + "test": "vitest --sequence.concurrent=false --no-file-parallelism", + "test:coverage": "vitest --sequence.concurrent=false --no-file-parallelism --coverage.enabled", + "build": "pnpm run clean && tsc -p tsconfig.build.json", + "dev": "tsc --watch -p tsconfig.build.json" + } +} diff --git a/internal-packages/webhook-engine/src/engine/cache.ts b/internal-packages/webhook-engine/src/engine/cache.ts new file mode 100644 index 0000000000..e0f7c24116 --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/cache.ts @@ -0,0 +1,49 @@ +import type { FilterAst } from "@trigger.dev/core/v3"; +import type { WebhookEndpoint } from "@trigger.dev/database"; + +// What the ingest hot path needs to verify + persist a delivery, resolved once and reused: the +// endpoint row, its plaintext signing secret, and its compiled filter AST (parsed once here, null = +// route all). All immutable per endpoint within the cache TTL. +export type CachedEndpoint = { + endpoint: WebhookEndpoint; + secret: string; + filterAst: FilterAst | null; +}; + +// Minimal in-memory TTL cache with FIFO eviction at maxSize. ttlMs <= 0 disables it (every get misses). +export class TtlCache { + private readonly map = new Map(); + + constructor( + private readonly ttlMs: number, + private readonly maxSize: number + ) {} + + get enabled(): boolean { + return this.ttlMs > 0; + } + + get(key: string): V | undefined { + if (!this.enabled) return undefined; + const entry = this.map.get(key); + if (!entry) return undefined; + if (entry.expiresAt <= Date.now()) { + this.map.delete(key); + return undefined; + } + return entry.value; + } + + set(key: string, value: V): void { + if (!this.enabled) return; + if (this.map.size >= this.maxSize && !this.map.has(key)) { + const oldest = this.map.keys().next().value; + if (oldest !== undefined) this.map.delete(oldest); + } + this.map.set(key, { value, expiresAt: Date.now() + this.ttlMs }); + } + + delete(key: string): void { + this.map.delete(key); + } +} diff --git a/internal-packages/webhook-engine/src/engine/disabled.test.ts b/internal-packages/webhook-engine/src/engine/disabled.test.ts new file mode 100644 index 0000000000..880941fe66 --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/disabled.test.ts @@ -0,0 +1,37 @@ +import type { WebhookDatabase } from "@trigger.dev/database"; +import { describe, expect, it } from "vitest"; +import { WebhookEngine } from "./index.js"; +import type { TriggerWebhookTaskCallback } from "./types.js"; + +const triggerTask: TriggerWebhookTaskCallback = async () => ({ success: true }); + +function buildDisabledEngine() { + return new WebhookEngine({ + prisma: {} as unknown as WebhookDatabase, + disabled: true, + redis: { host: "127.0.0.1", port: 6379 }, + worker: { concurrency: 1 }, + triggerTask, + resolveSigningSecret: async () => undefined, + logLevel: "error", + }); +} + +describe("WebhookEngine (disabled)", () => { + it("skips Redis and worker setup, and rejects public calls", async () => { + const engine = buildDisabledEngine(); + + await expect( + engine.ingest({ + opaqueId: "x", + rawBytes: new TextEncoder().encode("{}"), + headers: {}, + url: "https://example.com/webhooks/v1/ingest/x", + }) + ).rejects.toThrow(/disabled/i); + + await expect(engine.getJob("job")).rejects.toThrow(/disabled/i); + + await expect(engine.quit()).resolves.toBeUndefined(); + }); +}); diff --git a/internal-packages/webhook-engine/src/engine/filter/evaluate.ts b/internal-packages/webhook-engine/src/engine/filter/evaluate.ts new file mode 100644 index 0000000000..db3106f992 --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/filter/evaluate.ts @@ -0,0 +1,185 @@ +import type { FilterAst, FilterOp, FilterScalar, FilterValue } from "@trigger.dev/core/v3"; +import type { FilterContext, FilterMatch } from "./types.js"; + +// Evaluate a compiled filter against a delivery. Pure + deterministic (retry-safe). On no-match, +// `reason` names the representative failing clause + the actual value, for WebhookDelivery.filterReason. + +export function evaluateFilter(ast: FilterAst, ctx: FilterContext): FilterMatch { + return evalNode(ast, ctx); +} + +function evalNode(node: FilterAst, ctx: FilterContext): FilterMatch { + if (node.kind === "and") { + for (const clause of node.clauses) { + const r = evalNode(clause, ctx); + if (!r.match) return r; + } + return { match: true }; + } + if (node.kind === "or") { + let firstReason: string | undefined; + for (const clause of node.clauses) { + const r = evalNode(clause, ctx); + if (r.match) return { match: true }; + firstReason ??= r.reason; + } + return { match: false, reason: firstReason ?? "no condition matched" }; + } + if (node.kind === "quantifier") { + const arr = resolvePath(node.path, ctx); + const sub = node.clause; + const label = `${sub.path} ${OP_LABEL[sub.op]} ${JSON.stringify(sub.value)}`; + if (!Array.isArray(arr)) { + return { match: false, reason: `${node.path} is not an array` }; + } + const test = (el: unknown) => applyOp(sub.op, resolveRelative(el, sub.path), sub.value); + const ok = node.mode === "any" ? arr.some(test) : arr.every(test); + return ok + ? { match: true } + : { + match: false, + reason: + node.mode === "any" + ? `no element of ${node.path} satisfies ${label}` + : `not every element of ${node.path} satisfies ${label}`, + }; + } + const actual = resolvePath(node.path, ctx); + if (node.valueRef !== undefined) { + const rhs = resolvePath(node.valueRef, ctx); + return applyOpRef(node.op, actual, rhs) + ? { match: true } + : { + match: false, + reason: `${node.path} is ${JSON.stringify(actual)}, expected ${OP_LABEL[node.op]} ${ + node.valueRef + } (${JSON.stringify(rhs)})`, + }; + } + return applyOp(node.op, actual, node.value as FilterValue) + ? { match: true } + : { match: false, reason: describe(node.path, node.op, actual, node.value as FilterValue) }; +} + +// Field-to-field comparison (both operands resolved from the event). Equality is strict on the +// resolved values; ordering coerces to number. Non-comparison ops are rejected by the parser/types. +function applyOpRef(op: FilterOp, a: unknown, b: unknown): boolean { + switch (op) { + case "eq": + return a === b; + case "neq": + return a !== b; + case "gt": + return numCompare(a, b, (x, y) => x > y); + case "lt": + return numCompare(a, b, (x, y) => x < y); + case "gte": + return numCompare(a, b, (x, y) => x >= y); + case "lte": + return numCompare(a, b, (x, y) => x <= y); + default: + return false; + } +} + +function resolvePath(path: string, ctx: FilterContext): unknown { + const segs = path.split("."); + const [ns, ...rest] = segs; + if (ns === "header") return lookupHeaderCI(ctx.headers, rest.join(".")); + let cur: unknown = ns === "event" ? ctx.event : ns === "webhook" ? ctx.webhook : undefined; + for (const seg of rest) { + if (cur == null || typeof cur !== "object") return undefined; + cur = (cur as Record)[seg]; + } + return cur; +} + +// Resolve a bare (element-relative) dotted path against an object, used for quantifier sub-clauses. +function resolveRelative(obj: unknown, path: string): unknown { + let cur: unknown = obj; + for (const seg of path.split(".")) { + if (cur == null || typeof cur !== "object") return undefined; + cur = (cur as Record)[seg]; + } + return cur; +} + +function lookupHeaderCI(headers: Record, name: string): string | undefined { + const lower = name.toLowerCase(); + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === lower) return headers[key]; + } + return undefined; +} + +function applyOp(op: FilterOp, actual: unknown, value: FilterValue): boolean { + const missing = actual === undefined; + switch (op) { + case "eq": + return looseEq(actual, value as FilterScalar); + case "neq": + return !looseEq(actual, value as FilterScalar); + case "gt": + return numCompare(actual, value, (a, b) => a > b); + case "lt": + return numCompare(actual, value, (a, b) => a < b); + case "gte": + return numCompare(actual, value, (a, b) => a >= b); + case "lte": + return numCompare(actual, value, (a, b) => a <= b); + case "in": + return Array.isArray(value) && value.some((v) => looseEq(actual, v)); + case "nin": + return !(Array.isArray(value) && value.some((v) => looseEq(actual, v))); + case "startsWith": + return !missing && typeof value === "string" && String(actual).startsWith(value); + case "endsWith": + return !missing && typeof value === "string" && String(actual).endsWith(value); + case "contains": + return !missing && typeof value === "string" && String(actual).includes(value); + } +} + +// Literal-type-driven comparison, to tame JSON's loose typing. A missing field equals only `null`. +function looseEq(actual: unknown, lit: FilterScalar): boolean { + if (lit === null) return actual === null || actual === undefined; + if (actual === undefined) return false; + if (typeof lit === "string") return String(actual) === lit; + if (typeof lit === "number") { + const n = typeof actual === "number" ? actual : Number(actual); + return !Number.isNaN(n) && n === lit; + } + return actual === lit; // boolean +} + +function coerceNum(x: unknown): number { + return typeof x === "number" ? x : typeof x === "string" ? Number(x) : NaN; +} + +function numCompare( + actual: unknown, + value: unknown, + cmp: (a: number, b: number) => boolean +): boolean { + const a = coerceNum(actual); + const b = coerceNum(value); + return !Number.isNaN(a) && !Number.isNaN(b) && cmp(a, b); +} + +const OP_LABEL: Record = { + eq: "==", + neq: "!=", + gt: ">", + lt: "<", + gte: ">=", + lte: "<=", + in: "in", + nin: "not in", + startsWith: "startsWith", + endsWith: "endsWith", + contains: "contains", +}; + +function describe(path: string, op: FilterOp, actual: unknown, value: FilterValue): string { + return `${path} is ${JSON.stringify(actual)}, expected ${OP_LABEL[op]} ${JSON.stringify(value)}`; +} diff --git a/internal-packages/webhook-engine/src/engine/filter/filter.test.ts b/internal-packages/webhook-engine/src/engine/filter/filter.test.ts new file mode 100644 index 0000000000..4a86e15193 --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/filter/filter.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from "vitest"; +import { evaluateFilter, FilterParseError, parseFilter } from "./index.js"; +import type { FilterContext } from "./index.js"; + +const ctx = (over: Partial = {}): FilterContext => ({ + event: { + action: "created", + issue: { number: 200, author: "alice" }, + repository: { full_name: "myorg/repo", private: false }, + }, + headers: { "X-GitHub-Event": "issue_comment", "Content-Type": "application/json" }, + webhook: { externalRef: "install_42", tenantId: "t1", source: "github" }, + ...over, +}); + +const matches = (filter: string, over?: Partial) => + evaluateFilter(parseFilter(filter), ctx(over)).match; + +describe("parseFilter", () => { + it("parses a single clause", () => { + expect(parseFilter("event.action == 'created'")).toEqual({ + kind: "clause", + path: "event.action", + op: "eq", + value: "created", + }); + }); + + it("respects && binding tighter than || (DNF)", () => { + expect( + parseFilter( + "event.action == 'created' || event.action == 'edited' && event.repository.private == false" + ) + ).toEqual({ + kind: "or", + clauses: [ + { kind: "clause", path: "event.action", op: "eq", value: "created" }, + { + kind: "and", + clauses: [ + { kind: "clause", path: "event.action", op: "eq", value: "edited" }, + { kind: "clause", path: "event.repository.private", op: "eq", value: false }, + ], + }, + ], + }); + }); + + it("parses parens, lists, numbers, not in", () => { + expect(parseFilter("event.action in ['created', 'edited']").value).toEqual([ + "created", + "edited", + ]); + expect(parseFilter("event.issue.number > 100")).toMatchObject({ op: "gt", value: 100 }); + expect(parseFilter("event.action not in ['deleted']")).toMatchObject({ op: "nin" }); + expect(parseFilter("( event.action == 'created' )")).toMatchObject({ kind: "clause" }); + }); + + it("rejects malformed filters", () => { + for (const bad of [ + "event.action", + "event.action = 'x'", + "foo.bar == 'x'", + "event.action == 'created' && ( event.action == 'edited'", + "", + ]) { + expect(() => parseFilter(bad), bad).toThrow(FilterParseError); + } + }); + + it("enforces the soft clause cap", () => { + const big = Array.from({ length: 60 }, () => "event.action == 'created'").join(" && "); + expect(() => parseFilter(big)).toThrow(/too complex/); + }); +}); + +describe("evaluateFilter", () => { + it("matches equality, and, or, in, numeric, parens", () => { + expect(matches("event.action == 'created'")).toBe(true); + expect(matches("event.action == 'created' && event.repository.private == false")).toBe(true); + expect(matches("event.action == 'edited' || event.action == 'created'")).toBe(true); + expect(matches("event.action in ['edited', 'created']")).toBe(true); + expect(matches("event.issue.number > 100")).toBe(true); + expect(matches("event.issue.number > 500")).toBe(false); + expect( + matches( + "( event.action == 'edited' || event.action == 'created' ) && event.repository.private == false" + ) + ).toBe(true); + }); + + it("looks up headers case-insensitively", () => { + expect(matches("header.x-github-event == 'issue_comment'")).toBe(true); + }); + + it("resolves the webhook namespace", () => { + expect(matches("webhook.externalRef == 'install_42'")).toBe(true); + expect(matches("webhook.externalRef == 'other'")).toBe(false); + }); + + it("treats a missing field correctly", () => { + expect(matches("event.assignee == 'bob'")).toBe(false); + expect(matches("event.assignee != 'bob'")).toBe(true); + expect(matches("event.assignee == null")).toBe(true); + }); + + it("string operators", () => { + expect(matches("event.repository.full_name startsWith 'myorg/'")).toBe(true); + expect(matches("event.repository.full_name contains 'repo'")).toBe(true); + expect(matches("event.repository.full_name endsWith '/other'")).toBe(false); + }); + + it("field-to-field: parses a path RHS as a valueRef and compares two event fields", () => { + expect(parseFilter("event.issue.author == event.repository.full_name")).toEqual({ + kind: "clause", + path: "event.issue.author", + op: "eq", + valueRef: "event.repository.full_name", + }); + expect(matches("event.issue.author == event.issue.author")).toBe(true); + expect(matches("event.issue.author == event.repository.full_name")).toBe(false); + expect(matches("event.issue.number >= event.issue.number")).toBe(true); + }); + + it("returns a reason naming the failing clause", () => { + const r = evaluateFilter( + parseFilter("event.action == 'created' && event.issue.number > 500"), + ctx() + ); + expect(r.match).toBe(false); + expect(r.reason).toContain("event.issue.number"); + expect(r.reason).toContain("> 500"); + }); +}); + +describe("quantifiers", () => { + const arrCtx = (): FilterContext => ({ + event: { + action: "push", + commits: [{ message: "fix bug" }, { message: "deploy to prod" }], + labels: [{ name: "bug" }, { name: "urgent" }], + }, + headers: {}, + webhook: {}, + }); + const m = (filter: string) => evaluateFilter(parseFilter(filter), arrCtx()).match; + + it("parses a quantifier sub-clause", () => { + expect(parseFilter("event.commits any ( message startsWith 'deploy' )")).toEqual({ + kind: "quantifier", + mode: "any", + path: "event.commits", + clause: { path: "message", op: "startsWith", value: "deploy" }, + }); + }); + + it("evaluates any / all over array elements", () => { + expect(m("event.commits any ( message startsWith 'deploy' )")).toBe(true); + expect(m("event.commits any ( message startsWith 'nope' )")).toBe(false); + expect(m("event.labels all ( name != 'wontfix' )")).toBe(true); + expect(m("event.labels all ( name == 'bug' )")).toBe(false); + }); + + it("composes with top-level boolean operators", () => { + expect(m("event.action == 'push' && event.labels any ( name == 'urgent' )")).toBe(true); + }); + + it("a non-array path does not match", () => { + expect(evaluateFilter(parseFilter("event.action any ( x == 'y' )"), arrCtx()).match).toBe( + false + ); + }); +}); + +// The @trigger.dev/slack self-message loop guard: route only genuine human messages, drop the bot's +// own posts (bot_id present), edits/deletes, and Slack system messages. Proves the loop is closed. +describe("slack self-message guard", () => { + const GUARD = + "event.event.type == 'message' && event.event.bot_id == null && event.event.subtype in [null, 'file_share', 'thread_broadcast']"; + const ev = (event: Record): Partial => ({ event: { event } }); + + it("routes a human message in a thread", () => { + expect( + evaluateFilter(parseFilter(GUARD), ctx(ev({ type: "message", channel: "C1", ts: "1" }))).match + ).toBe(true); + }); + + it("drops a channel_join system message (bot invited to the channel)", () => { + expect( + evaluateFilter( + parseFilter(GUARD), + ctx(ev({ type: "message", subtype: "channel_join", text: "has joined" })) + ).match + ).toBe(false); + }); + + it("drops the bot's own post (bot_id present)", () => { + expect( + evaluateFilter( + parseFilter(GUARD), + ctx(ev({ type: "message", bot_id: "B123", text: "reply" })) + ).match + ).toBe(false); + }); + + it("drops an edit re-delivery (message_changed subtype)", () => { + expect( + evaluateFilter(parseFilter(GUARD), ctx(ev({ type: "message", subtype: "message_changed" }))) + .match + ).toBe(false); + }); + + it("drops an app_mention duplicate (dedupes to the message event)", () => { + expect( + evaluateFilter(parseFilter(GUARD), ctx(ev({ type: "app_mention", text: "hey" }))).match + ).toBe(false); + }); +}); diff --git a/internal-packages/webhook-engine/src/engine/filter/index.ts b/internal-packages/webhook-engine/src/engine/filter/index.ts new file mode 100644 index 0000000000..b2c741547e --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/filter/index.ts @@ -0,0 +1,8 @@ +export { parseFilter } from "./parse.js"; +export { evaluateFilter } from "./evaluate.js"; +export { + FilterParseError, + MAX_FILTER_CLAUSES, + type FilterContext, + type FilterMatch, +} from "./types.js"; diff --git a/internal-packages/webhook-engine/src/engine/filter/parse.ts b/internal-packages/webhook-engine/src/engine/filter/parse.ts new file mode 100644 index 0000000000..ad586a0815 --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/filter/parse.ts @@ -0,0 +1,222 @@ +import type { FilterAst, FilterOp, FilterScalar, FilterValue } from "@trigger.dev/core/v3"; +import { FilterParseError, MAX_FILTER_CLAUSES } from "./types.js"; + +// Runtime recursive-descent parser: filter string -> FilterAst. Mirrors the type-level grammar in +// core/schemas/webhookFilter.ts (kept in sync via the golden corpus in filter.test.ts). Runs at +// index/deploy time, never at ingest. Throws FilterParseError on any structural problem. + +const CMP_TO_OP: Record = { + "==": "eq", + "!=": "neq", + ">": "gt", + "<": "lt", + ">=": "gte", + "<=": "lte", +}; +const WORD_OPS: Record = { + in: "in", + startsWith: "startsWith", + endsWith: "endsWith", + contains: "contains", +}; +const NAMESPACES = new Set(["event", "header", "webhook"]); + +type Tok = { + t: + | "lparen" + | "rparen" + | "lbracket" + | "rbracket" + | "comma" + | "and" + | "or" + | "cmp" + | "word" + | "string" + | "number"; + v: string; +}; + +function tokenize(s: string): Tok[] { + const toks: Tok[] = []; + let i = 0; + while (i < s.length) { + const c = s[i]; + if (c === " " || c === "\t" || c === "\n") { + i++; + continue; + } + const simple = { + "(": "lparen", + ")": "rparen", + "[": "lbracket", + "]": "rbracket", + ",": "comma", + } as const; + if (c in simple) { + toks.push({ t: simple[c as keyof typeof simple], v: c }); + i++; + continue; + } + if (c === "&" && s[i + 1] === "&") { + toks.push({ t: "and", v: "&&" }); + i += 2; + continue; + } + if (c === "|" && s[i + 1] === "|") { + toks.push({ t: "or", v: "||" }); + i += 2; + continue; + } + if (c === "'") { + let j = i + 1; + while (j < s.length && s[j] !== "'") j++; + if (j >= s.length) throw new FilterParseError("unterminated string literal"); + toks.push({ t: "string", v: s.slice(i + 1, j) }); + i = j + 1; + continue; + } + const two = s.slice(i, i + 2); + if (two === "==" || two === "!=" || two === ">=" || two === "<=") { + toks.push({ t: "cmp", v: two }); + i += 2; + continue; + } + if (c === ">" || c === "<") { + toks.push({ t: "cmp", v: c }); + i++; + continue; + } + if (c === "-" || (c >= "0" && c <= "9")) { + const m = /^-?\d+(\.\d+)?/.exec(s.slice(i)); + if (m) { + toks.push({ t: "number", v: m[0] }); + i += m[0].length; + continue; + } + } + const w = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(s.slice(i)); + if (w) { + toks.push({ t: "word", v: w[0] }); + i += w[0].length; + continue; + } + throw new FilterParseError(`unexpected character "${c}"`); + } + return toks; +} + +export function parseFilter(input: string): FilterAst { + const toks = tokenize(input); + if (toks.length === 0) throw new FilterParseError("empty filter"); + let pos = 0; + let clauseCount = 0; + const peek = (): Tok | undefined => toks[pos]; + const next = (): Tok | undefined => toks[pos++]; + + function parseOr(): FilterAst { + const branches = [parseAnd()]; + while (peek()?.t === "or") { + next(); + branches.push(parseAnd()); + } + return branches.length === 1 ? branches[0] : { kind: "or", clauses: branches }; + } + function parseAnd(): FilterAst { + const branches = [parseUnary()]; + while (peek()?.t === "and") { + next(); + branches.push(parseUnary()); + } + return branches.length === 1 ? branches[0] : { kind: "and", clauses: branches }; + } + function parseUnary(): FilterAst { + if (peek()?.t === "lparen") { + next(); + const inner = parseOr(); + if (next()?.t !== "rparen") throw new FilterParseError('expected ")"'); + return inner; + } + return parseClause(); + } + function parseClause(): FilterAst { + if (++clauseCount > MAX_FILTER_CLAUSES) { + throw new FilterParseError( + `filter too complex (> ${MAX_FILTER_CLAUSES} clauses); split it or move logic to the handler` + ); + } + const pathTok = next(); + if (!pathTok || pathTok.t !== "word") throw new FilterParseError("expected a field path"); + const path = pathTok.v; + if (!NAMESPACES.has(path.split(".")[0])) { + throw new FilterParseError(`field must start with event./header./webhook., got "${path}"`); + } + // Quantifier: `arrPath any|all ( subPath op value )` — sub-clause resolves against each element. + const q = peek(); + if (q?.t === "word" && (q.v === "any" || q.v === "all")) { + next(); + if (next()?.t !== "lparen") throw new FilterParseError(`expected "(" after "${q.v}"`); + const subPathTok = next(); + if (!subPathTok || subPathTok.t !== "word") { + throw new FilterParseError("expected a field path inside the quantifier"); + } + const subOp = parseOp(subPathTok.v); + const subValue = parseScalar(); + if (next()?.t !== "rparen") throw new FilterParseError('expected ")"'); + return { + kind: "quantifier", + mode: q.v, + path, + clause: { path: subPathTok.v, op: subOp, value: subValue }, + }; + } + const op = parseOp(path); + // An unquoted, namespace-prefixed operand is a field reference (field-to-field), not a literal. + const operand = peek(); + if (operand?.t === "word" && NAMESPACES.has(operand.v.split(".")[0])) { + next(); + return { kind: "clause", path, op, valueRef: operand.v }; + } + return { kind: "clause", path, op, value: parseValue() }; + } + function parseOp(path: string): FilterOp { + const t = next(); + if (t?.t === "cmp") return CMP_TO_OP[t.v]; + if (t?.t === "word" && t.v === "not") { + if (next()?.v !== "in") throw new FilterParseError('expected "in" after "not"'); + return "nin"; + } + if (t?.t === "word" && t.v in WORD_OPS) return WORD_OPS[t.v]; + throw new FilterParseError(`expected an operator after "${path}"`); + } + function parseValue(): FilterValue { + if (peek()?.t === "lbracket") { + next(); + const items: FilterScalar[] = []; + while (peek() && peek()?.t !== "rbracket") { + items.push(parseScalar()); + if (peek()?.t === "comma") next(); + else break; + } + if (next()?.t !== "rbracket") throw new FilterParseError('expected "]"'); + return items; + } + return parseScalar(); + } + function parseScalar(): FilterScalar { + const t = next(); + if (!t) throw new FilterParseError("expected a value"); + if (t.t === "string") return t.v; + if (t.t === "number") return Number(t.v); + if (t.t === "word") { + if (t.v === "true") return true; + if (t.v === "false") return false; + if (t.v === "null") return null; + } + throw new FilterParseError(`unexpected value "${t.v}"`); + } + + const ast = parseOr(); + if (pos < toks.length) throw new FilterParseError(`unexpected trailing token "${peek()?.v}"`); + return ast; +} diff --git a/internal-packages/webhook-engine/src/engine/filter/types.ts b/internal-packages/webhook-engine/src/engine/filter/types.ts new file mode 100644 index 0000000000..5eda5df1e3 --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/filter/types.ts @@ -0,0 +1,19 @@ +// Evaluation context for a compiled webhook filter. `event` is the parsed body, `headers` are the +// inbound request headers (looked up case-insensitively), `webhook` is the endpoint metadata. +export type FilterContext = { + event: unknown; + headers: Record; + webhook: Record; +}; + +export type FilterMatch = { match: boolean; reason?: string }; + +export class FilterParseError extends Error { + constructor(message: string) { + super(message); + this.name = "FilterParseError"; + } +} + +// Soft cap, well below the type-level ~200-clause TS2589 wall, so authors get a clean message. +export const MAX_FILTER_CLAUSES = 50; diff --git a/internal-packages/webhook-engine/src/engine/index.ts b/internal-packages/webhook-engine/src/engine/index.ts new file mode 100644 index 0000000000..65eb756197 --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/index.ts @@ -0,0 +1,851 @@ +import type { Counter, Histogram, Meter, Tracer } from "@internal/tracing"; +import { getMeter, getTracer, startSpan } from "@internal/tracing"; +import { Logger } from "@trigger.dev/core/logger"; +import type { + Prisma, + WebhookDatabase, + WebhookDelivery, + WebhookEndpoint, +} from "@trigger.dev/database"; +import { Worker, type JobHandlerParams } from "@trigger.dev/redis-worker"; +import type { Redis } from "@internal/redis"; +import { createRedisClient } from "@internal/redis"; +import { FilterAst, WebhookVerifierArtifact } from "@trigger.dev/core/v3"; +import type { WebhookRoutingTarget } from "@trigger.dev/core/v3"; +import { WebhookDeliveryId } from "@trigger.dev/core/v3/isomorphic"; +import { webhookWorkerCatalog } from "./workerCatalog.js"; +import { ensurePartitions } from "./partitions.js"; +import { type CachedEndpoint, TtlCache } from "./cache.js"; +import { evaluateFilter, parseFilter } from "./filter/index.js"; +import { verify } from "./verification/index.js"; +import { sha256Hex } from "./verification/util.js"; +import { deriveIdempotencyKey, tryParseJson } from "./verification/derive.js"; +import type { + IngestInput, + IngestResult, + ReplayResult, + TriggerWebhookTaskCallback, + WebhookDeliverTaskErrorType, + WebhookEngineOptions, +} from "./types.js"; +import { evaluateSessionKeyTemplate, walkPath as resolveBodyPath } from "./sessionKey.js"; + +// The deliver job's retry budget (redis-worker DLQs after this many attempts). +const WEBHOOK_DELIVER_MAX_ATTEMPTS = webhookWorkerCatalog["webhook.deliver"].retry.maxAttempts; + +export class WebhookEngine { + private worker!: Worker; + private logger: Logger; + private tracer: Tracer; + private meter: Meter; + + private deliveryEnqueueCounter: Counter; + private deliveryFilteredCounter: Counter; + private deliveryExecutionCounter: Counter; + private deliveryExecutionDuration: Histogram; + private deliveryExecutionFailureCounter: Counter; + private ensurePartitionsCounter: Counter; + private endpointCacheCounter: Counter; + + prisma: WebhookDatabase; + + private triggerTask: TriggerWebhookTaskCallback; + private frontGate!: Redis; + // Caches the endpoint + resolved signing secret per opaqueId so the ingest hot path skips two + // Postgres reads (endpoint lookup + secret decrypt). Both are immutable per endpoint within the + // TTL; a status change or secret rotation takes effect after at most the TTL. + private endpointCache: TtlCache; + + constructor(private readonly options: WebhookEngineOptions) { + this.logger = + options.logger ?? new Logger("WebhookEngine", (this.options.logLevel ?? "info") as any); + this.prisma = options.prisma; + this.triggerTask = options.triggerTask; + + this.tracer = options.tracer ?? getTracer("webhook-engine"); + this.meter = options.meter ?? getMeter("webhook-engine"); + + this.deliveryEnqueueCounter = this.meter.createCounter("webhook_delivery_enqueues_total", { + description: "Total number of webhook deliveries enqueued for routing", + }); + this.deliveryFilteredCounter = this.meter.createCounter("webhook_delivery_filtered_total", { + description: + "Total number of webhook deliveries received but not routed due to the endpoint filter", + }); + this.deliveryExecutionCounter = this.meter.createCounter("webhook_delivery_executions_total", { + description: "Total number of webhook delivery routing executions", + }); + this.deliveryExecutionDuration = this.meter.createHistogram( + "webhook_delivery_execution_duration_ms", + { description: "Duration of webhook delivery routing in milliseconds", unit: "ms" } + ); + this.deliveryExecutionFailureCounter = this.meter.createCounter( + "webhook_delivery_execution_failures_total", + { description: "Total number of webhook delivery routing failures" } + ); + this.ensurePartitionsCounter = this.meter.createCounter("webhook_ensure_partitions_total", { + description: "Total number of ensurePartitions cron runs", + }); + this.endpointCacheCounter = this.meter.createCounter("webhook_endpoint_cache_total", { + description: "Endpoint+secret cache lookups on the ingest hot path, by result (hit/miss)", + }); + + this.endpointCache = new TtlCache( + options.endpointCache?.ttlMs ?? 30_000, + options.endpointCache?.maxSize ?? 10_000 + ); + + if (options.disabled) { + this.logger.info("Webhook engine disabled; skipping Redis and worker setup"); + return; + } + + this.frontGate = createRedisClient(options.redis); + + this.worker = new Worker({ + name: "webhook-engine-worker", + redisOptions: { + ...options.redis, + keyPrefix: `${options.redis.keyPrefix ?? ""}webhook:`, + }, + catalog: { + ...webhookWorkerCatalog, + ensurePartitions: { + ...webhookWorkerCatalog.ensurePartitions, + cron: options.partitions?.ensureSchedule ?? webhookWorkerCatalog.ensurePartitions.cron, + jitterInMs: + options.partitions?.ensureJitterInMs ?? + webhookWorkerCatalog.ensurePartitions.jitterInMs, + }, + }, + concurrency: { + limit: options.worker.concurrency, + workers: options.worker.workers, + tasksPerWorker: options.worker.tasksPerWorker, + }, + pollIntervalMs: options.worker.pollIntervalMs, + shutdownTimeoutMs: options.worker.shutdownTimeoutMs, + logger: new Logger("WebhookEngineWorker", (options.logLevel ?? "info") as any), + jobs: { + "webhook.deliver": this.#handleDeliverJob.bind(this), + ensurePartitions: this.#handleEnsurePartitionsJob.bind(this), + }, + }); + + if (!options.worker.disabled) { + this.worker.start(); + this.logger.info("Webhook engine worker started", { + concurrency: options.worker.concurrency, + pollIntervalMs: options.worker.pollIntervalMs, + }); + } else { + this.logger.info("Webhook engine worker disabled"); + } + } + + #assertEnabled(): void { + if (this.options.disabled) { + throw new Error('WebhookEngine is disabled: WEBHOOK_ENABLED is not "1"'); + } + } + + // PUBLIC ENTRY: verify inline, append-only delivery write, enqueue routing, ack. + async ingest(input: IngestInput): Promise { + this.#assertEnabled(); + return startSpan(this.tracer, "webhook.ingest", async (span) => { + span.setAttribute("opaqueId", input.opaqueId); + + // 1+2. Resolve the endpoint + signing secret (cached per opaqueId). Fail-closed outcomes + // (not found / inactive / secret missing) are never cached, so verify only ever runs with a + // non-empty secret. + const resolved = await this.#resolveEndpoint(input.opaqueId); + if (!resolved.ok) return resolved.result; + const { endpoint, secret } = resolved; + + // 3. Verify inline. safeParse the Json artifact so a corrupt row is a 400, not a 5xx storm. + const parsedArtifact = WebhookVerifierArtifact.safeParse(endpoint.verifierArtifact); + if (!parsedArtifact.success) { + return { outcome: "verification_failed", error: "corrupt verifier artifact" }; + } + const verdict = verify(parsedArtifact.data, { + rawBytes: input.rawBytes, + headers: input.headers, + url: input.url, + secret, + }); + if (!verdict.ok) { + return { outcome: "verification_failed", error: verdict.error ?? "invalid" }; + } + + // Provider handshake (Slack url_verification, Discord PING): a signed request that must get a + // synchronous echo, not a recorded/routed delivery. Generic, declared on the verifier artifact. + const handshake = + "handshake" in parsedArtifact.data ? parsedArtifact.data.handshake : undefined; + if (handshake) { + const event = verdict.parsedEvent as unknown; + if (String(resolveBodyPath(event, handshake.matchPath) ?? "") === handshake.matchValue) { + return { + outcome: "handshake", + body: String(resolveBodyPath(event, handshake.respondPath) ?? ""), + }; + } + } + + return this.#recordAndRoute({ + endpoint, + artifact: parsedArtifact.data, + filterAst: resolved.filterAst, + parsedEvent: verdict.parsedEvent, + idempotencyKey: verdict.idempotencyKey, + errorMessage: verdict.error ?? null, + rawBytes: input.rawBytes, + headers: input.headers, + }); + }); + } + + /** + * Shared post-verify path for ingest() and simulateInject(): the atomic Redis front gate (dedupe), + * the filter gate, the append-only delivery row, and the routing enqueue. The front gate is + * two-phase (claim with a short lock via SET NX, then promote to the full dedupe window once the + * job is durably enqueued) so it is both atomic and crash-safe: a crash mid-ingest releases the + * short lock instead of suppressing the provider's retry, and the Run Engine idempotencyKey gate + * is the durable exactly-once guard. A filtered delivery is still recorded (for visibility) but not + * routed. + */ + async #recordAndRoute(args: { + endpoint: WebhookEndpoint; + artifact: WebhookVerifierArtifact; + filterAst: FilterAst | null; + parsedEvent: unknown; + idempotencyKey: string; + errorMessage: string | null; + rawBytes: Uint8Array; + headers: Record; + }): Promise { + const { + endpoint, + artifact, + filterAst, + parsedEvent, + idempotencyKey, + errorMessage, + rawBytes, + headers, + } = args; + + const secretHeader = + "config" in artifact && + artifact.config.scheme === "shared-secret" && + artifact.config.placement === "header" + ? artifact.config.fieldName + : undefined; + + const gateKey = `webhookdedupe:${endpoint.id}:${idempotencyKey}`; + const { id, friendlyId, timestamp: createdAt } = WebhookDeliveryId.generate(); + + const claimed = await this.frontGate.set( + gateKey, + friendlyId, + "EX", + this.#frontGateClaimTtlSeconds(), + "NX" + ); + if (claimed !== "OK") { + const existing = await this.frontGate.get(gateKey); + return { outcome: "duplicate", deliveryId: existing ?? undefined }; + } + + const { filtered, reason } = this.#evaluateFilter( + filterAst, + parsedEvent, + headers, + endpoint, + idempotencyKey + ); + + const isTest = Object.entries(headers).some( + ([key, value]) => key.toLowerCase() === "x-trigger-test" && Boolean(value) + ); + + let rowCreated = false; + try { + await this.prisma.webhookDelivery.create({ + data: { + id, + friendlyId, + createdAt, + webhookEndpointId: endpoint.id, + organizationId: endpoint.organizationId, + projectId: endpoint.projectId, + runtimeEnvironmentId: endpoint.runtimeEnvironmentId, + environmentType: endpoint.environmentType, + externalDeliveryId: idempotencyKey, + idempotencyKey, + status: filtered ? "FILTERED" : "PENDING", + isTest, + parsedEvent: toStorableEvent(parsedEvent), + headers: capHeaders(headers, secretHeader), + rawBodyHash: sha256Hex(rawBytes), + errorMessage, + filterReason: reason, + }, + }); + rowCreated = true; + + if (filtered) { + this.deliveryFilteredCounter.add(1); + } else { + this.deliveryEnqueueCounter.add(1); + await this.worker.enqueueOnce({ + id: `webhook-delivery:${id}`, + job: "webhook.deliver", + payload: { deliveryId: id, createdAt }, + }); + } + } catch (error) { + await this.frontGate.del(gateKey).catch(() => {}); + if (rowCreated) { + await this.prisma.webhookDelivery + .update({ + where: { id_createdAt: { id, createdAt } }, + data: { status: "FAILED", errorMessage: String(error), processedAt: new Date() }, + }) + .catch(() => {}); + } + return { outcome: "enqueue_failed", error: String(error) }; + } + + await this.frontGate + .set(gateKey, friendlyId, "EX", this.#frontGateTtlSeconds(artifact)) + .catch(() => {}); + + return { outcome: "accepted", deliveryId: id, deliveryFriendlyId: friendlyId }; + } + + /** + * Inject a delivery WITHOUT signature verification, then run the same filter + record + route path + * as ingest(). This is the test-console "simulate" mode for endpoints we cannot sign for + * (asymmetric public-key schemes; url-secret path placement). The body must be JSON. Everything + * downstream (filter, startOn, routing, run/session) runs for real. + */ + async simulateInject(input: IngestInput): Promise { + this.#assertEnabled(); + return startSpan(this.tracer, "webhook.simulate", async (span) => { + span.setAttribute("opaqueId", input.opaqueId); + + const endpoint = await this.prisma.webhookEndpoint.findFirst({ + where: { opaqueId: input.opaqueId }, + }); + if (!endpoint) return { outcome: "endpoint_not_found" }; + if (endpoint.status !== "ACTIVE") return { outcome: "endpoint_inactive" }; + + const parsedArtifact = WebhookVerifierArtifact.safeParse(endpoint.verifierArtifact); + if (!parsedArtifact.success) { + return { outcome: "verification_failed", error: "corrupt verifier artifact" }; + } + + const parsed = tryParseJson(input.rawBytes); + if (parsed.error || parsed.parsedEvent === undefined) { + return { outcome: "verification_failed", error: parsed.error ?? "body is not valid JSON" }; + } + + return this.#recordAndRoute({ + endpoint, + artifact: parsedArtifact.data, + filterAst: this.#parseStoredFilter(endpoint), + parsedEvent: parsed.parsedEvent, + idempotencyKey: deriveSimulateIdempotencyKey(parsedArtifact.data, input), + errorMessage: null, + rawBytes: input.rawBytes, + headers: input.headers, + }); + }); + } + + // PUBLIC: re-run a past delivery's task from its stored event. We don't keep the raw body, so this + // re-triggers from the captured parsedEvent + headers (not a re-verify). A NEW delivery row with a + // fresh idempotency key is created so the run actually executes (not deduped) and the replay is + // auditable; it shares the original externalDeliveryId so the two group together. + async replayDelivery(input: { id: string; createdAt: Date }): Promise { + this.#assertEnabled(); + return startSpan(this.tracer, "webhook.replay", async (span) => { + span.setAttribute("deliveryId", input.id); + + const original = await this.prisma.webhookDelivery.findFirst({ + where: { id: input.id, createdAt: input.createdAt }, + }); + if (!original) return { outcome: "delivery_not_found" }; + + const endpoint = await this.prisma.webhookEndpoint.findFirst({ + where: { id: original.webhookEndpointId }, + }); + if (!endpoint) return { outcome: "endpoint_not_found" }; + if ((endpoint.routingTarget as WebhookRoutingTarget).type !== "task") { + return { outcome: "unsupported_target" }; + } + + const { id, friendlyId, timestamp: createdAt } = WebhookDeliveryId.generate(); + + await this.prisma.webhookDelivery.create({ + data: { + id, + friendlyId, + createdAt, + webhookEndpointId: original.webhookEndpointId, + organizationId: original.organizationId, + projectId: original.projectId, + runtimeEnvironmentId: original.runtimeEnvironmentId, + environmentType: original.environmentType, + externalDeliveryId: original.externalDeliveryId, // groups with the original + idempotencyKey: `replay:${id}`, // unique -> the Run Engine runs it, not dedupes + status: "PENDING", + parsedEvent: (original.parsedEvent ?? undefined) as Prisma.InputJsonValue | undefined, + headers: (original.headers ?? undefined) as Prisma.InputJsonValue | undefined, + rawBodyHash: original.rawBodyHash, + }, + }); + this.deliveryEnqueueCounter.add(1); + + await this.worker.enqueueOnce({ + id: `webhook-delivery:${id}`, + job: "webhook.deliver", + payload: { deliveryId: id, createdAt }, + }); + + return { outcome: "replayed", deliveryId: id, deliveryFriendlyId: friendlyId }; + }); + } + + // Resolve the endpoint + plaintext signing secret for an opaqueId, cached. Only fully-resolved + // (ACTIVE + secret present) endpoints are cached; fail-closed outcomes always re-read. + async #resolveEndpoint( + opaqueId: string + ): Promise< + | { ok: true; endpoint: WebhookEndpoint; secret: string; filterAst: FilterAst | null } + | { ok: false; result: IngestResult } + > { + const cached = this.endpointCache.get(opaqueId); + if (cached) { + this.endpointCacheCounter.add(1, { result: "hit" }); + return { + ok: true, + endpoint: cached.endpoint, + secret: cached.secret, + filterAst: cached.filterAst, + }; + } + this.endpointCacheCounter.add(1, { result: "miss" }); + + // Single-row via the global @unique on opaqueId (Q2). + const endpoint = await this.prisma.webhookEndpoint.findFirst({ where: { opaqueId } }); + if (!endpoint) return { ok: false, result: { outcome: "endpoint_not_found" } }; + if (endpoint.status !== "ACTIVE") + return { ok: false, result: { outcome: "endpoint_inactive" } }; + + // Injected port keeps the engine SecretStore-free. + const secret = endpoint.signingSecretKey + ? await this.options.resolveSigningSecret(endpoint.signingSecretKey) + : undefined; + if (!secret) return { ok: false, result: { outcome: "secret_missing" } }; + + // Parse the stored AST once per cache load. A corrupt AST fails open (route all) rather than + // blocking delivery — a filter bug must never silently swallow real webhooks. + const filterAst = this.#parseStoredFilter(endpoint); + + this.endpointCache.set(opaqueId, { endpoint, secret, filterAst }); + return { ok: true, endpoint, secret, filterAst }; + } + + #parseStoredFilter(endpoint: WebhookEndpoint): FilterAst | null { + if (endpoint.filterAst == null) return null; + const parsed = FilterAst.safeParse(endpoint.filterAst); + if (!parsed.success) { + this.logger.warn("webhook: corrupt filterAst, routing all", { endpointId: endpoint.id }); + return null; + } + return parsed.data; + } + + // Evaluate the endpoint filter (if any) against the verified delivery. Fail-open: an evaluation + // error routes the delivery (never drops a real webhook) and is logged. + // Evaluate a session routing target's `startOn` against the event. Absent => start allowed. A parse or + // eval error fails open (start allowed), matching the route filter, so a bad predicate never wedges a + // session. Parsed per delivery: only on the session path, and cheap relative to the DB + S2 work. + #evaluateSessionStart( + startOn: string | undefined, + delivery: WebhookDelivery, + endpoint: WebhookEndpoint + ): boolean { + if (!startOn) return true; + try { + const result = evaluateFilter(parseFilter(startOn), { + event: delivery.parsedEvent, + headers: (delivery.headers as Record | null) ?? {}, + webhook: { + externalRef: endpoint.endpointExternalRef, + tenantId: endpoint.endpointTenantId, + id: endpoint.handlerWebhookId, + source: endpoint.source, + deliveryId: delivery.externalDeliveryId, + }, + }); + return result.match; + } catch (error) { + this.logger.warn("webhook startOn evaluation failed, allowing start (fail-open)", { + endpointId: endpoint.id, + error: error instanceof Error ? error.message : String(error), + }); + return true; + } + } + + #evaluateFilter( + filterAst: FilterAst | null, + parsedEvent: unknown, + headers: Record, + endpoint: WebhookEndpoint, + deliveryId: string + ): { filtered: boolean; reason: string | null } { + if (!filterAst) return { filtered: false, reason: null }; + try { + const result = evaluateFilter(filterAst, { + event: parsedEvent, + headers, + webhook: { + externalRef: endpoint.endpointExternalRef, + tenantId: endpoint.endpointTenantId, + id: endpoint.handlerWebhookId, + source: endpoint.source, + deliveryId, + }, + }); + return result.match + ? { filtered: false, reason: null } + : { filtered: true, reason: result.reason ?? null }; + } catch (error) { + this.logger.warn("webhook filter evaluation failed, routing (fail-open)", { + endpointId: endpoint.id, + error: error instanceof Error ? error.message : String(error), + }); + return { filtered: false, reason: null }; + } + } + + // Best-effort dedupe window. Capped at the configured max; the durable Run Engine + // idempotencyKey gate is the real guard for late retries past this window. + #frontGateTtlSeconds(_artifact: WebhookVerifierArtifact): number { + const def = this.options.frontGate?.defaultTtlSeconds ?? 6 * 60 * 60; + const max = this.options.frontGate?.maxTtlSeconds ?? 6 * 60 * 60; + return Math.min(def, max); + } + + // Short "processing lock" TTL held between the atomic claim and the promote-to-full-window. Keep + // it well above the create+enqueue latency (ms) but short enough that a crash mid-ingest releases + // the key long before a provider's retry, so events are re-processed rather than dropped. + #frontGateClaimTtlSeconds(): number { + return this.options.frontGate?.claimTtlSeconds ?? 60; + } + + // webhook.deliver handler: load delivery + endpoint, route to the task via the + // injected port, mark terminal. Run Engine idempotency is the durable dedupe gate. + async #handleDeliverJob({ + payload, + attempt, + }: JobHandlerParams) { + return startSpan(this.tracer, "webhook.deliver", async (span) => { + span.setAttribute("deliveryId", payload.deliveryId); + this.deliveryExecutionCounter.add(1); + const start = performance.now(); + + // 1. Load by composite PK. (id, createdAt) prunes to exactly one partition. + const delivery = await this.prisma.webhookDelivery.findFirst({ + where: { id: payload.deliveryId, createdAt: payload.createdAt }, + }); + if (!delivery) { + this.logger.error("webhook.deliver: delivery not found", { + deliveryId: payload.deliveryId, + }); + return; // terminal; nothing to retry + } + + if (delivery.status === "SUCCEEDED" || delivery.status === "FAILED") { + this.logger.debug("webhook.deliver: already terminal", { + deliveryId: delivery.id, + status: delivery.status, + }); + return; + } + + const endpoint = await this.prisma.webhookEndpoint.findFirst({ + where: { id: delivery.webhookEndpointId }, + }); + if (!endpoint) { + await this.#markFailed(delivery, "Endpoint not found"); + return; + } + + // 2. Mark PROCESSING (best-effort). + await this.prisma.webhookDelivery.update({ + where: { id_createdAt: { id: delivery.id, createdAt: delivery.createdAt } }, + data: { status: "PROCESSING" }, + }); + + // 3. Resolve route. + const routingTarget = endpoint.routingTarget as WebhookRoutingTarget; + + if (routingTarget.type === "session") { + await this.#deliverToSession(delivery, endpoint, routingTarget, start, attempt); + return; + } + if (routingTarget.type !== "task") { + await this.#markFailed( + delivery, + `Unsupported routing target: ${(routingTarget as { type: string }).type}` + ); + return; + } + + // 4. Call the injected triggerTask port. + const identityTags = [ + `webhook:endpoint:${endpoint.id}`, + `webhook:source:${endpoint.source}`, + ...(endpoint.endpointTenantId ? [`webhook:tenant:${endpoint.endpointTenantId}`] : []), + ]; + + const result = await this.options.triggerTask({ + environmentId: delivery.runtimeEnvironmentId, + taskId: routingTarget.taskId, + idempotencyKey: `${endpoint.id}:${delivery.idempotencyKey}`, + idempotencyKeyExpiresAt: this.#retryWindowExpiry(endpoint), + payload: delivery.parsedEvent, + headers: (delivery.headers as Record | null) ?? {}, + identityTags, + endpointMetadata: endpoint.metadata, + }); + + await this.#applyDeliverResult(delivery, result, start, attempt); + }); + } + + // Route a verified delivery to a session: resolve the key template to the session externalId, then + // hand off to the injected deliverToSession port (find-or-create the session, append the action). + async #deliverToSession( + delivery: WebhookDelivery, + endpoint: WebhookEndpoint, + routingTarget: Extract, + start: number, + attempt: number + ) { + if (!this.options.deliverToSession) { + await this.#markFailed(delivery, "Session delivery is not configured"); + return; + } + + const externalId = evaluateSessionKeyTemplate(routingTarget.keyTemplate, { + body: delivery.parsedEvent, + webhook: { + externalRef: endpoint.endpointExternalRef ?? "", + tenantId: endpoint.endpointTenantId ?? "", + id: endpoint.handlerWebhookId, + source: endpoint.source, + deliveryId: delivery.externalDeliveryId, + }, + header: (delivery.headers as Record | null) ?? {}, + }); + if (!externalId) { + await this.#markFailed(delivery, `Session key resolved empty: ${routingTarget.keyTemplate}`); + return; + } + + const isSessionStart = this.#evaluateSessionStart(routingTarget.startOn, delivery, endpoint); + + const result = await this.options.deliverToSession({ + environmentId: delivery.runtimeEnvironmentId, + taskIdentifier: routingTarget.taskIdentifier, + externalId, + deliverAs: routingTarget.deliverAs, + actionType: routingTarget.actionType, + connectorId: routingTarget.connectorId, + event: delivery.parsedEvent, + source: endpoint.source, + headers: (delivery.headers as Record | null) ?? {}, + deliveryId: delivery.externalDeliveryId, + triggerConfigTemplate: routingTarget.triggerConfigTemplate, + idempotencyKey: delivery.idempotencyKey, + isSessionStart, + }); + + await this.#applyDeliverResult(delivery, result, start, attempt); + } + + // Mark a delivery terminal from a port result. Shared by the task and session paths: success -> + // SUCCEEDED (+runId); a transient error resets to PENDING and throws so redis-worker retries, EXCEPT + // on the final attempt where it's marked FAILED (throwing there would DLQ the job and leave the + // delivery stuck PENDING); any other error -> FAILED. + async #applyDeliverResult( + delivery: WebhookDelivery, + result: { + success: boolean; + runId?: string; + error?: string; + errorType?: WebhookDeliverTaskErrorType; + skipped?: boolean; + skippedReason?: string; + }, + start: number, + attempt: number + ) { + this.deliveryExecutionDuration.record(performance.now() - start); + + if (result.skipped) { + // Resume-only and no session existed: terminal + visible, but nothing routed (no run, no egress). + await this.prisma.webhookDelivery.update({ + where: { id_createdAt: { id: delivery.id, createdAt: delivery.createdAt } }, + data: { + status: "FILTERED", + filterReason: result.skippedReason ?? "startOn: not a session-start event", + processedAt: new Date(), + }, + }); + } else if (result.success) { + await this.prisma.webhookDelivery.update({ + where: { id_createdAt: { id: delivery.id, createdAt: delivery.createdAt } }, + data: { status: "SUCCEEDED", runId: result.runId ?? null, processedAt: new Date() }, + }); + } else if (result.errorType === "QUEUE_LIMIT" || result.errorType === "SYSTEM_ERROR") { + if (attempt >= WEBHOOK_DELIVER_MAX_ATTEMPTS - 1) { + await this.#markFailed( + delivery, + result.error ?? "webhook.deliver failed after max attempts" + ); + return; + } + await this.prisma.webhookDelivery.update({ + where: { id_createdAt: { id: delivery.id, createdAt: delivery.createdAt } }, + data: { status: "PENDING", errorMessage: result.error ?? null }, + }); + this.deliveryExecutionFailureCounter.add(1); + throw new Error(result.error ?? "webhook.deliver transient failure"); + } else { + await this.#markFailed(delivery, result.error ?? "webhook.deliver failed"); + } + } + + async #markFailed(delivery: WebhookDelivery, message: string) { + this.deliveryExecutionFailureCounter.add(1); + await this.prisma.webhookDelivery.update({ + where: { id_createdAt: { id: delivery.id, createdAt: delivery.createdAt } }, + data: { status: "FAILED", errorMessage: message, processedAt: new Date() }, + }); + } + + #retryWindowExpiry(endpoint: WebhookEndpoint): Date { + return new Date(Date.now() + resolveRetryWindowSeconds(endpoint) * 1000); + } + + // ensurePartitions cron handler. Pre-creates dated partitions ahead + drops cold ones. + async #handleEnsurePartitionsJob( + _job: JobHandlerParams + ) { + return startSpan(this.tracer, "ensurePartitions", async (span) => { + this.ensurePartitionsCounter.add(1); + const result = await ensurePartitions(this.prisma, { + now: new Date(), + lookaheadDays: this.options.partitions?.lookaheadDays ?? 10, // 7..14 + retentionDays: this.options.partitions?.retentionDays ?? 7, + }); + span.setAttribute("created", result.created.length); + span.setAttribute("dropped", result.dropped.length); + span.setAttribute("deferred", result.deferred.length); + this.logger.info("webhook ensurePartitions", result); + }); + } + + async getJob(id: string) { + this.#assertEnabled(); + return this.worker.getJob(id); + } + + async quit() { + if (this.options.disabled) return; + this.logger.info("Shutting down webhook engine"); + + try { + await this.worker.stop(); + await this.frontGate.quit(); + this.logger.info("Webhook engine worker stopped successfully"); + } catch (error) { + this.logger.error("Error stopping webhook engine worker", { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} + +// The provider retry window: idempotencyKeyExpiresAt = now + this. Must exceed the +// longest provider retry horizon so a late retry still hits the durable gate. +function resolveRetryWindowSeconds(endpoint: { source: string }): number { + const DAY = 24 * 60 * 60; + switch (endpoint.source) { + case "stripe": + return 3 * DAY; + case "github": + return 1 * DAY; + default: + return 1 * DAY; + } +} + +const DROP_HEADERS = new Set(["authorization", "proxy-authorization", "cookie", "set-cookie"]); +const HEADERS_CAP_BYTES = 4 * 1024; + +// Curate the headers stored on the delivery row (and thus surfaced to onEvent): drop credential +// headers + any scheme secret-bearing header, and bound the total size. +function capHeaders(headers: Record, secretHeader?: string): Prisma.InputJsonValue { + const drop = secretHeader ? new Set([...DROP_HEADERS, secretHeader.toLowerCase()]) : DROP_HEADERS; + const out: Record = {}; + let bytes = 0; + for (const [key, value] of Object.entries(headers)) { + if (drop.has(key.toLowerCase())) continue; + const entryBytes = key.length + (typeof value === "string" ? value.length : 0) + 4; + if (bytes + entryBytes > HEADERS_CAP_BYTES) continue; + out[key] = value; + bytes += entryBytes; + } + return out; +} + +function deriveSimulateIdempotencyKey( + artifact: WebhookVerifierArtifact, + input: IngestInput +): string { + const idempotencyField = "config" in artifact ? artifact.config.idempotencyField : undefined; + const lowerHeaders: Record = {}; + for (const [key, value] of Object.entries(input.headers)) lowerHeaders[key.toLowerCase()] = value; + return deriveIdempotencyKey({ + idempotencyField, + headers: lowerHeaders, + rawBytes: input.rawBytes, + timestampValue: "", + signatureValue: "simulate", + }); +} + +/** + * The verified event stored on the delivery row. This is the only durable copy of the event: it is + * routed as the run payload (task + session) and re-used on replay, so it is stored in full and the + * ingress body-size limit bounds it. The dashboard caps it for display, not here. Returns undefined + * only when the event cannot be serialized (never for a JSON-parsed body), which keeps a + * non-serializable value from failing the whole delivery. + */ +function toStorableEvent(event: unknown): Prisma.InputJsonValue | undefined { + if (event === undefined || event === null) return undefined; + try { + JSON.stringify(event); + return event as Prisma.InputJsonValue; + } catch { + return undefined; + } +} diff --git a/internal-packages/webhook-engine/src/engine/ingest.test.ts b/internal-packages/webhook-engine/src/engine/ingest.test.ts new file mode 100644 index 0000000000..33c657c44f --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/ingest.test.ts @@ -0,0 +1,815 @@ +import { createHmac, randomBytes } from "node:crypto"; +import { + containerTestWithIsolatedRedisNoClickhouse, + createStandalonePostgresContainer, +} from "@internal/testcontainers"; +import { createRedisClient } from "@internal/redis"; +import { type Prisma, PrismaClient } from "@trigger.dev/database"; +import type { RedisOptions } from "@internal/redis"; +import { WebhookEndpointId } from "@trigger.dev/core/v3/isomorphic"; +import { expect } from "vitest"; +import { WebhookEngine } from "./index.js"; +import { parseFilter } from "./filter/index.js"; +import type { + DeliverWebhookToSessionCallback, + DeliverWebhookToSessionParams, + TriggerWebhookTaskCallback, + TriggerWebhookTaskParams, +} from "./types.js"; + +// End-to-end ingest -> deliver over a real Postgres + Redis: the de-risking test for the atomic +// front gate (happy path, concurrent dedup, crash recovery) and the Run-Engine idempotencyKey wiring. + +const SECRET = "whsec_integration_secret"; +const SECRET_KEY = "secretref_integration"; + +const VERIFIER_CONFIG = { + scheme: "hmac", + algorithm: "sha256", + encoding: "hex", + signatureHeader: "stripe-signature", + signature: { itemSeparator: ",", fieldSeparator: "=", field: "v1" }, + timestamp: { source: { from: "signatureField", field: "t" }, toleranceSeconds: 300 }, + signingString: { template: "{timestamp}.{body}" }, + idempotencyField: { from: "body", name: "id" }, +} as const; + +// Live clock so the 300s tolerance passes (the engine calls verify() without an injected clock). +function signedInput(eventId: string, opaqueId: string, type = "payment_intent.succeeded") { + const body = JSON.stringify({ id: eventId, type }); + const t = Math.floor(Date.now() / 1000); + const sig = createHmac("sha256", SECRET).update(`${t}.${body}`).digest("hex"); + return { + opaqueId, + rawBytes: new TextEncoder().encode(body), + headers: { "stripe-signature": `t=${t},v1=${sig}` }, + url: `https://api.example.com/webhooks/v1/ingest/${opaqueId}`, + }; +} + +async function createEndpoint(prisma: PrismaClient, over?: { filter?: string }) { + return prisma.webhookEndpoint.create({ + data: { + friendlyId: WebhookEndpointId.generate().friendlyId, + opaqueId: `op_${randomBytes(12).toString("hex")}`, + organizationId: "org_test", + projectId: "proj_test", + runtimeEnvironmentId: "env_test", + environmentType: "PRODUCTION", + source: "stripe", + handlerWebhookId: "handle-stripe-webhook", + routingTarget: { type: "task", taskId: "handle-stripe-task" }, + verifierArtifact: { kind: "config", config: VERIFIER_CONFIG }, + signingSecretKey: SECRET_KEY, + status: "ACTIVE", + ...(over?.filter + ? { + filter: over.filter, + filterAst: parseFilter(over.filter) as unknown as Prisma.InputJsonValue, + filterAstVersion: 1, + } + : {}), + }, + }); +} + +// Models Run-Engine idempotency: same idempotencyKey -> same run, so distinct runs = runsByKey.size. +function makeTriggerTaskStub() { + const calls: TriggerWebhookTaskParams[] = []; + const runsByKey = new Map(); + let runCounter = 0; + const triggerTask: TriggerWebhookTaskCallback = async (params) => { + calls.push(params); + let runId = runsByKey.get(params.idempotencyKey); + if (!runId) { + runId = `run_${++runCounter}`; + runsByKey.set(params.idempotencyKey, runId); + } + return { success: true, runId }; + }; + return { triggerTask, calls, runsByKey }; +} + +// Session-routing endpoint: routes to a find-or-created session (keyTemplate -> externalId) instead +// of a task. The stub stands in for the webapp's deliverToSession port (find-or-create + append). +async function createSessionEndpoint( + prisma: PrismaClient, + keyTemplate: string, + startOn?: string, + handlerWebhookId = "agent-x:orders" +) { + return prisma.webhookEndpoint.create({ + data: { + friendlyId: WebhookEndpointId.generate().friendlyId, + opaqueId: `op_${randomBytes(12).toString("hex")}`, + organizationId: "org_test", + projectId: "proj_test", + runtimeEnvironmentId: "env_test", + environmentType: "PRODUCTION", + source: "stripe", + handlerWebhookId, + routingTarget: { + type: "session", + taskIdentifier: "agent-x", + keyTemplate, + actionType: "order.event", + deliverAs: "action", + ...(startOn ? { startOn } : {}), + }, + verifierArtifact: { kind: "config", config: VERIFIER_CONFIG }, + signingSecretKey: SECRET_KEY, + status: "ACTIVE", + }, + }); +} + +function makeDeliverToSessionStub() { + const calls: DeliverWebhookToSessionParams[] = []; + const runsByExternalId = new Map(); + let runCounter = 0; + const deliverToSession: DeliverWebhookToSessionCallback = async (params) => { + calls.push(params); + let runId = runsByExternalId.get(params.externalId); + if (!runId) { + runId = `srun_${++runCounter}`; + runsByExternalId.set(params.externalId, runId); + } + return { success: true, runId }; + }; + return { deliverToSession, calls, runsByExternalId }; +} + +function buildEngine( + prisma: PrismaClient, + redisOptions: RedisOptions, + triggerTask: TriggerWebhookTaskCallback, + over?: { + claimTtlSeconds?: number; + workerDisabled?: boolean; + endpointCacheTtlMs?: number; + resolveSigningSecret?: (key: string) => Promise; + deliverToSession?: DeliverWebhookToSessionCallback; + } +) { + return new WebhookEngine({ + prisma, + redis: redisOptions, + worker: { concurrency: 1, pollIntervalMs: 50, disabled: over?.workerDisabled }, + frontGate: over?.claimTtlSeconds ? { claimTtlSeconds: over.claimTtlSeconds } : undefined, + endpointCache: + over?.endpointCacheTtlMs !== undefined ? { ttlMs: over.endpointCacheTtlMs } : undefined, + triggerTask, + deliverToSession: over?.deliverToSession, + resolveSigningSecret: + over?.resolveSigningSecret ?? (async (key) => (key === SECRET_KEY ? SECRET : undefined)), + logLevel: "error", + }); +} + +async function waitFor(fn: () => Promise, timeoutMs = 10_000, intervalMs = 50) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await fn()) return; + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error(`waitFor timed out after ${timeoutMs}ms`); +} + +containerTestWithIsolatedRedisNoClickhouse( + "ingest -> deliver routes a verified event to exactly one run", + async ({ prisma, redisOptions }) => { + const endpoint = await createEndpoint(prisma); + const { triggerTask, calls, runsByKey } = makeTriggerTaskStub(); + const engine = buildEngine(prisma, redisOptions, triggerTask); + + try { + const eventId = "evt_happy_1"; + const result = await engine.ingest(signedInput(eventId, endpoint.opaqueId)); + + expect(result.outcome).toBe("accepted"); + if (result.outcome !== "accepted") return; + const deliveryId = result.deliveryId; + + await waitFor(async () => { + const d = await prisma.webhookDelivery.findFirst({ where: { id: deliveryId } }); + return d?.status === "SUCCEEDED"; + }); + + const delivery = await prisma.webhookDelivery.findFirst({ where: { id: deliveryId } }); + expect(delivery?.runId).toBe("run_1"); + expect(delivery?.externalDeliveryId).toBe(eventId); + + expect(calls).toHaveLength(1); + expect(runsByKey.size).toBe(1); + expect(calls[0].taskId).toBe("handle-stripe-task"); + expect(calls[0].idempotencyKey).toBe(`${endpoint.id}:${eventId}`); + } finally { + await engine.quit(); + } + } +); + +containerTestWithIsolatedRedisNoClickhouse( + "ingest -> deliver routes the full event when it exceeds the dashboard snapshot cap", + async ({ prisma, redisOptions }) => { + const endpoint = await createEndpoint(prisma); + const { triggerTask, calls } = makeTriggerTaskStub(); + const engine = buildEngine(prisma, redisOptions, triggerTask); + + try { + const eventId = "evt_large_1"; + const blob = "x".repeat(64 * 1024); + const body = JSON.stringify({ id: eventId, type: "payment_intent.succeeded", blob }); + const t = Math.floor(Date.now() / 1000); + const sig = createHmac("sha256", SECRET).update(`${t}.${body}`).digest("hex"); + + const result = await engine.ingest({ + opaqueId: endpoint.opaqueId, + rawBytes: new TextEncoder().encode(body), + headers: { "stripe-signature": `t=${t},v1=${sig}` }, + url: `https://api.example.com/webhooks/v1/ingest/${endpoint.opaqueId}`, + }); + + expect(result.outcome).toBe("accepted"); + if (result.outcome !== "accepted") return; + const deliveryId = result.deliveryId; + + await waitFor(async () => { + const d = await prisma.webhookDelivery.findFirst({ where: { id: deliveryId } }); + return d?.status === "SUCCEEDED"; + }); + + expect(calls).toHaveLength(1); + expect(calls[0].payload).toEqual({ id: eventId, type: "payment_intent.succeeded", blob }); + + const delivery = await prisma.webhookDelivery.findFirst({ where: { id: deliveryId } }); + const stored = delivery?.parsedEvent as { blob?: string; truncated?: boolean } | null; + expect(stored?.blob).toBe(blob); + expect(stored?.truncated).toBeUndefined(); + } finally { + await engine.quit(); + } + } +); + +// Two-database mode: point the engine at a SEPARATE Postgres (the future data-plane DB) with the +// fixture's `prisma` as the main DB, and prove webhook writes land only on the injected client (a +// leaked query onto the main client would show up as a row there). Runtime backstop to the types. +containerTestWithIsolatedRedisNoClickhouse( + "two-database mode: webhook writes land on the injected client's DB, never the main DB", + async ({ prisma, redisOptions }) => { + const { container: webhookContainer, url: webhookUrl } = + await createStandalonePostgresContainer(); + const webhookDb = new PrismaClient({ datasources: { db: { url: webhookUrl } } }); + const { triggerTask } = makeTriggerTaskStub(); + const engine = buildEngine(webhookDb, redisOptions, triggerTask); + + try { + // The endpoint lives on the webhook DB; the engine reads it from its own client. + const endpoint = await createEndpoint(webhookDb); + + const eventId = "evt_two_db_1"; + const result = await engine.ingest(signedInput(eventId, endpoint.opaqueId)); + expect(result.outcome).toBe("accepted"); + if (result.outcome !== "accepted") return; + + await waitFor(async () => { + const d = await webhookDb.webhookDelivery.findFirst({ where: { id: result.deliveryId } }); + return d?.status === "SUCCEEDED"; + }); + + // The delivery + endpoint are on the webhook DB... + expect(await webhookDb.webhookDelivery.count()).toBe(1); + expect(await webhookDb.webhookEndpoint.count()).toBe(1); + + // ...and nothing leaked onto the main DB. + expect(await prisma.webhookDelivery.count()).toBe(0); + expect(await prisma.webhookEndpoint.count()).toBe(0); + } finally { + await engine.quit(); + await webhookDb.$disconnect(); + await webhookContainer.stop(); + } + }, + 120_000 +); + +containerTestWithIsolatedRedisNoClickhouse( + "a provider handshake (url_verification) echoes the challenge and records no delivery", + async ({ prisma, redisOptions }) => { + const endpoint = await prisma.webhookEndpoint.create({ + data: { + friendlyId: WebhookEndpointId.generate().friendlyId, + opaqueId: `op_${randomBytes(12).toString("hex")}`, + organizationId: "org_test", + projectId: "proj_test", + runtimeEnvironmentId: "env_test", + environmentType: "PRODUCTION", + source: "slack", + handlerWebhookId: "handle-slack-channel", + routingTarget: { type: "task", taskId: "unused" }, + verifierArtifact: { + kind: "config", + config: VERIFIER_CONFIG, + handshake: { + matchPath: "type", + matchValue: "url_verification", + respondPath: "challenge", + }, + }, + signingSecretKey: SECRET_KEY, + status: "ACTIVE", + }, + }); + const { triggerTask } = makeTriggerTaskStub(); + const engine = buildEngine(prisma, redisOptions, triggerTask); + + try { + const body = JSON.stringify({ type: "url_verification", challenge: "chal_xyz" }); + const t = Math.floor(Date.now() / 1000); + const sig = createHmac("sha256", SECRET).update(`${t}.${body}`).digest("hex"); + const result = await engine.ingest({ + opaqueId: endpoint.opaqueId, + rawBytes: new TextEncoder().encode(body), + headers: { "stripe-signature": `t=${t},v1=${sig}` }, + url: `https://api.example.com/webhooks/v1/ingest/${endpoint.opaqueId}`, + }); + + expect(result.outcome).toBe("handshake"); + if (result.outcome === "handshake") expect(result.body).toBe("chal_xyz"); + + const count = await prisma.webhookDelivery.count({ + where: { webhookEndpointId: endpoint.id }, + }); + expect(count).toBe(0); + } finally { + await engine.quit(); + } + } +); + +containerTestWithIsolatedRedisNoClickhouse( + "concurrent duplicate deliveries collapse to one delivery row and one run", + async ({ prisma, redisOptions }) => { + const endpoint = await createEndpoint(prisma); + const { triggerTask, calls, runsByKey } = makeTriggerTaskStub(); + const engine = buildEngine(prisma, redisOptions, triggerTask); + + try { + const eventId = "evt_dupe_1"; + const input = signedInput(eventId, endpoint.opaqueId); + + const [a, b] = await Promise.all([engine.ingest(input), engine.ingest(input)]); + + // The atomic SET NX claim guarantees exactly one accepted and one duplicate. + expect([a.outcome, b.outcome].sort()).toEqual(["accepted", "duplicate"]); + + const accepted = (a.outcome === "accepted" ? a : b) as Extract< + typeof a, + { outcome: "accepted" } + >; + const duplicate = (a.outcome === "duplicate" ? a : b) as Extract< + typeof a, + { outcome: "duplicate" } + >; + expect(duplicate.deliveryId).toBe(accepted.deliveryFriendlyId); + + const rows = await prisma.webhookDelivery.findMany({ + where: { webhookEndpointId: endpoint.id }, + }); + expect(rows).toHaveLength(1); + + await waitFor(async () => { + const d = await prisma.webhookDelivery.findFirst({ where: { id: accepted.deliveryId } }); + return d?.status === "SUCCEEDED"; + }); + + expect(calls).toHaveLength(1); + expect(runsByKey.size).toBe(1); + } finally { + await engine.quit(); + } + } +); + +containerTestWithIsolatedRedisNoClickhouse( + "stores curated, size-bounded headers (drops credentials and oversized headers)", + async ({ prisma, redisOptions }) => { + const endpoint = await createEndpoint(prisma); + const { triggerTask } = makeTriggerTaskStub(); + const engine = buildEngine(prisma, redisOptions, triggerTask, { workerDisabled: true }); + + try { + const input = signedInput("evt_headers_1", endpoint.opaqueId); + input.headers["authorization"] = "Bearer super-secret"; + input.headers["cookie"] = "session=abc"; + input.headers["x-custom"] = "keep-me"; + input.headers["x-huge"] = "z".repeat(8 * 1024); + + const result = await engine.ingest(input); + expect(result.outcome).toBe("accepted"); + if (result.outcome !== "accepted") return; + + const delivery = await prisma.webhookDelivery.findFirst({ where: { id: result.deliveryId } }); + const stored = delivery!.headers as Record; + + expect(stored["authorization"]).toBeUndefined(); + expect(stored["cookie"]).toBeUndefined(); + expect(stored["x-huge"]).toBeUndefined(); + expect(stored["x-custom"]).toBe("keep-me"); + expect(stored["stripe-signature"]).toBeDefined(); + expect(JSON.stringify(stored).length).toBeLessThanOrEqual(4 * 1024 + 256); + } finally { + await engine.quit(); + } + } +); + +containerTestWithIsolatedRedisNoClickhouse( + "caches the endpoint + secret across ingests and re-reads after the TTL", + async ({ prisma, redisOptions }) => { + const endpoint = await createEndpoint(prisma); + const { triggerTask } = makeTriggerTaskStub(); + let secretReads = 0; + const engine = buildEngine(prisma, redisOptions, triggerTask, { + endpointCacheTtlMs: 200, + resolveSigningSecret: async (key) => { + secretReads++; + return key === SECRET_KEY ? SECRET : undefined; + }, + }); + + try { + expect((await engine.ingest(signedInput("evt_cache_1", endpoint.opaqueId))).outcome).toBe( + "accepted" + ); + expect((await engine.ingest(signedInput("evt_cache_2", endpoint.opaqueId))).outcome).toBe( + "accepted" + ); + // Second ingest hit the cache, so the secret was resolved only once. + expect(secretReads).toBe(1); + + // After the TTL the entry expires and the next ingest re-resolves. + await new Promise((r) => setTimeout(r, 250)); + expect((await engine.ingest(signedInput("evt_cache_3", endpoint.opaqueId))).outcome).toBe( + "accepted" + ); + expect(secretReads).toBe(2); + } finally { + await engine.quit(); + } + } +); + +containerTestWithIsolatedRedisNoClickhouse( + "a crash between claim and promote releases the short lock so the event is re-processed", + async ({ prisma, redisOptions }) => { + const endpoint = await createEndpoint(prisma); + const { triggerTask } = makeTriggerTaskStub(); + const engine = buildEngine(prisma, redisOptions, triggerTask, { + claimTtlSeconds: 1, + workerDisabled: true, + }); + const gate = createRedisClient(redisOptions); + + try { + const eventId = "evt_crash_1"; + const input = signedInput(eventId, endpoint.opaqueId); + const gateKey = `webhookdedupe:${endpoint.id}:${eventId}`; + + // Post-crash state: claim set (short TTL), but the ingest died before creating the row. + await gate.set(gateKey, "whd_orphaned_claim", "EX", 1, "NX"); + + const blocked = await engine.ingest(input); + expect(blocked.outcome).toBe("duplicate"); + expect( + await prisma.webhookDelivery.count({ where: { webhookEndpointId: endpoint.id } }) + ).toBe(0); + + // Once the short lock expires, a retry re-claims and the event is processed, not dropped. + await waitFor(async () => (await gate.get(gateKey)) === null, 5_000); + + const recovered = await engine.ingest(input); + expect(recovered.outcome).toBe("accepted"); + expect( + await prisma.webhookDelivery.count({ where: { webhookEndpointId: endpoint.id } }) + ).toBe(1); + } finally { + await gate.quit(); + await engine.quit(); + } + } +); + +containerTestWithIsolatedRedisNoClickhouse( + "filter gate: a non-matching event is recorded FILTERED and not routed; a match is routed", + async ({ prisma, redisOptions }) => { + const endpoint = await createEndpoint(prisma, { + filter: "event.type == 'payment_intent.succeeded'", + }); + const { triggerTask, calls } = makeTriggerTaskStub(); + const engine = buildEngine(prisma, redisOptions, triggerTask); + + try { + // Matching event -> routed to a run as usual. + const match = await engine.ingest(signedInput("evt_match", endpoint.opaqueId)); + expect(match.outcome).toBe("accepted"); + if (match.outcome !== "accepted") return; + await waitFor(async () => { + const d = await prisma.webhookDelivery.findFirst({ where: { id: match.deliveryId } }); + return d?.status === "SUCCEEDED"; + }); + expect(calls).toHaveLength(1); + + // Non-matching event -> still received (200 + row), recorded FILTERED, never routed. + const filtered = await engine.ingest( + signedInput("evt_nomatch", endpoint.opaqueId, "payment_intent.failed") + ); + expect(filtered.outcome).toBe("accepted"); + if (filtered.outcome !== "accepted") return; + + const row = await prisma.webhookDelivery.findFirst({ where: { id: filtered.deliveryId } }); + expect(row?.status).toBe("FILTERED"); + expect(row?.runId).toBeNull(); + expect(row?.filterReason).toContain("event.type"); + + // The deliver worker must never pick it up: no new triggerTask call after a beat. + await new Promise((r) => setTimeout(r, 300)); + expect(calls).toHaveLength(1); + } finally { + await engine.quit(); + } + } +); + +containerTestWithIsolatedRedisNoClickhouse( + "session routing resolves the key and delivers to the session port", + async ({ prisma, redisOptions }) => { + const endpoint = await createSessionEndpoint(prisma, "{body.id}"); + const { triggerTask } = makeTriggerTaskStub(); + const { deliverToSession, calls } = makeDeliverToSessionStub(); + const engine = buildEngine(prisma, redisOptions, triggerTask, { deliverToSession }); + + try { + const eventId = "evt_sess_1"; + const result = await engine.ingest(signedInput(eventId, endpoint.opaqueId)); + if (result.outcome !== "accepted") + throw new Error(`expected accepted, got ${result.outcome}`); + + await waitFor(async () => { + const d = await prisma.webhookDelivery.findFirst({ where: { id: result.deliveryId } }); + return d?.status === "SUCCEEDED"; + }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.externalId).toBe(eventId); // {body.id} -> event id + expect(calls[0]?.taskIdentifier).toBe("agent-x"); + expect(calls[0]?.actionType).toBe("order.event"); + expect((calls[0]!.event as { id: string }).id).toBe(eventId); + expect(calls[0]?.deliveryId).toBe(eventId); + + const d = await prisma.webhookDelivery.findFirst({ where: { id: result.deliveryId } }); + expect(d?.runId).toBe("srun_1"); // the session's run, from the port + } finally { + await engine.quit(); + } + } +); + +containerTestWithIsolatedRedisNoClickhouse( + "session delivery with an unresolvable key is FAILED and never calls the port", + async ({ prisma, redisOptions }) => { + const endpoint = await createSessionEndpoint(prisma, "{body.customerId}"); // not in the body + const { triggerTask } = makeTriggerTaskStub(); + const { deliverToSession, calls } = makeDeliverToSessionStub(); + const engine = buildEngine(prisma, redisOptions, triggerTask, { deliverToSession }); + + try { + const result = await engine.ingest(signedInput("evt_sess_nokey", endpoint.opaqueId)); + if (result.outcome !== "accepted") + throw new Error(`expected accepted, got ${result.outcome}`); + + await waitFor(async () => { + const d = await prisma.webhookDelivery.findFirst({ where: { id: result.deliveryId } }); + return d?.status === "FAILED"; + }); + + expect(calls).toHaveLength(0); + const d = await prisma.webhookDelivery.findFirst({ where: { id: result.deliveryId } }); + expect(d?.errorMessage).toContain("key resolved empty"); + } finally { + await engine.quit(); + } + } +); + +containerTestWithIsolatedRedisNoClickhouse( + "startOn is evaluated and passed to the port as isSessionStart (default true when absent)", + async ({ prisma, redisOptions }) => { + const gated = await createSessionEndpoint(prisma, "{body.id}", "event.type == 'session.start'"); + const open = await createSessionEndpoint(prisma, "{body.id}", undefined, "agent-y:orders"); // no startOn + const { triggerTask } = makeTriggerTaskStub(); + const { deliverToSession, calls } = makeDeliverToSessionStub(); + const engine = buildEngine(prisma, redisOptions, triggerTask, { deliverToSession }); + + try { + const rStart = await engine.ingest(signedInput("evt_start", gated.opaqueId, "session.start")); + const rOther = await engine.ingest(signedInput("evt_other", gated.opaqueId, "message")); + const rOpen = await engine.ingest(signedInput("evt_open", open.opaqueId, "message")); + for (const r of [rStart, rOther, rOpen]) { + if (r.outcome !== "accepted") throw new Error(`expected accepted, got ${r.outcome}`); + } + + await waitFor(async () => calls.length === 3); + const byId = (id: string) => calls.find((c) => (c.event as { id: string }).id === id); + expect(byId("evt_start")?.isSessionStart).toBe(true); // matches startOn + expect(byId("evt_other")?.isSessionStart).toBe(false); // does not match startOn + expect(byId("evt_open")?.isSessionStart).toBe(true); // no startOn -> default true + } finally { + await engine.quit(); + } + } +); + +containerTestWithIsolatedRedisNoClickhouse( + "a skipped session result is recorded FILTERED with the reason and no run", + async ({ prisma, redisOptions }) => { + const endpoint = await createSessionEndpoint( + prisma, + "{body.id}", + "event.type == 'session.start'" + ); + const { triggerTask } = makeTriggerTaskStub(); + // Stand in for the webapp: resume-only (no session yet) + not a start event -> skipped. + const calls: DeliverWebhookToSessionParams[] = []; + const deliverToSession: DeliverWebhookToSessionCallback = async (params) => { + calls.push(params); + if (!params.isSessionStart) { + return { + success: true, + skipped: true, + skippedReason: "startOn: not a session-start event", + }; + } + return { success: true, runId: "srun_start" }; + }; + const engine = buildEngine(prisma, redisOptions, triggerTask, { deliverToSession }); + + try { + const skip = await engine.ingest(signedInput("evt_skip", endpoint.opaqueId, "message")); + const start = await engine.ingest(signedInput("evt_go", endpoint.opaqueId, "session.start")); + if (skip.outcome !== "accepted" || start.outcome !== "accepted") + throw new Error("expected accepted"); + + await waitFor(async () => { + const a = await prisma.webhookDelivery.findFirst({ where: { id: skip.deliveryId } }); + const b = await prisma.webhookDelivery.findFirst({ where: { id: start.deliveryId } }); + return a?.status === "FILTERED" && b?.status === "SUCCEEDED"; + }); + + const skipped = await prisma.webhookDelivery.findFirst({ where: { id: skip.deliveryId } }); + expect(skipped?.status).toBe("FILTERED"); + expect(skipped?.filterReason).toContain("startOn"); + expect(skipped?.runId).toBeNull(); + + const started = await prisma.webhookDelivery.findFirst({ where: { id: start.deliveryId } }); + expect(started?.runId).toBe("srun_start"); + } finally { + await engine.quit(); + } + } +); + +containerTestWithIsolatedRedisNoClickhouse( + "session delivery fails when the deliverToSession port is not configured", + async ({ prisma, redisOptions }) => { + const endpoint = await createSessionEndpoint(prisma, "{body.id}"); + const { triggerTask } = makeTriggerTaskStub(); + const engine = buildEngine(prisma, redisOptions, triggerTask); // no deliverToSession port + + try { + const result = await engine.ingest(signedInput("evt_sess_noport", endpoint.opaqueId)); + if (result.outcome !== "accepted") + throw new Error(`expected accepted, got ${result.outcome}`); + + await waitFor(async () => { + const d = await prisma.webhookDelivery.findFirst({ where: { id: result.deliveryId } }); + return d?.status === "FAILED"; + }); + + const d = await prisma.webhookDelivery.findFirst({ where: { id: result.deliveryId } }); + expect(d?.errorMessage).toContain("not configured"); + } finally { + await engine.quit(); + } + } +); + +containerTestWithIsolatedRedisNoClickhouse( + "a delivery whose port keeps failing transiently is FAILED after max attempts (not stuck PENDING)", + async ({ prisma, redisOptions }) => { + const endpoint = await createEndpoint(prisma); + let calls = 0; + const triggerTask: TriggerWebhookTaskCallback = async () => { + calls++; + return { success: false, errorType: "SYSTEM_ERROR", error: "boom" }; + }; + const engine = buildEngine(prisma, redisOptions, triggerTask); + + try { + const result = await engine.ingest(signedInput("evt_exhaust", endpoint.opaqueId)); + if (result.outcome !== "accepted") + throw new Error(`expected accepted, got ${result.outcome}`); + + // 5 attempts with backoff (~1+2+4+8s), then the final attempt marks FAILED instead of PENDING. + await waitFor(async () => { + const d = await prisma.webhookDelivery.findFirst({ where: { id: result.deliveryId } }); + return d?.status === "FAILED"; + }, 40_000); + + const d = await prisma.webhookDelivery.findFirst({ where: { id: result.deliveryId } }); + expect(d?.status).toBe("FAILED"); + expect(d?.errorMessage).toContain("boom"); + expect(calls).toBe(5); // maxAttempts; not left retrying / stuck PENDING + } finally { + await engine.quit(); + } + } +); + +containerTestWithIsolatedRedisNoClickhouse( + "simulateInject skips verification: an unsigned body that ingest() rejects is still recorded + routed", + async ({ prisma, redisOptions }) => { + const endpoint = await createEndpoint(prisma); + const { triggerTask, calls, runsByKey } = makeTriggerTaskStub(); + const engine = buildEngine(prisma, redisOptions, triggerTask); + + try { + const eventId = "evt_simulate_1"; + const body = JSON.stringify({ id: eventId, type: "payment_intent.succeeded" }); + const unsignedInput = { + opaqueId: endpoint.opaqueId, + rawBytes: new TextEncoder().encode(body), + headers: {}, + url: `https://api.example.com/webhooks/v1/ingest/${endpoint.opaqueId}`, + }; + + const rejected = await engine.ingest(unsignedInput); + expect(rejected.outcome).toBe("verification_failed"); + + const simulated = await engine.simulateInject(unsignedInput); + expect(simulated.outcome).toBe("accepted"); + if (simulated.outcome !== "accepted") return; + + await waitFor(async () => { + const d = await prisma.webhookDelivery.findFirst({ where: { id: simulated.deliveryId } }); + return d?.status === "SUCCEEDED"; + }); + + const delivery = await prisma.webhookDelivery.findFirst({ + where: { id: simulated.deliveryId }, + }); + expect(delivery?.runId).toBe("run_1"); + expect(delivery?.externalDeliveryId).toBe(eventId); + expect(delivery?.errorMessage).toBeNull(); + + expect(calls).toHaveLength(1); + expect(runsByKey.size).toBe(1); + expect(calls[0].taskId).toBe("handle-stripe-task"); + expect(calls[0].idempotencyKey).toBe(`${endpoint.id}:${eventId}`); + } finally { + await engine.quit(); + } + } +); + +containerTestWithIsolatedRedisNoClickhouse( + "simulateInject honors the endpoint filter: a non-matching event is recorded FILTERED, not routed", + async ({ prisma, redisOptions }) => { + const endpoint = await createEndpoint(prisma, { + filter: "event.type == 'payment_intent.succeeded'", + }); + const { triggerTask, calls } = makeTriggerTaskStub(); + const engine = buildEngine(prisma, redisOptions, triggerTask); + + try { + const body = JSON.stringify({ id: "evt_simulate_filtered", type: "charge.refunded" }); + const simulated = await engine.simulateInject({ + opaqueId: endpoint.opaqueId, + rawBytes: new TextEncoder().encode(body), + headers: {}, + url: `https://api.example.com/webhooks/v1/ingest/${endpoint.opaqueId}`, + }); + expect(simulated.outcome).toBe("accepted"); + if (simulated.outcome !== "accepted") return; + + const delivery = await prisma.webhookDelivery.findFirst({ + where: { id: simulated.deliveryId }, + }); + expect(delivery?.status).toBe("FILTERED"); + expect(calls).toHaveLength(0); + } finally { + await engine.quit(); + } + } +); diff --git a/internal-packages/webhook-engine/src/engine/partitions.test.ts b/internal-packages/webhook-engine/src/engine/partitions.test.ts new file mode 100644 index 0000000000..fbc1288d13 --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/partitions.test.ts @@ -0,0 +1,116 @@ +import { postgresTest } from "@internal/testcontainers"; +import type { PrismaClient } from "@trigger.dev/database"; +import { expect } from "vitest"; +import { + addDays, + createPartition, + dayBucket, + detachPartitionConcurrently, + dropPartition, + ensurePartitions, + floorDayUTC, + listDatedPartitions, + partitionExists, + partitionName, + recoverInterruptedDetaches, +} from "./partitions.js"; + +// `prisma db push` builds WebhookDelivery as a plain table (partitioning lives only in the migration +// SQL), so recreate it as the partitioned parent (no DEFAULT, matching the migration) before +// exercising the in-app partition manager. +async function makePartitioned(prisma: PrismaClient) { + await prisma.$executeRawUnsafe(`DROP TABLE IF EXISTS "WebhookDelivery" CASCADE`); + await prisma.$executeRawUnsafe(` + CREATE TABLE "WebhookDelivery" ( + "id" TEXT NOT NULL, + "friendlyId" TEXT NOT NULL, + "webhookEndpointId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "runtimeEnvironmentId" TEXT NOT NULL, + "environmentType" "RuntimeEnvironmentType" NOT NULL, + "externalDeliveryId" TEXT NOT NULL, + "idempotencyKey" TEXT NOT NULL, + "runId" TEXT, + "status" "WebhookDeliveryStatus" NOT NULL DEFAULT 'PENDING', + "parsedEvent" JSONB, + "headers" JSONB, + "rawBodyHash" TEXT, + "errorMessage" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "processedAt" TIMESTAMP(3), + CONSTRAINT "WebhookDelivery_pkey" PRIMARY KEY ("id","createdAt") + ) PARTITION BY RANGE ("createdAt")`); + await prisma.$executeRawUnsafe( + `CREATE INDEX "WebhookDelivery_webhookEndpointId_createdAt_idx" ON "WebhookDelivery"("webhookEndpointId","createdAt" DESC)` + ); +} + +postgresTest("ensurePartitions creates the window and is idempotent", async ({ prisma }) => { + await makePartitioned(prisma); + const now = new Date(Date.UTC(2026, 6, 15)); + + const first = await ensurePartitions(prisma, { now, lookaheadDays: 7, retentionDays: 3 }); + // retention(3) back .. lookahead(7) forward, inclusive of both ends = 11 day buckets. + expect(first.created).toHaveLength(11); + expect(await partitionExists(prisma, partitionName(floorDayUTC(now)))).toBe(true); + + const second = await ensurePartitions(prisma, { now, lookaheadDays: 7, retentionDays: 3 }); + expect(second.created).toHaveLength(0); + expect(second.existing).toHaveLength(11); +}); + +postgresTest("createPartition is created-then-exists", async ({ prisma }) => { + await makePartitioned(prisma); + const bucket = dayBucket(new Date(Date.UTC(2026, 7, 1))); + + expect(await createPartition(prisma, bucket)).toBe("created"); + expect(await createPartition(prisma, bucket)).toBe("exists"); + expect(await partitionExists(prisma, bucket.name)).toBe(true); +}); + +postgresTest("detachPartitionConcurrently then drop removes a partition", async ({ prisma }) => { + await makePartitioned(prisma); + const bucket = dayBucket(new Date(Date.UTC(2026, 0, 1))); + await createPartition(prisma, bucket); + expect(await partitionExists(prisma, bucket.name)).toBe(true); + + await detachPartitionConcurrently(prisma, bucket.name); + await dropPartition(prisma, bucket.name); + expect(await partitionExists(prisma, bucket.name)).toBe(false); +}); + +postgresTest("ensurePartitions drops children past the retention window", async ({ prisma }) => { + await makePartitioned(prisma); + const now = new Date(Date.UTC(2026, 6, 15)); + + const old = dayBucket(addDays(floorDayUTC(now), -30)); + await createPartition(prisma, old); + expect(await partitionExists(prisma, old.name)).toBe(true); + + const result = await ensurePartitions(prisma, { now, lookaheadDays: 7, retentionDays: 3 }); + expect(result.dropped).toContain(old.name); + expect(result.deferred).toHaveLength(0); + expect(await partitionExists(prisma, old.name)).toBe(false); + expect((await listDatedPartitions(prisma)).some((p) => p.name === old.name)).toBe(false); +}); + +postgresTest( + "recoverInterruptedDetaches drops a detached-but-not-dropped leftover", + async ({ prisma }) => { + await makePartitioned(prisma); + // A crash between DETACH and DROP leaves a standalone dated table that's no longer a partition. + await prisma.$executeRawUnsafe( + `CREATE TABLE "WebhookDelivery_2020_01_01" (LIKE "WebhookDelivery")` + ); + expect(await partitionExists(prisma, "WebhookDelivery_2020_01_01")).toBe(true); + // Not a partition, so the manager doesn't list it among dated partitions. + expect( + (await listDatedPartitions(prisma)).some((p) => p.name === "WebhookDelivery_2020_01_01") + ).toBe(false); + + await recoverInterruptedDetaches(prisma); + expect(await partitionExists(prisma, "WebhookDelivery_2020_01_01")).toBe(false); + } +); diff --git a/internal-packages/webhook-engine/src/engine/partitions.ts b/internal-packages/webhook-engine/src/engine/partitions.ts new file mode 100644 index 0000000000..399f6cce8d --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/partitions.ts @@ -0,0 +1,245 @@ +import type { WebhookDatabase } from "@trigger.dev/database"; + +// Two identifier forms for the PascalCase Prisma table name. DDL must DOUBLE-QUOTE +// (Postgres folds unquoted identifiers to lowercase); pg_class.relname stores the +// bare case-preserved name, so catalog lookups bind the bare form. +export const PARENT_DDL = `"WebhookDelivery"`; +export const PARENT_NAME = `WebhookDelivery`; + +// --------------------------------------------------------------------------- +// Day-bucket math (everything in UTC, matching how the migration writes bounds) +// --------------------------------------------------------------------------- + +export type Bucket = { lo: Date; hi: Date; name: string }; // name bare, quoted at SQL build + +export function floorDayUTC(d: Date): Date { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); +} + +export function addDays(d: Date, n: number): Date { + return new Date(d.getTime() + n * 86_400_000); +} + +export function partitionName(lo: Date): string { + const y = lo.getUTCFullYear(); + const m = String(lo.getUTCMonth() + 1).padStart(2, "0"); + const day = String(lo.getUTCDate()).padStart(2, "0"); + return `WebhookDelivery_${y}_${m}_${day}`; // bare Prisma table name +} + +export function dayBucket(lo: Date): Bucket { + const floored = floorDayUTC(lo); + const hi = addDays(floored, 1); + return { lo: floored, hi, name: partitionName(floored) }; +} + +/** All day buckets covering [start, end] inclusive of the day containing end. */ +export function dayBuckets(start: Date, end: Date): Bucket[] { + const out: Bucket[] = []; + let cur = floorDayUTC(start); + const last = floorDayUTC(end); + while (cur.getTime() <= last.getTime()) { + out.push(dayBucket(cur)); + cur = addDays(cur, 1); + } + return out; +} + +// Identifiers are built only from our own YYYY_MM_DD naming, never user input, so +// inlining them (and the bound literals, which Postgres requires as constants in DDL) +// is safe. We still assert the shape as a belt-and-braces guard. Caller quotes: `"${safeName(b.name)}"`. +function safeName(name: string): string { + if (!/^WebhookDelivery_\d{4}_\d{2}_\d{2}$/.test(name)) { + throw new Error(`refusing to use unexpected partition name: ${name}`); + } + return name; +} + +// --------------------------------------------------------------------------- +// Create / detach / drop primitives +// --------------------------------------------------------------------------- + +export async function partitionExists(prisma: WebhookDatabase, name: string): Promise { + // to_regclass takes a quoted identifier string so it preserves the PascalCase. Cast to text: + // Prisma's query engine can't deserialize the raw regclass OID type. + const r = await prisma.$queryRawUnsafe<{ oid: string | null }[]>( + `SELECT to_regclass($1)::text AS oid`, + `"${safeName(name)}"` + ); + return r[0]?.oid != null; +} + +/** + * Create one dated child as a true PARTITION OF the parent (inherits the parent indexes, no + * validating scan). There is no DEFAULT partition, so the lookahead window MUST stay ahead of + * ingest: an insert whose createdAt has no matching child errors instead of landing in a default. + */ +export async function createPartition( + prisma: WebhookDatabase, + b: Bucket +): Promise<"created" | "exists"> { + if (await partitionExists(prisma, b.name)) return "exists"; + const name = `"${safeName(b.name)}"`; + await prisma.$executeRawUnsafe( + `CREATE TABLE ${name} PARTITION OF ${PARENT_DDL} ` + + `FOR VALUES FROM ('${b.lo.toISOString()}') TO ('${b.hi.toISOString()}')` + ); + return "created"; +} + +/** + * Detach a child without blocking ingest: CONCURRENTLY takes only SHARE UPDATE EXCLUSIVE on the + * parent (no conflict with the ROW EXCLUSIVE that inserts hold) and waits just for in-flight txns + * to finish. It MUST run outside a transaction block, so it goes out as a standalone statement. + */ +export async function detachPartitionConcurrently( + prisma: WebhookDatabase, + name: string +): Promise { + await prisma.$executeRawUnsafe( + `ALTER TABLE ${PARENT_DDL} DETACH PARTITION "${safeName(name)}" CONCURRENTLY` + ); +} + +/** Drop a (now standalone, post-detach) table. With no partition attachment it takes no parent lock. */ +export async function dropPartition(prisma: WebhookDatabase, name: string): Promise { + await prisma.$executeRawUnsafe(`DROP TABLE IF EXISTS "${safeName(name)}"`); +} + +/** + * Clean up a detach a crash left half-done, so the next retention pass can proceed: + * (a) a child stuck "pending detach" (interrupted mid-CONCURRENTLY) is FINALIZEd then dropped — + * Postgres allows only one pending detach at a time, so this must run before any new detach; + * (b) a child fully detached but not yet dropped is a standalone leftover table to drop. + */ +export async function recoverInterruptedDetaches(prisma: WebhookDatabase): Promise { + const pending = await prisma.$queryRawUnsafe<{ name: string }[]>( + `SELECT c.relname::text AS name FROM pg_inherits i + JOIN pg_class c ON c.oid = i.inhrelid + JOIN pg_class p ON p.oid = i.inhparent + WHERE p.relname = $1 AND i.inhdetachpending`, + PARENT_NAME + ); + for (const { name } of pending) { + await prisma.$executeRawUnsafe( + `ALTER TABLE ${PARENT_DDL} DETACH PARTITION "${safeName(name)}" FINALIZE` + ); + await dropPartition(prisma, name); + } + + const leftovers = await prisma.$queryRawUnsafe<{ name: string }[]>( + `SELECT c.relname::text AS name FROM pg_class c + WHERE c.relkind = 'r' + AND c.relname ~ '^WebhookDelivery_[0-9]{4}_[0-9]{2}_[0-9]{2}$' + AND NOT EXISTS (SELECT 1 FROM pg_inherits i WHERE i.inhrelid = c.oid)` + ); + for (const { name } of leftovers) { + await dropPartition(prisma, name); + } +} + +// --------------------------------------------------------------------------- +// The in-app manager (what the @internal/webhook-engine ensurePartitions cron runs) +// --------------------------------------------------------------------------- + +export type EnsureOptions = { + now: Date; + lookaheadDays: number; // pre-create this many days ahead (7 to 14); MUST stay ahead of ingest + retentionDays: number; // keep this many days back; drop colder children +}; + +export type EnsureResult = { + created: string[]; + dropped: string[]; + existing: string[]; + deferred: string[]; // children we couldn't detach/drop this run; retried next run +}; + +export async function ensurePartitions( + prisma: WebhookDatabase, + opts: EnsureOptions +): Promise { + const today = floorDayUTC(opts.now); + const start = addDays(today, -opts.retentionDays); + const end = addDays(today, opts.lookaheadDays); + + const result: EnsureResult = { created: [], dropped: [], existing: [], deferred: [] }; + + for (const b of dayBuckets(start, end)) { + const outcome = await createPartition(prisma, b); + if (outcome === "created") result.created.push(b.name); + else result.existing.push(b.name); + } + + // Finish any detach a prior run left half-done, then concurrently detach + drop each dated child + // whose whole range is older than the retention window. CONCURRENTLY keeps ingest unblocked; a + // child we can't process now (e.g. a transient error) is left for the next run. + await recoverInterruptedDetaches(prisma); + for (const child of await listDatedPartitions(prisma)) { + if (child.hi.getTime() <= start.getTime()) { + try { + await detachPartitionConcurrently(prisma, child.name); + await dropPartition(prisma, child.name); + result.dropped.push(child.name); + } catch { + result.deferred.push(child.name); + } + } + } + + return result; +} + +// --------------------------------------------------------------------------- +// Introspection +// --------------------------------------------------------------------------- + +export type PartitionInfo = { + name: string; + bound: string; + approxRows: number; + bytes: number; + lo?: Date; + hi?: Date; +}; + +export async function listPartitions(prisma: WebhookDatabase): Promise { + const rows = await prisma.$queryRawUnsafe< + { name: string; bound: string; approx_rows: bigint; bytes: bigint }[] + >( + `SELECT c.relname::text AS name, + pg_get_expr(c.relpartbound, c.oid) AS bound, + c.reltuples::bigint AS approx_rows, + pg_total_relation_size(c.oid) AS bytes + FROM pg_inherits i + JOIN pg_class c ON c.oid = i.inhrelid + JOIN pg_class p ON p.oid = i.inhparent + WHERE p.relname = $1 + ORDER BY c.relname`, + PARENT_NAME + ); + return rows.map((row) => { + let lo: Date | undefined; + let hi: Date | undefined; + const m = row.name.match(/^WebhookDelivery_(\d{4})_(\d{2})_(\d{2})$/); + if (m) { + lo = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))); + hi = addDays(lo, 1); + } + return { + name: row.name, + bound: row.bound, + approxRows: Number(row.approx_rows), + bytes: Number(row.bytes), + lo, + hi, + }; + }); +} + +export async function listDatedPartitions( + prisma: WebhookDatabase +): Promise>[]> { + const all = await listPartitions(prisma); + return all.filter((p) => p.lo && p.hi).map((p) => ({ name: p.name, lo: p.lo!, hi: p.hi! })); +} diff --git a/internal-packages/webhook-engine/src/engine/sessionKey.test.ts b/internal-packages/webhook-engine/src/engine/sessionKey.test.ts new file mode 100644 index 0000000000..7f3bd3d5bc --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/sessionKey.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { evaluateSessionKeyTemplate } from "./sessionKey.js"; + +const meta = { + externalRef: "inst_42", + tenantId: "tnt_1", + id: "github-issues", + source: "github", + deliveryId: "dlv_abc", +}; + +const ns = (body: unknown, header: Record = {}) => ({ + body, + webhook: meta, + header, +}); + +describe("evaluateSessionKeyTemplate", () => { + it("resolves a nested body path", () => { + expect(evaluateSessionKeyTemplate("{body.issue.id}", ns({ issue: { id: 99 } }))).toBe("99"); + }); + + it("treats a bare path as the body namespace", () => { + expect(evaluateSessionKeyTemplate("{issue.id}", ns({ issue: { id: 7 } }))).toBe("7"); + }); + + it("resolves webhook meta and composes literals", () => { + expect( + evaluateSessionKeyTemplate("{webhook.externalRef}-{body.issue.id}", ns({ issue: { id: 5 } })) + ).toBe("inst_42-5"); + }); + + it("matches headers case-insensitively", () => { + expect( + evaluateSessionKeyTemplate("{header.x-installation}", ns({}, { "X-Installation": "abc" })) + ).toBe("abc"); + }); + + it("resolves a composite across all three namespaces", () => { + expect( + evaluateSessionKeyTemplate( + "{webhook.tenantId}/{header.x-inst}/{body.issue.id}", + ns({ issue: { id: 3 } }, { "x-inst": "i9" }) + ) + ).toBe("tnt_1/i9/3"); + }); + + it("returns undefined when a body path is missing", () => { + expect( + evaluateSessionKeyTemplate("{body.issue.nope}", ns({ issue: { id: 1 } })) + ).toBeUndefined(); + }); + + it("returns undefined for an unknown webhook meta field", () => { + expect(evaluateSessionKeyTemplate("{webhook.installationId}", ns({}))).toBeUndefined(); + }); + + it("returns undefined for a missing header", () => { + expect( + evaluateSessionKeyTemplate("{header.x-nope}", ns({}, { "x-inst": "i9" })) + ).toBeUndefined(); + }); + + it("returns undefined when a value is empty string", () => { + expect(evaluateSessionKeyTemplate("{body.region}", ns({ region: "" }))).toBeUndefined(); + }); + + it("coerces numbers and booleans (including 0/false) to strings", () => { + expect(evaluateSessionKeyTemplate("{body.n}-{body.b}", ns({ n: 0, b: false }))).toBe("0-false"); + }); + + it("falls back to the next path with || when the first is empty (Slack thread_ts || ts)", () => { + const template = "{body.channel}:{body.thread_ts || body.ts}"; + // reply: thread_ts present -> used + expect( + evaluateSessionKeyTemplate(template, ns({ channel: "C1", thread_ts: "t1", ts: "m2" })) + ).toBe("C1:t1"); + // thread start: no thread_ts -> falls back to ts + expect(evaluateSessionKeyTemplate(template, ns({ channel: "C1", ts: "m2" }))).toBe("C1:m2"); + }); + + it("uses the first non-empty across a || chain, else undefined", () => { + expect(evaluateSessionKeyTemplate("{body.a || body.b || body.c}", ns({ c: "z" }))).toBe("z"); + expect(evaluateSessionKeyTemplate("{body.a || body.b}", ns({}))).toBeUndefined(); + }); +}); diff --git a/internal-packages/webhook-engine/src/engine/sessionKey.ts b/internal-packages/webhook-engine/src/engine/sessionKey.ts new file mode 100644 index 0000000000..b1803deed8 --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/sessionKey.ts @@ -0,0 +1,54 @@ +// Runtime evaluation of a session routing key template. The template ({body.x}/{webhook.x}/{header.x}) +// is compiled from the SDK's session.webhook `key` at deploy-sync; here we resolve it against a live +// delivery to produce the session externalId. Returns undefined if any placeholder is missing/empty, +// so the engine can fail the delivery rather than route it to a garbage session key. + +export type SessionKeyNamespaces = { + body: unknown; // the parsed event body + webhook: Record; // endpoint meta: externalRef, tenantId, id, source, deliveryId + header: Record; // inbound request headers (matched case-insensitively) +}; + +export function evaluateSessionKeyTemplate( + template: string, + ns: SessionKeyNamespaces +): string | undefined { + let ok = true; + const out = template.replace(/\{([^}]+)\}/g, (_match, raw: string) => { + // A placeholder may list fallbacks: `{a || b || c}` resolves to the first non-empty path (so a + // Slack thread-start with no thread_ts falls back to ts). Missing/empty everywhere fails the key. + for (const path of raw.split("||")) { + const value = resolveKeyPath(path.trim(), ns); + if (value !== undefined && value !== null && value !== "") return String(value); + } + ok = false; + return ""; + }); + return ok ? out : undefined; +} + +function resolveKeyPath(path: string, ns: SessionKeyNamespaces): unknown { + if (path.startsWith("webhook.")) { + return ns.webhook[path.slice("webhook.".length)]; + } + if (path.startsWith("header.")) { + const name = path.slice("header.".length).toLowerCase(); + for (const [key, value] of Object.entries(ns.header)) { + if (key.toLowerCase() === name) return value; + } + return undefined; + } + // body. prefix or a bare path both resolve against the event body. + const dotted = path.startsWith("body.") ? path.slice("body.".length) : path; + return walkPath(ns.body, dotted); +} + +// Resolve a dotted path over an arbitrary object (shared with the ingest handshake hook). +export function walkPath(root: unknown, dotted: string): unknown { + let current: unknown = root; + for (const segment of dotted.split(".")) { + if (current === null || typeof current !== "object") return undefined; + current = (current as Record)[segment]; + } + return current; +} diff --git a/internal-packages/webhook-engine/src/engine/signing/index.ts b/internal-packages/webhook-engine/src/engine/signing/index.ts new file mode 100644 index 0000000000..82e56bf03e --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/signing/index.ts @@ -0,0 +1,193 @@ +import type { + WebhookHmacConfig, + WebhookSharedSecretConfig, + WebhookUrlSecretConfig, + WebhookValueSource, + WebhookVerifierConfig, +} from "@trigger.dev/core/v3"; +import { randomUUID } from "node:crypto"; +import { readPath, tryParseJson } from "../verification/derive.js"; +import { buildSigningBytes, deriveHmacKey, hmacDigest } from "../verification/util.js"; + +export type SignResult = + | { ok: true; headers: Record; url: string; body: Uint8Array } + | { ok: false; notSignable: boolean; error: string }; + +export type SignArgs = { + config: WebhookVerifierConfig; + secret: string; + rawBody: Uint8Array; + url: string; + headers?: Record; + nowMs?: number; +}; + +/** + * Produce a request the engine verifier accepts under the same config + secret. The inverse of + * `verification/`: it owns both the signed value and its placement, so re-parsing the request it + * builds yields byte-identical signing bytes and a matching signature. Asymmetric and url-secret + * `path` placement return `{ ok: false, notSignable: true }` (simulate-only downstream). + */ +export function signWithVerifierConfig(args: SignArgs): SignResult { + const { config } = args; + switch (config.scheme) { + case "hmac": + return signHmac(config, args); + case "shared-secret": + return signSharedSecret(config, args); + case "url-secret": + return signUrlSecret(config, args); + case "asymmetric": + return { + ok: false, + notSignable: true, + error: + "asymmetric signatures cannot be produced (only the public key is held); use unsigned/tampered or simulate mode", + }; + } +} + +function lowerCaseHeaders(headers: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(headers)) out[k.toLowerCase()] = v; + return out; +} + +function readStringSource( + source: WebhookValueSource, + args: SignArgs, + headersLc: Record +): string | undefined { + switch (source.from) { + case "header": + return headersLc[source.name.toLowerCase()]; + case "url": + return args.url; + case "constant": + return source.value; + case "body": { + const parsed = tryParseJson(args.rawBody).parsedEvent as Record | undefined; + const v = readPath(parsed, source.path); + return typeof v === "string" || typeof v === "number" ? String(v) : undefined; + } + case "signatureField": + return undefined; + } +} + +function signHmac(cfg: WebhookHmacConfig, args: SignArgs): SignResult { + const headers: Record = { ...(args.headers ?? {}) }; + const headersLc = lowerCaseHeaders(headers); + const now = args.nowMs ?? Date.now(); + + let timestampValue = ""; + if (cfg.timestamp) { + const src = cfg.timestamp.source; + if (src.from === "header" || src.from === "signatureField") { + timestampValue = + cfg.timestamp.unit === "milliseconds" ? String(now) : String(Math.floor(now / 1000)); + if (src.from === "header") { + headers[src.name] = timestampValue; + headersLc[src.name.toLowerCase()] = timestampValue; + } + } else { + timestampValue = readStringSource(src, args, headersLc) ?? ""; + } + } + + let signingBytes: Uint8Array; + if (cfg.signingString === "raw") { + signingBytes = args.rawBody; + } else { + const vars: Record = { timestamp: timestampValue }; + for (const [name, source] of Object.entries(cfg.signingString.vars ?? {})) { + let v: string | undefined; + if (source.from === "header") { + v = headersLc[source.name.toLowerCase()] ?? randomUUID(); + headers[source.name] = v; + headersLc[source.name.toLowerCase()] = v; + } else { + v = readStringSource(source, args, headersLc); + } + vars[name] = v ?? ""; + } + signingBytes = buildSigningBytes(cfg.signingString.template, args.rawBody, vars); + } + + const digest = hmacDigest( + cfg.algorithm, + deriveHmacKey(args.secret, cfg.secret), + signingBytes, + cfg.encoding + ); + headers[cfg.signatureHeader] = buildSignatureHeader(cfg, digest, timestampValue); + return { ok: true, headers, url: args.url, body: args.rawBody }; +} + +/** Inverse of `parseSignatureHeader`: assemble the element(s) the verifier will split back out. */ +function buildSignatureHeader( + cfg: WebhookHmacConfig, + digest: string, + timestampValue: string +): string { + const ex = cfg.signature; + const elements: string[] = []; + if (cfg.timestamp?.source.from === "signatureField" && ex?.fieldSeparator) { + elements.push(`${cfg.timestamp.source.field}${ex.fieldSeparator}${timestampValue}`); + } + if (ex?.field && ex.fieldSeparator) { + elements.push(`${ex.field}${ex.fieldSeparator}${digest}`); + } else { + elements.push(digest); + } + return elements.join(ex?.itemSeparator ?? ""); +} + +function signSharedSecret(cfg: WebhookSharedSecretConfig, args: SignArgs): SignResult { + const headers: Record = { ...(args.headers ?? {}) }; + let body = args.rawBody; + switch (cfg.placement) { + case "header": + headers[cfg.fieldName ?? ""] = args.secret; + break; + case "bearer": + headers["authorization"] = `Bearer ${args.secret}`; + break; + case "basic": + headers["authorization"] = `Basic ${Buffer.from(`:${args.secret}`).toString("base64")}`; + break; + case "body": { + const parsed = tryParseJson(args.rawBody).parsedEvent; + if (parsed == null || typeof parsed !== "object") { + return { + ok: false, + notSignable: false, + error: "body placement requires a JSON object body", + }; + } + const next = { ...(parsed as Record), [cfg.fieldName ?? ""]: args.secret }; + body = new TextEncoder().encode(JSON.stringify(next)); + break; + } + } + return { ok: true, headers, url: args.url, body }; +} + +function signUrlSecret(cfg: WebhookUrlSecretConfig, args: SignArgs): SignResult { + if (cfg.placement === "path") { + return { + ok: false, + notSignable: true, + error: + "url-secret path placement cannot be represented on the fixed ingress URL; use simulate mode", + }; + } + const url = new URL(args.url); + url.searchParams.set(cfg.paramName, args.secret); + return { + ok: true, + headers: { ...(args.headers ?? {}) }, + url: url.toString(), + body: args.rawBody, + }; +} diff --git a/internal-packages/webhook-engine/src/engine/signing/signing.test.ts b/internal-packages/webhook-engine/src/engine/signing/signing.test.ts new file mode 100644 index 0000000000..2c7cac720b --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/signing/signing.test.ts @@ -0,0 +1,169 @@ +import type { + WebhookAsymmetricConfig, + WebhookHmacConfig, + WebhookSharedSecretConfig, + WebhookUrlSecretConfig, + WebhookVerifierConfig, +} from "@trigger.dev/core/v3"; +import { describe, expect, it } from "vitest"; +import { verify } from "../verification/index.js"; +import { signWithVerifierConfig } from "./index.js"; + +const INGRESS_URL = "https://example.com/webhooks/v1/ingest/opaque123"; +const NOW = 1783000000000; +const BODY = new TextEncoder().encode( + JSON.stringify({ id: "evt_123", event_id: "sqevt_123", type: "test.event" }) +); + +function roundTrip(config: WebhookVerifierConfig, secret: string) { + const signed = signWithVerifierConfig({ + config, + secret, + rawBody: BODY, + url: INGRESS_URL, + nowMs: NOW, + }); + if (!signed.ok) throw new Error(`expected signable: ${signed.error}`); + return verify( + { kind: "config", config }, + { rawBytes: signed.body, headers: signed.headers, url: signed.url, secret, nowMs: NOW } + ); +} + +const stripe: WebhookHmacConfig = { + scheme: "hmac", + algorithm: "sha256", + encoding: "hex", + signatureHeader: "stripe-signature", + signature: { itemSeparator: ",", fieldSeparator: "=", field: "v1" }, + timestamp: { source: { from: "signatureField", field: "t" }, toleranceSeconds: 300 }, + signingString: { template: "{timestamp}.{body}" }, + idempotencyField: { from: "body", name: "id" }, +}; + +const github: WebhookHmacConfig = { + scheme: "hmac", + algorithm: "sha256", + encoding: "hex", + signatureHeader: "x-hub-signature-256", + signature: { fieldSeparator: "=", field: "sha256" }, + signingString: "raw", + idempotencyField: { from: "header", name: "x-github-delivery" }, +}; + +const svix: WebhookHmacConfig = { + scheme: "hmac", + algorithm: "sha256", + encoding: "base64", + signatureHeader: "svix-signature", + signature: { itemSeparator: " ", fieldSeparator: ",", field: "v1" }, + timestamp: { source: { from: "header", name: "svix-timestamp" }, toleranceSeconds: 300 }, + signingString: { + template: "{id}.{timestamp}.{body}", + vars: { id: { from: "header", name: "svix-id" } }, + }, + secret: { encoding: "base64", stripPrefix: "whsec_" }, + idempotencyField: { from: "header", name: "svix-id" }, +}; + +const square: WebhookHmacConfig = { + scheme: "hmac", + algorithm: "sha256", + encoding: "base64", + signatureHeader: "x-square-hmacsha256-signature", + signature: {}, + signingString: { template: "{url}{body}", vars: { url: { from: "url" } } }, + idempotencyField: { from: "body", name: "event_id" }, +}; + +const discord: WebhookAsymmetricConfig = { + scheme: "asymmetric", + algorithm: "ed25519", + encoding: "hex", + signatureHeader: "x-signature-ed25519", + signature: {}, + timestamp: { source: { from: "header", name: "x-signature-timestamp" } }, + signingString: { template: "{timestamp}{body}" }, + publicKeyEncoding: "raw-hex", +}; + +const svixSecret = `whsec_${Buffer.from("svix-signing-key").toString("base64")}`; + +describe("signWithVerifierConfig round-trips through the verifier", () => { + it("custom hmac (raw body, bare signature) — the demo-webhook shape", () => { + const custom: WebhookHmacConfig = { + scheme: "hmac", + algorithm: "sha256", + encoding: "hex", + signatureHeader: "x-webhook-signature", + signature: {}, + signingString: "raw", + }; + expect(roundTrip(custom, "shhh-custom-secret").ok).toBe(true); + }); + + it("stripe (hex, t=..,v1=.. with signatureField timestamp)", () => { + expect(roundTrip(stripe, "whsec_stripe_test").ok).toBe(true); + }); + + it("github (prefixed sha256=, raw body)", () => { + expect(roundTrip(github, "github_integrator_secret").ok).toBe(true); + }); + + it("svix (base64 v1,, header timestamp + id var, whsec base64 secret)", () => { + expect(roundTrip(svix, svixSecret).ok).toBe(true); + }); + + it("square (bare base64 over {url}{body})", () => { + expect(roundTrip(square, "square_signature_key").ok).toBe(true); + }); + + it("shared-secret header / bearer / basic / body all verify", () => { + const placements: WebhookSharedSecretConfig["placement"][] = [ + "header", + "bearer", + "basic", + "body", + ]; + for (const placement of placements) { + const config: WebhookSharedSecretConfig = { + scheme: "shared-secret", + placement, + fieldName: + placement === "header" ? "x-api-key" : placement === "body" ? "apiKey" : undefined, + }; + expect(roundTrip(config, "the-shared-secret").ok, `placement=${placement}`).toBe(true); + } + }); + + it("url-secret query verifies", () => { + const config: WebhookUrlSecretConfig = { + scheme: "url-secret", + placement: "query", + paramName: "token", + }; + expect(roundTrip(config, "url-secret-value").ok).toBe(true); + }); + + it("url-secret path is not signable (fixed ingress path)", () => { + const config: WebhookUrlSecretConfig = { + scheme: "url-secret", + placement: "path", + paramName: "token", + }; + const r = signWithVerifierConfig({ config, secret: "x", rawBody: BODY, url: INGRESS_URL }); + expect(r.ok).toBe(false); + expect(r.ok === false && r.notSignable).toBe(true); + }); + + it("asymmetric (discord) is not signable (only the public key is held)", () => { + const r = signWithVerifierConfig({ + config: discord, + secret: "deadbeef", + rawBody: BODY, + url: INGRESS_URL, + }); + expect(r.ok).toBe(false); + expect(r.ok === false && r.notSignable).toBe(true); + }); +}); diff --git a/internal-packages/webhook-engine/src/engine/types.ts b/internal-packages/webhook-engine/src/engine/types.ts new file mode 100644 index 0000000000..2787660915 --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/types.ts @@ -0,0 +1,117 @@ +import type { Logger } from "@trigger.dev/core/logger"; +import type { Meter, Tracer } from "@internal/tracing"; +import type { WebhookDatabase } from "@trigger.dev/database"; +import type { RedisOptions } from "@internal/redis"; + +export type WebhookDeliverTaskErrorType = "QUEUE_LIMIT" | "SYSTEM_ERROR" | "NOT_FOUND"; + +export type TriggerWebhookTaskParams = { + environmentId: string; + taskId: string; + idempotencyKey: string; // = externalDeliveryId; the Run Engine correctness gate + idempotencyKeyExpiresAt: Date; // provider retry window + payload: unknown; // delivery.parsedEvent, already JSON + headers: Record; // inbound request headers -> onEvent({ headers }) + identityTags: string[]; // endpoint identity -> run tags + endpointMetadata: unknown; // endpoint.metadata -> run metadata +}; + +export interface TriggerWebhookTaskCallback { + (params: TriggerWebhookTaskParams): Promise<{ + success: boolean; + runId?: string; // persisted onto WebhookDelivery.runId on success + error?: string; + errorType?: WebhookDeliverTaskErrorType; + }>; +} + +export interface WebhookEngineOptions { + logger?: Logger; + logLevel?: string; + prisma: WebhookDatabase; + redis: RedisOptions; + /** + * When true the feature is fully off: the engine skips opening its Redis clients and building the + * worker, so a deployment with webhooks disabled holds no connections and does no queue polling. + * Distinct from `worker.disabled`, which keeps the engine (and its front-gate Redis) for ingress + * but does not start the worker loop. + */ + disabled?: boolean; + worker: { + concurrency: number; + workers?: number; + tasksPerWorker?: number; + pollIntervalMs?: number; + shutdownTimeoutMs?: number; + disabled?: boolean; + }; + partitions?: { + ensureSchedule?: string; + ensureJitterInMs?: number; + lookaheadDays?: number; // 7..14; how many days ahead to pre-create + retentionDays?: number; // keep this many days back; drop colder children + }; + tracer?: Tracer; + meter?: Meter; + frontGate?: { defaultTtlSeconds?: number; maxTtlSeconds?: number; claimTtlSeconds?: number }; + // Hot-path cache for the endpoint + resolved signing secret, keyed by opaqueId. ttlMs <= 0 disables. + endpointCache?: { ttlMs?: number; maxSize?: number }; + triggerTask: TriggerWebhookTaskCallback; + // Q4: injected so the engine never imports the webapp SecretStore. Returns the + // plaintext signing secret, or undefined/empty so ingest fails closed. + resolveSigningSecret: (key: string) => Promise; + // Session routing: find-or-create the session on the resolved key and append the action envelope. + deliverToSession?: DeliverWebhookToSessionCallback; +} + +export type DeliverWebhookToSessionParams = { + environmentId: string; + taskIdentifier: string; // the claiming agent; the session's task + externalId: string; // resolved from the routing target's keyTemplate + deliverAs: "action" | "message"; // "action" -> onAction envelope; "message" -> a channel turn + actionType?: string; // becomes the action envelope's `type` (deliverAs "action") + connectorId?: string; // the channel connector id (deliverAs "message"); the run resolves inbound by it + event: unknown; // delivery.parsedEvent + source: string; // provider tag + headers: Record; + deliveryId: string; // externalDeliveryId; also the S2 part id for idempotent re-append + triggerConfigTemplate?: Record; + idempotencyKey: string; + // Evaluated startOn: true (default) allows creating a new session; false means resume-only, so a + // key with no existing session is ignored rather than started. + isSessionStart: boolean; +}; + +export interface DeliverWebhookToSessionCallback { + (params: DeliverWebhookToSessionParams): Promise<{ + success: boolean; + runId?: string; // the session's current run, persisted onto WebhookDelivery.runId + error?: string; + errorType?: WebhookDeliverTaskErrorType; + skipped?: boolean; // resume-only and no session existed: recorded FILTERED, not routed + skippedReason?: string; + }>; +} + +export type IngestInput = { + opaqueId: string; // Q2: globally unique, so ingest resolves the endpoint (and its env id + type) from it + rawBytes: Uint8Array; + headers: Record; + url: string; +}; + +export type ReplayResult = + | { outcome: "replayed"; deliveryId: string; deliveryFriendlyId: string } // new row + run enqueued + | { outcome: "delivery_not_found" } + | { outcome: "endpoint_not_found" } + | { outcome: "unsupported_target" }; // routing target isn't a task + +export type IngestResult = + | { outcome: "accepted"; deliveryId: string; deliveryFriendlyId: string } + | { outcome: "handshake"; body: string } // provider handshake (Slack url_verification) -> 200 echo + | { outcome: "duplicate"; deliveryId?: string } // front-gate hit -> 200 + | { outcome: "endpoint_not_found" } // -> 404 + | { outcome: "endpoint_inactive" } // -> 404 + | { outcome: "secret_missing" } // -> 400 (fail-closed) + | { outcome: "verification_failed"; error: string } // -> 400 + | { outcome: "enqueue_failed"; error: string }; // -> 5xx (provider retries) diff --git a/internal-packages/webhook-engine/src/engine/verification/asymmetric.ts b/internal-packages/webhook-engine/src/engine/verification/asymmetric.ts new file mode 100644 index 0000000000..2bfb45bda0 --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/verification/asymmetric.ts @@ -0,0 +1,89 @@ +import type { WebhookAsymmetricConfig as AsymmetricConfig } from "@trigger.dev/core/v3"; +import { createPublicKey, verify as cryptoVerify, type KeyObject } from "node:crypto"; +import { deriveIdempotencyKey, tryParseJson } from "./derive.js"; +import { prepareSignedVerification } from "./parse.js"; +import type { SchemeVerifier, VerifierResult, VerifyInput } from "./types.js"; +import { decodeSignature } from "./util.js"; + +// SPKI DER prefix for an Ed25519 public key (RFC 8410): 12-byte header + 32 raw key bytes. +// Lets us accept a raw Ed25519 key (e.g. Discord's hex public key) without a PEM wrapper. +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +function importPublicKey( + stored: string, + encoding: AsymmetricConfig["publicKeyEncoding"], + algorithm: AsymmetricConfig["algorithm"] +): KeyObject { + const enc = encoding ?? "pem"; + if (enc === "pem") return createPublicKey(stored); + if (enc === "spki-der-base64") { + return createPublicKey({ key: Buffer.from(stored, "base64"), format: "der", type: "spki" }); + } + // Raw key bytes. Only Ed25519 has a fixed-size raw key we can wrap into SPKI here. + const raw = Buffer.from(stored, enc === "raw-hex" ? "hex" : "base64"); + if (algorithm === "ed25519") { + return createPublicKey({ + key: Buffer.concat([ED25519_SPKI_PREFIX, raw]), + format: "der", + type: "spki", + }); + } + throw new Error("raw public key encoding is only supported for ed25519"); +} + +export const asymmetricVerifier: SchemeVerifier = { + scheme: "asymmetric", + verify(config, input): VerifierResult { + const cfg = config as AsymmetricConfig; + + const prep = prepareSignedVerification(cfg, input); + if (!prep.ok) return fail(prep.error, cfg, input); + + let key: KeyObject; + try { + key = importPublicKey(input.secret, cfg.publicKeyEncoding, cfg.algorithm); + } catch { + return fail("invalid public key", cfg, input); + } + + // Ed25519 is PureEdDSA (no prehash) → algorithm null; ECDSA/RSA hash with sha256. + const nodeAlgorithm = cfg.algorithm === "ed25519" ? null : "sha256"; + + const matched = prep.signatures.some((sig) => { + try { + return cryptoVerify( + nodeAlgorithm, + prep.signingBytes, + key, + decodeSignature(sig, cfg.encoding) + ); + } catch { + return false; + } + }); + if (!matched) return fail("signature mismatch", cfg, input); + + const idempotencyKey = deriveIdempotencyKey({ + idempotencyField: cfg.idempotencyField, + headers: input.headers, + rawBytes: input.rawBytes, + timestampValue: prep.timestampValue, + signatureValue: prep.signatureValue, + }); + return { ok: true, idempotencyKey, ...tryParseJson(input.rawBytes) }; + }, +}; + +function fail(error: string, cfg: AsymmetricConfig, input: VerifyInput): VerifierResult { + return { + ok: false, + error, + idempotencyKey: deriveIdempotencyKey({ + idempotencyField: cfg.idempotencyField, + headers: input.headers, + rawBytes: input.rawBytes, + timestampValue: "", + signatureValue: "", + }), + }; +} diff --git a/internal-packages/webhook-engine/src/engine/verification/derive.ts b/internal-packages/webhook-engine/src/engine/verification/derive.ts new file mode 100644 index 0000000000..88417cecbb --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/verification/derive.ts @@ -0,0 +1,60 @@ +import { sha256Hex } from "./util.js"; + +export function deriveIdempotencyKey(args: { + idempotencyField?: { from: "header" | "body"; name: string }; // Q9 + headers: Record; + rawBytes: Uint8Array; + timestampValue: string; + signatureValue: string; + formPayloadField?: string; +}): string { + if (args.idempotencyField) { + const { from, name } = args.idempotencyField; + const v = + from === "header" + ? args.headers[name.toLowerCase()] + : readPath( + parseEventBody(args.rawBytes, { formPayloadField: args.formPayloadField }) + .parsedEvent as Record | undefined, + name + ); + if (typeof v === "string" && v.length > 0) return v; + } + const composite = `${sha256Hex(args.rawBytes)}\n${args.timestampValue}\n${args.signatureValue}`; + return sha256Hex(composite); +} + +export function readPath(obj: unknown, path: string): unknown { + return path.split(".").reduce((acc, k) => (acc == null ? acc : acc[k]), obj); +} + +/** + * Parse the verified body into the routed event. Tries JSON first. When that fails and a + * `formPayloadField` is configured, decodes the body as `application/x-www-form-urlencoded` and + * JSON-parses that field's value (Slack interactivity posts `payload=`). The signature was + * already checked over the raw bytes, so this only affects the parsed event, never verification. + */ +export function parseEventBody( + rawBytes: Uint8Array, + opts?: { formPayloadField?: string } +): { parsedEvent?: unknown; error?: string } { + const text = new TextDecoder().decode(rawBytes); + try { + return { parsedEvent: JSON.parse(text) }; + } catch { + void 0; + } + if (opts?.formPayloadField) { + try { + const raw = new URLSearchParams(text).get(opts.formPayloadField); + if (raw != null) return { parsedEvent: JSON.parse(raw) }; + } catch { + void 0; + } + } + return { error: "verified body is not valid JSON" }; +} + +export function tryParseJson(rawBytes: Uint8Array): { parsedEvent?: unknown; error?: string } { + return parseEventBody(rawBytes); +} diff --git a/internal-packages/webhook-engine/src/engine/verification/hmac.ts b/internal-packages/webhook-engine/src/engine/verification/hmac.ts new file mode 100644 index 0000000000..048b78a166 --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/verification/hmac.ts @@ -0,0 +1,54 @@ +import type { WebhookHmacConfig as HmacConfig } from "@trigger.dev/core/v3"; +import type { SchemeVerifier, VerifierResult, VerifyInput } from "./types.js"; +import { hmacDigest, deriveHmacKey, constantTimeEqual } from "./util.js"; +import { deriveIdempotencyKey, parseEventBody } from "./derive.js"; +import { prepareSignedVerification } from "./parse.js"; + +export const hmacVerifier: SchemeVerifier = { + scheme: "hmac", + verify(config, input): VerifierResult { + const cfg = config as HmacConfig; + + // Shared, config-driven parsing: extract signature candidate(s) from the header + // (Stripe `t=,v1=`, GitHub `sha256=`, Svix `v1,` lists, …), validate the timestamp, + // and build the exact signed bytes. No per-provider code beyond this. + const prep = prepareSignedVerification(cfg, input); + if (!prep.ok) return fail(prep.error, cfg, input); + + const key = deriveHmacKey(input.secret, cfg.secret); + const expected = hmacDigest(cfg.algorithm, key, prep.signingBytes, cfg.encoding); + + // Accept if ANY extracted candidate matches (signature rotation / multi-sig lists). + const matched = prep.signatures.some((sig) => constantTimeEqual(sig, expected)); + if (!matched) return fail("signature mismatch", cfg, input); + + const idempotencyKey = deriveIdempotencyKey({ + idempotencyField: cfg.idempotencyField, + headers: input.headers, + rawBytes: input.rawBytes, + timestampValue: prep.timestampValue, + signatureValue: prep.signatureValue, + formPayloadField: cfg.formPayload?.field, + }); + return { + ok: true, + idempotencyKey, + ...parseEventBody(input.rawBytes, { formPayloadField: cfg.formPayload?.field }), + }; + }, +}; + +function fail(error: string, cfg: HmacConfig, input: VerifyInput): VerifierResult { + return { + ok: false, + error, + idempotencyKey: deriveIdempotencyKey({ + idempotencyField: cfg.idempotencyField, + headers: input.headers, + rawBytes: input.rawBytes, + timestampValue: "", + signatureValue: "", + formPayloadField: cfg.formPayload?.field, + }), + }; +} diff --git a/internal-packages/webhook-engine/src/engine/verification/index.ts b/internal-packages/webhook-engine/src/engine/verification/index.ts new file mode 100644 index 0000000000..198a0b8369 --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/verification/index.ts @@ -0,0 +1,48 @@ +import type { + SchemeVerifier, + VerifierArtifact, + VerifierConfig, + VerifierResult, + VerifyInput, +} from "./types.js"; +import { hmacVerifier } from "./hmac.js"; +import { asymmetricVerifier } from "./asymmetric.js"; +import { sharedSecretVerifier } from "./sharedSecret.js"; +import { urlSecretVerifier } from "./urlSecret.js"; + +const SCHEME_REGISTRY: Record = { + hmac: hmacVerifier, + "shared-secret": sharedSecretVerifier, + "url-secret": urlSecretVerifier, + asymmetric: asymmetricVerifier, +}; + +function normalizeHeaders(headers: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(headers)) out[k.toLowerCase()] = v; + return out; +} + +export function verify(artifact: VerifierArtifact, input: VerifyInput): VerifierResult { + const normalized: VerifyInput = { ...input, headers: normalizeHeaders(input.headers) }; + + switch (artifact.kind) { + case "config": + case "preset": { + const config = artifact.config; + const verifier = SCHEME_REGISTRY[config.scheme]; + if (!verifier) { + throw new Error(`No verifier registered for scheme "${config.scheme}"`); + } + return verifier.verify(config, normalized); + } + case "bundle": + throw new Error("Bundle verifiers are not available in this version (P3 seam)"); + default: { + const _exhaustive: never = artifact; + throw new Error(`Unknown verifier artifact kind`); + } + } +} + +export { type VerifyInput, type VerifierResult } from "./types.js"; diff --git a/internal-packages/webhook-engine/src/engine/verification/parse.ts b/internal-packages/webhook-engine/src/engine/verification/parse.ts new file mode 100644 index 0000000000..70eaa2a779 --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/verification/parse.ts @@ -0,0 +1,136 @@ +import type { + WebhookSignatureExtraction, + WebhookSigningString, + WebhookTimestampConfig, + WebhookValueSource, +} from "@trigger.dev/core/v3"; +import { readPath, tryParseJson } from "./derive.js"; +import type { VerifyInput } from "./types.js"; +import { buildSigningBytes } from "./util.js"; + +// The subset of a verifier config shared by every signed scheme (hmac, asymmetric). +export type SignedConfig = { + signatureHeader: string; + signature?: WebhookSignatureExtraction; + timestamp?: WebhookTimestampConfig; + signingString: WebhookSigningString; +}; + +export type PreparedVerification = + | { ok: false; error: string } + | { + ok: true; + signatures: string[]; // candidate signatures; the scheme accepts ANY match + signingBytes: Uint8Array; + timestampValue: string; + signatureValue: string; // first candidate, for the idempotency fallback hash + }; + +// Parse one signature header into (a) candidate signature strings and (b) a field map for +// signatureField lookups (e.g. Stripe `t`). See WebhookSignatureExtraction for the shapes. +export function parseSignatureHeader( + headerValue: string, + extraction?: WebhookSignatureExtraction +): { signatures: string[]; fields: Map } { + const ex = extraction ?? {}; + const rawItems = ex.itemSeparator ? headerValue.split(ex.itemSeparator) : [headerValue]; + const items = ex.trim ? rawItems.map((s) => s.trim()) : rawItems; + + const fields = new Map(); + const signatures: string[] = []; + + for (const item of items) { + if (ex.fieldSeparator) { + const idx = item.indexOf(ex.fieldSeparator); + if (idx === -1) continue; // malformed element, skip + const name = item.slice(0, idx); + const value = item.slice(idx + ex.fieldSeparator.length); + const arr = fields.get(name) ?? []; + arr.push(value); + fields.set(name, arr); + if (ex.field && name === ex.field) signatures.push(value); + } else { + // No field separator → the whole element IS the signature (bare value). + signatures.push(item); + } + } + + return { signatures, fields }; +} + +type ResolveContext = { + headers: Record; + fields: Map; + rawBytes: Uint8Array; + url: string; +}; + +function resolveValueSource(source: WebhookValueSource, ctx: ResolveContext): string | undefined { + switch (source.from) { + case "header": + return ctx.headers[source.name.toLowerCase()]; + case "signatureField": + return ctx.fields.get(source.field)?.[0]; + case "body": { + const parsed = tryParseJson(ctx.rawBytes).parsedEvent as Record | undefined; + const v = readPath(parsed, source.path); + return typeof v === "string" || typeof v === "number" ? String(v) : undefined; + } + case "url": + return ctx.url; + case "constant": + return source.value; + } +} + +// Shared pre-verification: pull the signature(s) out of the header, validate the timestamp +// (replay window), and build the exact bytes to be signed. The scheme-specific code then only +// computes/compares the signature. No per-provider branching lives here — it's all config. +export function prepareSignedVerification( + config: SignedConfig, + input: VerifyInput +): PreparedVerification { + const rawHeader = input.headers[config.signatureHeader.toLowerCase()]; + if (!rawHeader) return { ok: false, error: "missing signature header" }; + + const { signatures, fields } = parseSignatureHeader(rawHeader, config.signature); + if (signatures.length === 0) return { ok: false, error: "no signature found in header" }; + + const ctx: ResolveContext = { + headers: input.headers, + fields, + rawBytes: input.rawBytes, + url: input.url, + }; + + let timestampValue = ""; + if (config.timestamp) { + const v = resolveValueSource(config.timestamp.source, ctx); + if (!v) return { ok: false, error: "missing timestamp" }; + const n = Number(v); + if (Number.isNaN(n)) return { ok: false, error: "unparseable timestamp" }; + timestampValue = v; + if (config.timestamp.toleranceSeconds !== undefined) { + const tsSeconds = config.timestamp.unit === "milliseconds" ? n / 1000 : n; + const nowS = (input.nowMs ?? Date.now()) / 1000; + if (Math.abs(nowS - tsSeconds) > config.timestamp.toleranceSeconds) { + return { ok: false, error: "timestamp outside tolerance" }; + } + } + } + + let signingBytes: Uint8Array; + if (config.signingString === "raw") { + signingBytes = input.rawBytes; + } else { + const vars: Record = { timestamp: timestampValue }; + if (config.signingString.vars) { + for (const [name, source] of Object.entries(config.signingString.vars)) { + vars[name] = resolveValueSource(source, ctx) ?? ""; + } + } + signingBytes = buildSigningBytes(config.signingString.template, input.rawBytes, vars); + } + + return { ok: true, signatures, signingBytes, timestampValue, signatureValue: signatures[0] }; +} diff --git a/internal-packages/webhook-engine/src/engine/verification/sharedSecret.ts b/internal-packages/webhook-engine/src/engine/verification/sharedSecret.ts new file mode 100644 index 0000000000..01618d5a1a --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/verification/sharedSecret.ts @@ -0,0 +1,64 @@ +import type { WebhookSharedSecretConfig as SharedSecretConfig } from "@trigger.dev/core/v3"; +import type { SchemeVerifier, VerifierResult, VerifyInput } from "./types.js"; +import { constantTimeEqual } from "./util.js"; +import { deriveIdempotencyKey, tryParseJson } from "./derive.js"; + +export const sharedSecretVerifier: SchemeVerifier = { + scheme: "shared-secret", + verify(config, input): VerifierResult { + const cfg = config as Extract; + const provided = extractCandidate(cfg, input); + if (provided === undefined) return failSS("missing shared secret", cfg, input); + if (!constantTimeEqual(provided, input.secret)) + return failSS("shared secret mismatch", cfg, input); + const idempotencyKey = deriveIdempotencyKey({ + idempotencyField: cfg.idempotencyField, + headers: input.headers, + rawBytes: input.rawBytes, + timestampValue: "", + signatureValue: provided, + }); + return { ok: true, idempotencyKey, ...tryParseJson(input.rawBytes) }; + }, +}; + +function extractCandidate(cfg: any, input: VerifyInput): string | undefined { + switch (cfg.placement) { + case "header": + return input.headers[(cfg.fieldName ?? "").toLowerCase()] || undefined; + case "bearer": { + const h = input.headers["authorization"] ?? ""; + return h.startsWith("Bearer ") ? h.slice(7) : undefined; + } + case "basic": { + const h = input.headers["authorization"] ?? ""; + if (!h.startsWith("Basic ")) return undefined; + const decoded = Buffer.from(h.slice(6), "base64").toString("utf8"); + const idx = decoded.indexOf(":"); + return idx === -1 ? decoded : decoded.slice(idx + 1); // password segment + } + case "body": { + const parsed = tryParseJson(input.rawBytes).parsedEvent as + | Record + | undefined; + const v = parsed?.[cfg.fieldName ?? ""]; + return typeof v === "string" ? v : undefined; + } + default: + return undefined; + } +} + +function failSS(error: string, cfg: any, input: VerifyInput): VerifierResult { + return { + ok: false, + error, + idempotencyKey: deriveIdempotencyKey({ + idempotencyField: cfg.idempotencyField, + headers: input.headers, + rawBytes: input.rawBytes, + timestampValue: "", + signatureValue: "", + }), + }; +} diff --git a/internal-packages/webhook-engine/src/engine/verification/types.ts b/internal-packages/webhook-engine/src/engine/verification/types.ts new file mode 100644 index 0000000000..0cb553004c --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/verification/types.ts @@ -0,0 +1,20 @@ +import type { + WebhookVerifierArtifact as VerifierArtifact, + WebhookVerifierConfig as VerifierConfig, + WebhookVerifierResult as VerifierResult, +} from "@trigger.dev/core/v3"; + +export type VerifyInput = { + rawBytes: Uint8Array; + headers: Record; // lower-cased keys, see gotcha + url: string; + secret: string; // plaintext signing secret, fail-closed upstream (never empty here) + nowMs?: number; // injectable clock for deterministic timestamp-tolerance tests +}; + +export interface SchemeVerifier { + readonly scheme: VerifierConfig["scheme"]; + verify(config: VerifierConfig, input: VerifyInput): VerifierResult; +} + +export type { VerifierArtifact, VerifierConfig, VerifierResult }; diff --git a/internal-packages/webhook-engine/src/engine/verification/urlSecret.ts b/internal-packages/webhook-engine/src/engine/verification/urlSecret.ts new file mode 100644 index 0000000000..8ad49011da --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/verification/urlSecret.ts @@ -0,0 +1,38 @@ +import type { WebhookUrlSecretConfig as UrlSecretConfig } from "@trigger.dev/core/v3"; +import type { SchemeVerifier, VerifierResult, VerifyInput } from "./types.js"; +import { constantTimeEqual } from "./util.js"; +import { deriveIdempotencyKey, tryParseJson } from "./derive.js"; + +export const urlSecretVerifier: SchemeVerifier = { + scheme: "url-secret", + verify(config, input): VerifierResult { + const cfg = config as Extract; + const u = new URL(input.url); + let provided: string | undefined; + if (cfg.placement === "query") { + provided = u.searchParams.get(cfg.paramName) ?? undefined; + } else { + const segs = u.pathname.split("/").filter(Boolean); + provided = segs[segs.length - 1]; + } + if (!provided) + return { ok: false, error: "missing url secret", idempotencyKey: derive0(cfg, input) }; + if (!constantTimeEqual(provided, input.secret)) + return { ok: false, error: "url secret mismatch", idempotencyKey: derive0(cfg, input) }; + return { + ok: true, + idempotencyKey: derive0(cfg, input, provided), + ...tryParseJson(input.rawBytes), + }; + }, +}; + +function derive0(cfg: any, input: VerifyInput, sig = ""): string { + return deriveIdempotencyKey({ + idempotencyField: undefined, + headers: input.headers, + rawBytes: input.rawBytes, + timestampValue: "", + signatureValue: sig, + }); +} diff --git a/internal-packages/webhook-engine/src/engine/verification/util.ts b/internal-packages/webhook-engine/src/engine/verification/util.ts new file mode 100644 index 0000000000..5e2fd3cca7 --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/verification/util.ts @@ -0,0 +1,61 @@ +import { createHmac, timingSafeEqual as nodeTimingSafeEqual, createHash } from "node:crypto"; + +// Constant-time compare. NEVER ===. Both sides first hashed to 32-byte sha256 so +// timingSafeEqual gets equal-length buffers (it throws on length mismatch). +export function constantTimeEqual(a: string, b: string): boolean { + const ha = createHash("sha256").update(a, "utf8").digest(); + const hb = createHash("sha256").update(b, "utf8").digest(); + return nodeTimingSafeEqual(ha, hb); +} + +export function hmacDigest( + algorithm: "sha256" | "sha1", + secret: string | Buffer, + signingBytes: Uint8Array, + encoding: "hex" | "base64" +): string { + return createHmac(algorithm, secret).update(signingBytes).digest(encoding); +} + +// The HMAC key bytes from the stored secret. Most providers use the secret verbatim (utf8); +// Svix-family secrets are `whsec_` → strip the prefix then base64-decode to raw bytes. +export function deriveHmacKey( + secret: string, + transform?: { encoding?: "utf8" | "base64"; stripPrefix?: string } +): Buffer { + let s = secret; + if (transform?.stripPrefix && s.startsWith(transform.stripPrefix)) { + s = s.slice(transform.stripPrefix.length); + } + return Buffer.from(s, transform?.encoding === "base64" ? "base64" : "utf8"); +} + +export function decodeSignature(value: string, encoding: "hex" | "base64"): Buffer { + return Buffer.from(value, encoding); +} + +export function sha256Hex(input: string | Uint8Array): string { + return createHash("sha256").update(input).digest("hex"); +} + +// Build the signing bytes for a templated signing string ("{timestamp}.{body}") +// without ever turning the raw body into a string (preserve exact bytes). +export function buildSigningBytes( + template: string, + rawBytes: Uint8Array, + vars: Record +): Uint8Array { + const BODY = "{body}"; + const idx = template.indexOf(BODY); + const substitute = (s: string) => s.replace(/\{(\w+)\}/g, (_m, k) => vars[k] ?? ""); + if (idx === -1) { + return new TextEncoder().encode(substitute(template)); + } + const head = new TextEncoder().encode(substitute(template.slice(0, idx))); + const tail = new TextEncoder().encode(substitute(template.slice(idx + BODY.length))); + const out = new Uint8Array(head.length + rawBytes.length + tail.length); + out.set(head, 0); + out.set(rawBytes, head.length); + out.set(tail, head.length + rawBytes.length); + return out; +} diff --git a/internal-packages/webhook-engine/src/engine/verification/verify.test.ts b/internal-packages/webhook-engine/src/engine/verification/verify.test.ts new file mode 100644 index 0000000000..f2b0b5fc6a --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/verification/verify.test.ts @@ -0,0 +1,350 @@ +import { createHmac, generateKeyPairSync, sign as cryptoSign, type KeyObject } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { verify } from "./index.js"; +import type { VerifyInput } from "./types.js"; + +// Real signatures computed with node:crypto, exercised through the config-driven verifier. +// This is the deferred M5 suite, now grounded in the actual provider header shapes. + +const NOW_MS = 1_700_000_000_000; // fixed clock so timestamp-tolerance is deterministic +const NOW_S = Math.floor(NOW_MS / 1000); + +function input( + over: Partial & { rawBytes: Uint8Array; headers: Record } +): VerifyInput { + return { + url: "https://api.example.com/webhooks/v1/ingest/abc", + secret: "whsec_test_secret", + nowMs: NOW_MS, + ...over, + }; +} + +function bytes(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +const cfg = (config: object) => ({ kind: "config" as const, config: config as any }); + +describe("hmac verifier (config-driven)", () => { + it("verifies a Stripe-style t=,v1= combined header", () => { + const body = JSON.stringify({ id: "evt_123", type: "payment_intent.succeeded" }); + const sig = createHmac("sha256", "whsec_test_secret").update(`${NOW_S}.${body}`).digest("hex"); + const result = verify( + cfg({ + scheme: "hmac", + algorithm: "sha256", + encoding: "hex", + signatureHeader: "stripe-signature", + signature: { itemSeparator: ",", fieldSeparator: "=", field: "v1" }, + timestamp: { source: { from: "signatureField", field: "t" }, toleranceSeconds: 300 }, + signingString: { template: "{timestamp}.{body}" }, + idempotencyField: { from: "body", name: "id" }, + }), + input({ rawBytes: bytes(body), headers: { "stripe-signature": `t=${NOW_S},v1=${sig}` } }) + ); + expect(result.ok).toBe(true); + expect(result.idempotencyKey).toBe("evt_123"); + }); + + it("decodes a form-encoded Slack interactivity body via formPayload", () => { + const interaction = { type: "block_actions", actions: [{ value: "call-1::approve" }] }; + const formBody = `payload=${encodeURIComponent(JSON.stringify(interaction))}`; + const sig = createHmac("sha256", "whsec_test_secret") + .update(`v0:${NOW_S}:${formBody}`) + .digest("hex"); + const result = verify( + cfg({ + scheme: "hmac", + algorithm: "sha256", + encoding: "hex", + signatureHeader: "x-slack-signature", + signature: { fieldSeparator: "=", field: "v0" }, + timestamp: { + source: { from: "header", name: "x-slack-request-timestamp" }, + toleranceSeconds: 300, + }, + signingString: { template: "v0:{timestamp}:{body}" }, + formPayload: { field: "payload" }, + }), + input({ + rawBytes: bytes(formBody), + headers: { "x-slack-signature": `v0=${sig}`, "x-slack-request-timestamp": String(NOW_S) }, + }) + ); + expect(result.ok).toBe(true); + expect(result.parsedEvent).toEqual(interaction); + }); + + it("rejects a Stripe signature outside the tolerance window", () => { + const body = "{}"; + const staleT = NOW_S - 10_000; + const sig = createHmac("sha256", "whsec_test_secret").update(`${staleT}.${body}`).digest("hex"); + const result = verify( + cfg({ + scheme: "hmac", + algorithm: "sha256", + encoding: "hex", + signatureHeader: "stripe-signature", + signature: { itemSeparator: ",", fieldSeparator: "=", field: "v1" }, + timestamp: { source: { from: "signatureField", field: "t" }, toleranceSeconds: 300 }, + signingString: { template: "{timestamp}.{body}" }, + }), + input({ rawBytes: bytes(body), headers: { "stripe-signature": `t=${staleT},v1=${sig}` } }) + ); + expect(result.ok).toBe(false); + }); + + it("accepts any v1 candidate during Stripe key rotation", () => { + const body = "{}"; + const good = createHmac("sha256", "whsec_test_secret").update(`${NOW_S}.${body}`).digest("hex"); + const result = verify( + cfg({ + scheme: "hmac", + algorithm: "sha256", + encoding: "hex", + signatureHeader: "stripe-signature", + signature: { itemSeparator: ",", fieldSeparator: "=", field: "v1" }, + timestamp: { source: { from: "signatureField", field: "t" }, toleranceSeconds: 300 }, + signingString: { template: "{timestamp}.{body}" }, + }), + input({ + rawBytes: bytes(body), + headers: { "stripe-signature": `t=${NOW_S},v1=deadbeef,v1=${good}` }, + }) + ); + expect(result.ok).toBe(true); + }); + + it("verifies a GitHub-style sha256= prefixed header over the raw body", () => { + const body = JSON.stringify({ action: "opened" }); + const sig = createHmac("sha256", "whsec_test_secret").update(body).digest("hex"); + const result = verify( + cfg({ + scheme: "hmac", + algorithm: "sha256", + encoding: "hex", + signatureHeader: "x-hub-signature-256", + signature: { fieldSeparator: "=", field: "sha256" }, + signingString: "raw", + idempotencyField: { from: "header", name: "x-github-delivery" }, + }), + input({ + rawBytes: bytes(body), + headers: { "x-hub-signature-256": `sha256=${sig}`, "x-github-delivery": "guid-1" }, + }) + ); + expect(result.ok).toBe(true); + expect(result.idempotencyKey).toBe("guid-1"); + }); + + it("rejects a tampered body", () => { + const sig = createHmac("sha256", "whsec_test_secret").update("{}").digest("hex"); + const result = verify( + cfg({ + scheme: "hmac", + algorithm: "sha256", + encoding: "hex", + signatureHeader: "x-hub-signature-256", + signature: { fieldSeparator: "=", field: "sha256" }, + signingString: "raw", + }), + input({ + rawBytes: bytes('{"tampered":true}'), + headers: { "x-hub-signature-256": `sha256=${sig}` }, + }) + ); + expect(result.ok).toBe(false); + }); + + it("verifies a Svix-style space-list, base64 secret, {id}.{ts}.{body}", () => { + // Svix secret is whsec_; the HMAC key is the base64-decoded remainder. + const keyBytes = Buffer.from("c2VjcmV0LWtleS1ieXRlcw==", "base64"); + const secret = "whsec_c2VjcmV0LWtleS1ieXRlcw=="; + const id = "msg_123"; + const body = JSON.stringify({ hello: "world" }); + const sig = createHmac("sha256", keyBytes).update(`${id}.${NOW_S}.${body}`).digest("base64"); + const result = verify( + cfg({ + scheme: "hmac", + algorithm: "sha256", + encoding: "base64", + signatureHeader: "svix-signature", + signature: { itemSeparator: " ", fieldSeparator: ",", field: "v1" }, + timestamp: { source: { from: "header", name: "svix-timestamp" }, toleranceSeconds: 300 }, + signingString: { + template: "{id}.{timestamp}.{body}", + vars: { id: { from: "header", name: "svix-id" } }, + }, + secret: { encoding: "base64", stripPrefix: "whsec_" }, + idempotencyField: { from: "header", name: "svix-id" }, + }), + input({ + rawBytes: bytes(body), + secret, + headers: { + "svix-id": id, + "svix-timestamp": String(NOW_S), + "svix-signature": `v1,${sig}`, + }, + }) + ); + expect(result.ok).toBe(true); + expect(result.idempotencyKey).toBe(id); + }); + + it("verifies a Square-style {url}{body} signature", () => { + const url = "https://api.example.com/webhooks/v1/ingest/abc"; + const body = JSON.stringify({ event_id: "sq_1" }); + const sig = createHmac("sha256", "whsec_test_secret").update(`${url}${body}`).digest("base64"); + const result = verify( + cfg({ + scheme: "hmac", + algorithm: "sha256", + encoding: "base64", + signatureHeader: "x-square-hmacsha256-signature", + signature: {}, + signingString: { template: "{url}{body}", vars: { url: { from: "url" } } }, + idempotencyField: { from: "body", name: "event_id" }, + }), + input({ rawBytes: bytes(body), url, headers: { "x-square-hmacsha256-signature": sig } }) + ); + expect(result.ok).toBe(true); + expect(result.idempotencyKey).toBe("sq_1"); + }); +}); + +describe("asymmetric verifier (config-driven)", () => { + it("verifies a Discord-style Ed25519 signature with a raw-hex public key", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const rawPubHex = rawEd25519PublicKeyHex(publicKey); + const ts = String(NOW_S); + const body = JSON.stringify({ type: 1 }); + const sig = cryptoSign(null, Buffer.from(`${ts}${body}`), privateKey).toString("hex"); + const config = { + scheme: "asymmetric", + algorithm: "ed25519", + encoding: "hex", + signatureHeader: "x-signature-ed25519", + signature: {}, + timestamp: { source: { from: "header", name: "x-signature-timestamp" } }, + signingString: { template: "{timestamp}{body}" }, + publicKeyEncoding: "raw-hex", + }; + const ok = verify( + cfg(config), + input({ + rawBytes: bytes(body), + secret: rawPubHex, + headers: { "x-signature-ed25519": sig, "x-signature-timestamp": ts }, + }) + ); + expect(ok.ok).toBe(true); + + const tampered = verify( + cfg(config), + input({ + rawBytes: bytes('{"type":2}'), + secret: rawPubHex, + headers: { "x-signature-ed25519": sig, "x-signature-timestamp": ts }, + }) + ); + expect(tampered.ok).toBe(false); + }); + + it("verifies a SendGrid-style ECDSA P-256 signature with an SPKI-DER base64 public key", () => { + const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const spkiB64 = publicKey.export({ type: "spki", format: "der" }).toString("base64"); + const ts = String(NOW_S); + const body = JSON.stringify([{ sg_event_id: "e1" }]); + const sig = cryptoSign("sha256", Buffer.from(`${ts}${body}`), privateKey).toString("base64"); + const result = verify( + cfg({ + scheme: "asymmetric", + algorithm: "ecdsa-p256", + encoding: "base64", + signatureHeader: "x-twilio-email-event-webhook-signature", + signature: {}, + timestamp: { source: { from: "header", name: "x-twilio-email-event-webhook-timestamp" } }, + signingString: { template: "{timestamp}{body}" }, + publicKeyEncoding: "spki-der-base64", + }), + input({ + rawBytes: bytes(body), + secret: spkiB64, + headers: { + "x-twilio-email-event-webhook-signature": sig, + "x-twilio-email-event-webhook-timestamp": ts, + }, + }) + ); + expect(result.ok).toBe(true); + }); +}); + +describe("adversarial / fail-closed", () => { + const stripeConfig = { + scheme: "hmac", + algorithm: "sha256", + encoding: "hex", + signatureHeader: "stripe-signature", + signature: { itemSeparator: ",", fieldSeparator: "=", field: "v1" }, + timestamp: { source: { from: "signatureField", field: "t" }, toleranceSeconds: 300 }, + signingString: { template: "{timestamp}.{body}" }, + }; + + it("rejects a request with no signature header", () => { + const result = verify(cfg(stripeConfig), input({ rawBytes: bytes("{}"), headers: {} })); + expect(result.ok).toBe(false); + }); + + it("rejects when the signature field is absent (timestamp only)", () => { + const result = verify( + cfg(stripeConfig), + input({ rawBytes: bytes("{}"), headers: { "stripe-signature": `t=${NOW_S}` } }) + ); + expect(result.ok).toBe(false); + }); + + it("rejects a body signed with the wrong secret", () => { + const body = "{}"; + const forged = createHmac("sha256", "attacker-secret").update(`${NOW_S}.${body}`).digest("hex"); + const result = verify( + cfg(stripeConfig), + input({ rawBytes: bytes(body), headers: { "stripe-signature": `t=${NOW_S},v1=${forged}` } }) + ); + expect(result.ok).toBe(false); + }); + + it("rejects multiple forged signature candidates (no free pass from the list)", () => { + const result = verify( + cfg(stripeConfig), + input({ + rawBytes: bytes("{}"), + headers: { "stripe-signature": `t=${NOW_S},v1=deadbeef,v1=cafebabe` }, + }) + ); + expect(result.ok).toBe(false); + }); + + it("rejects an empty signature value", () => { + const result = verify( + cfg({ + scheme: "hmac", + algorithm: "sha256", + encoding: "hex", + signatureHeader: "x-hub-signature-256", + signature: { fieldSeparator: "=", field: "sha256" }, + signingString: "raw", + }), + input({ rawBytes: bytes("{}"), headers: { "x-hub-signature-256": "sha256=" } }) + ); + expect(result.ok).toBe(false); + }); +}); + +// Extract the raw 32-byte Ed25519 public key (last 32 bytes of the SPKI DER) as hex. +function rawEd25519PublicKeyHex(publicKey: KeyObject): string { + const der = publicKey.export({ type: "spki", format: "der" }); + return Buffer.from(der.subarray(der.length - 32)).toString("hex"); +} diff --git a/internal-packages/webhook-engine/src/engine/workerCatalog.ts b/internal-packages/webhook-engine/src/engine/workerCatalog.ts new file mode 100644 index 0000000000..ec6fa8f3bd --- /dev/null +++ b/internal-packages/webhook-engine/src/engine/workerCatalog.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; +import { CronSchema, type WorkerCatalog } from "@trigger.dev/redis-worker"; + +export const webhookWorkerCatalog = { + "webhook.deliver": { + schema: z.object({ + deliveryId: z.string(), + createdAt: z.coerce.date(), + }), + visibilityTimeoutMs: 60_000, + retry: { + maxAttempts: 5, + }, + }, + ensurePartitions: { + schema: CronSchema, + visibilityTimeoutMs: 60_000 * 5, + cron: "17 3 * * *", // daily 03:17 default; overridable from options.partitions.ensureSchedule + jitterInMs: 5 * 60_000, // up to 5m jitter; overridable from options.partitions.ensureJitterInMs + retry: { + maxAttempts: 1, // cron jobs do not retry (next tick re-runs) + }, + }, +} satisfies WorkerCatalog; diff --git a/internal-packages/webhook-engine/src/index.ts b/internal-packages/webhook-engine/src/index.ts new file mode 100644 index 0000000000..ba67a006c0 --- /dev/null +++ b/internal-packages/webhook-engine/src/index.ts @@ -0,0 +1,15 @@ +export { WebhookEngine } from "./engine/index.js"; +export { parseFilter, FilterParseError } from "./engine/filter/index.js"; +export { signWithVerifierConfig } from "./engine/signing/index.js"; +export type { SignResult, SignArgs } from "./engine/signing/index.js"; +export { verify } from "./engine/verification/index.js"; +export type { VerifyInput, VerifierResult } from "./engine/verification/index.js"; +export type { + WebhookEngineOptions, + TriggerWebhookTaskParams, + TriggerWebhookTaskCallback, + WebhookDeliverTaskErrorType, + IngestInput, + IngestResult, + ReplayResult, +} from "./engine/types.js"; diff --git a/internal-packages/webhook-engine/test/setup.ts b/internal-packages/webhook-engine/test/setup.ts new file mode 100644 index 0000000000..b2bacd6baf --- /dev/null +++ b/internal-packages/webhook-engine/test/setup.ts @@ -0,0 +1,4 @@ +import { vi } from "vitest"; + +// Set extended timeout for container tests +vi.setConfig({ testTimeout: 60_000 }); diff --git a/internal-packages/webhook-engine/tsconfig.build.json b/internal-packages/webhook-engine/tsconfig.build.json new file mode 100644 index 0000000000..89c87a3dc6 --- /dev/null +++ b/internal-packages/webhook-engine/tsconfig.build.json @@ -0,0 +1,21 @@ +{ + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"], + "compilerOptions": { + "composite": true, + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable", "DOM.AsyncIterable"], + "outDir": "dist", + "module": "Node16", + "moduleResolution": "Node16", + "moduleDetection": "force", + "verbatimModuleSyntax": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "preserveWatchOutput": true, + "skipLibCheck": true, + "strict": true, + "declaration": true + } +} diff --git a/internal-packages/webhook-engine/tsconfig.json b/internal-packages/webhook-engine/tsconfig.json new file mode 100644 index 0000000000..af630abe1f --- /dev/null +++ b/internal-packages/webhook-engine/tsconfig.json @@ -0,0 +1,8 @@ +{ + "references": [{ "path": "./tsconfig.src.json" }, { "path": "./tsconfig.test.json" }], + "compilerOptions": { + "moduleResolution": "Node16", + "module": "Node16", + "customConditions": ["@triggerdotdev/source"] + } +} diff --git a/internal-packages/webhook-engine/tsconfig.src.json b/internal-packages/webhook-engine/tsconfig.src.json new file mode 100644 index 0000000000..0df3d2d222 --- /dev/null +++ b/internal-packages/webhook-engine/tsconfig.src.json @@ -0,0 +1,20 @@ +{ + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "src/**/*.test.ts"], + "compilerOptions": { + "composite": true, + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable", "DOM.AsyncIterable"], + "module": "Node16", + "moduleResolution": "Node16", + "moduleDetection": "force", + "verbatimModuleSyntax": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "preserveWatchOutput": true, + "skipLibCheck": true, + "strict": true, + "customConditions": ["@triggerdotdev/source"] + } +} diff --git a/internal-packages/webhook-engine/tsconfig.test.json b/internal-packages/webhook-engine/tsconfig.test.json new file mode 100644 index 0000000000..f4b838bd2e --- /dev/null +++ b/internal-packages/webhook-engine/tsconfig.test.json @@ -0,0 +1,20 @@ +{ + "include": ["src/**/*.test.ts"], + "references": [{ "path": "./tsconfig.src.json" }], + "compilerOptions": { + "composite": true, + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable", "DOM.AsyncIterable"], + "module": "Node16", + "moduleResolution": "Node16", + "moduleDetection": "force", + "verbatimModuleSyntax": false, + "types": ["vitest/globals"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "preserveWatchOutput": true, + "strict": true, + "customConditions": ["@triggerdotdev/source"] + } +} diff --git a/internal-packages/webhook-engine/vitest.config.ts b/internal-packages/webhook-engine/vitest.config.ts new file mode 100644 index 0000000000..daafd294fa --- /dev/null +++ b/internal-packages/webhook-engine/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vitest/config"; +import { DurationShardingSequencer } from "@internal/testcontainers/sequencer"; + +export default defineConfig({ + test: { + sequence: { sequencer: DurationShardingSequencer }, + globals: true, + retry: process.env.CI ? 2 : 0, + environment: "node", + setupFiles: ["./test/setup.ts"], + testTimeout: 30000, + hookTimeout: 30000, + }, + esbuild: { + target: "node18", + }, +}); diff --git a/internal-packages/webhook-sources/NOTICE.md b/internal-packages/webhook-sources/NOTICE.md new file mode 100644 index 0000000000..e127789978 --- /dev/null +++ b/internal-packages/webhook-sources/NOTICE.md @@ -0,0 +1,11 @@ +# Third-party attribution + +The bundled provider samples in `src/generated/hookdeck-samples.json` are ingested from the +[Hookdeck webhook-samples](https://github.com/hookdeck/webhook-samples) dataset, which is licensed +under the MIT License. + +The event bodies are normalized (transport and signature headers stripped, routing headers kept) +into this package's `SampleRecord` shape by `src/ingest.ts`. Signatures are not stored; the console +signs each body with the target endpoint's own config at send time. + +To refresh the snapshot, run `pnpm run ingest`. diff --git a/internal-packages/webhook-sources/catalog/build-aggregates.ts b/internal-packages/webhook-sources/catalog/build-aggregates.ts new file mode 100644 index 0000000000..939f4f254b --- /dev/null +++ b/internal-packages/webhook-sources/catalog/build-aggregates.ts @@ -0,0 +1,52 @@ +import { readdirSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const srcDir = fileURLToPath(new URL("../src/", import.meta.url)); + +const alias = (base: string) => base.replace(/[^a-zA-Z0-9]/g, "_"); + +const listModules = (dir: string, exclude: string[]) => + readdirSync(new URL(`../src/${dir}/`, import.meta.url)) + .filter((f) => f.endsWith(".ts") && !exclude.includes(f)) + .map((f) => f.replace(/\.ts$/, "")) + .sort(); + +const banner = "/** GENERATED by catalog/build-aggregates.ts. Do not edit by hand. */"; + +const registryModules = listModules("registry", ["types.ts", "index.ts"]); +const registryImports = registryModules + .map((m) => `import { entry as e_${alias(m)} } from "./${m}.js";`) + .join("\n"); +const registryList = registryModules.map((m) => `e_${alias(m)}`).join(", "); +const registryOut = `${banner} +import { type ProviderRegistryEntry } from "./types.js"; +${registryImports} + +export const registryEntries: ProviderRegistryEntry[] = [${registryList}]; + +export const registry: Record = Object.fromEntries( + registryEntries.map((e) => [e.id, e]) +); + +export function getProvider(id: string): ProviderRegistryEntry | undefined { + return registry[id]; +} +`; +writeFileSync(new URL("./registry/index.ts", `file://${srcDir}`), registryOut); + +const handAuthoredModules = listModules("handAuthored", ["index.ts"]); +const handImports = handAuthoredModules + .map((m) => `import { samples as s_${alias(m)} } from "./${m}.js";`) + .join("\n"); +const handSpread = handAuthoredModules.map((m) => `...s_${alias(m)}`).join(", "); +const handOut = `${banner} +import { type SampleRecord } from "../sampleRecord.js"; +${handImports} + +export const handAuthoredSamples: SampleRecord[] = [${handSpread}]; +`; +writeFileSync(new URL("./handAuthored/index.ts", `file://${srcDir}`), handOut); + +console.log( + `Aggregated ${registryModules.length} registry entries, ${handAuthoredModules.length} hand-authored sample modules` +); diff --git a/internal-packages/webhook-sources/catalog/build-brief.md b/internal-packages/webhook-sources/catalog/build-brief.md new file mode 100644 index 0000000000..4ea5a7f991 --- /dev/null +++ b/internal-packages/webhook-sources/catalog/build-brief.md @@ -0,0 +1,118 @@ +# Provider build brief (read this fully, then build your assigned provider) + +You are building ONE provider for the webhook sample library. Do the work YOURSELF. Do NOT spawn or +delegate to any subagents. Only create your provider's own files; do NOT edit any shared file +(`providers.json`, any `index.ts`, `samples.ts`, or another provider's files). + +PACKAGE: `/Users/eric/code/triggerdotdev/isolated/webhooks/internal-packages/webhook-sources` + +## What to create + +Find your provider's row in the FACTS table below. Then: + +**A registry entry, always:** `src/registry/.ts` exporting `entry` (see clerk reference). + +**Sample bodies, depending on `source`:** +- `source=hookdeck` -> samples already exist in `src/generated/hookdeck-samples.json`. Do NOT author a + sample file. Just read that JSON, confirm entries for your provider, and report count + eventTypes. +- `source=handauthored` -> create `src/handAuthored/.ts` exporting `samples: SampleRecord[]` with + 4 to 6 representative events. USE WEB SEARCH against the docs URL to get the REAL payload shape. + +## Reference (clerk) + +`src/registry/clerk.ts`: +``` +import { type ProviderRegistryEntry } from "./types.js"; +export const entry: ProviderRegistryEntry = { + id: "clerk", label: "Clerk", category: "auth-identity", + docsUrl: "https://clerk.com/docs/integrations/webhooks/overview", + preset: "svix", secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "type" }, sampleSource: "handauthored", +}; +``` +`src/handAuthored/clerk.ts` exports `export const samples: SampleRecord[] = [ { provider, providerLabel, presetId, eventType, name, description?, body, docsUrl?, provenance } ]`. + +## Shapes + +`ProviderRegistryEntry`: `id, label, category, icon?, docsUrl?, preset?, secretProvisioning ("provider"|"integrator"|"either"), eventTypeSource ({from:"body",path} | {from:"header",name}), sampleSource ("hookdeck"|"octokit"|"capture"|"handauthored")`. + +`SampleRecord`: `provider (string), providerLabel?, presetId? (one of stripe|github|svix|square|discord), eventType (string), name (string), description?, body (the event JSON), extraHeaders? (record of NON-signature routing headers), docsUrl?, provenance ({kind:"handauthored", snapshotDate:"2026-07"})`. + +## Preset verification (CRITICAL: verify before you set `preset`/`presetId`) + +Your row lists a preset HYPOTHESIS. Set `entry.preset` and every sample's `presetId` to it ONLY if the +provider's real signature wire format EXACTLY matches one of ours below. If it does not match, DROP the +preset: omit `entry.preset`, omit `presetId`, keep `sampleSource`, and report that you downgraded to +sample-only and why. + +- `stripe`: header `stripe-signature` = `t=,v1=`, HMAC-SHA256 over `{t}.{rawBody}`. +- `github`: header `x-hub-signature-256` = `sha256=`, HMAC-SHA256 over `rawBody`. (A provider using + `x-hub-signature` WITHOUT the `-256`, or SHA1, does NOT match.) +- `svix` (Standard Webhooks): headers `webhook-id`/`webhook-timestamp`/`webhook-signature` (or the + `svix-*` equivalents), HMAC-SHA256 base64 over `{id}.{timestamp}.{rawBody}`, secret `whsec_`-prefixed. +- `square`: header `x-square-hmacsha256-signature`, HMAC-SHA256 base64 over `{notificationUrl}{rawBody}`. +- `discord`: headers `X-Signature-Ed25519` + `X-Signature-Timestamp`, Ed25519 over `{timestamp}{rawBody}`. + +## Rules + +- `eventType` MUST equal the real discriminant value for that sample (from the row's `discriminant`). +- **Header-discriminated providers** (row says `discriminant=header:`): the body has no type field, + so add `extraHeaders: { "": "" }` to each sample so the type survives loading. + Body-discriminated providers must NOT set signature headers in `extraHeaders` (the composer signs at + send time; never store a signature). +- Some providers deliver an ARRAY of events (e.g. SendGrid) or a nested/wrapped shape (row notes it); + model the body faithfully and set `eventType` to the meaningful value. Some are form-encoded (Twilio): + represent the body as the key/value object. +- Use realistic-but-FAKE ids/values; no real secrets or PII. +- This repo BLOCKS new `//` line comments via a hook. Use `/** */` JSDoc only, or none. If a Write is + rejected for comments, remove them and retry. +- Import paths end in `.js`. Do NOT run tests, aggregate, or typecheck; do NOT touch index files. + +## Report format (final message, plain text) + +`FILES: . PRESET: . EVENTS: . SOURCE: . CAVEATS: .` + +## FACTS (one row per provider) + +Columns: id | label | category | tier | preset-hypothesis | scheme | discriminant | secret | source | docsUrl | suggested events + +- stripe | Stripe | payments | first-class | stripe | hmac-sha256 t=,v1= | body:type | integrator | hookdeck | https://docs.stripe.com/webhooks | (registry only; ~13 hookdeck samples already tagged presetId=stripe) +- anthropic | Anthropic (Claude) | ai-platform | first-class | svix | standard-webhooks | body:type | provider | handauthored | https://platform.claude.com/docs/en/managed-agents/webhooks | session.status_idled, session.run_started, deployment_run.succeeded, deployment_run.failed, agent.* (Managed Agents; envelope {id,type,created_at,data}) +- resend | Resend | email | first-class | svix | svix | body:type | provider | handauthored | https://resend.com/docs/dashboard/webhooks/introduction | email.sent, email.delivered, email.bounced, email.complained, email.opened, email.clicked (body {type, created_at, data}) +- replicate | Replicate | ai-platform | first-class | svix | standard-webhooks | body:status | provider | handauthored | https://replicate.com/docs/topics/webhooks/verify-webhook | prediction succeeded/failed/canceled (body = prediction object {id, status, output, ...}); pick eventType from status +- brex | Brex | fintech | first-class | svix | svix | body:type | provider | handauthored | https://developer.brex.com/docs/webhooks/ | card transaction/expense/budget events per docs +- recall-ai | Recall.ai | ai-platform | first-class | svix | standard-webhooks | body:event | provider | handauthored | https://docs.recall.ai/docs/authenticating-requests-from-recallai | bot.status_change, recording.done, transcript.done (body {event, data}) +- square | Square | payments | first-class | square | hmac-sha256 (url+body) | body:type | integrator | handauthored | https://developer.squareup.com/docs/webhooks/step3validate | payment.created, payment.updated, order.created, refund.created (body {merchant_id, type, event_id, created_at, data}) +- discord | Discord | communication | first-class | discord | ed25519 | body:type | provider | handauthored | https://docs.discord.com/developers/events/webhook-events | Webhook Events (type 0 with body.event.type e.g. APPLICATION_AUTHORIZED) and/or Interactions PING (type 1); numeric top-level type, verify carefully and note in caveats +- gitlab | GitLab | source-control | first-class | svix (VERIFY signing-token) | default plaintext X-Gitlab-Token OR opt-in signing-token | header:x-gitlab-event (also body.object_kind) | integrator | hookdeck | https://docs.gitlab.com/user/project/integrations/webhooks/ | 7 untagged hookdeck samples exist. If GitLab's signing-token is standard-webhooks-compatible, hand-author 3-4 svix-tagged samples; else register sample-only (drop preset) and rely on hookdeck samples +- whatsapp | WhatsApp (Meta) | communication | first-class | github (VERIFY x-hub-signature-256) | hmac-sha256 x-hub-signature-256 | body (entry[].changes[].value.field: messages|statuses) | integrator | handauthored | https://developers.facebook.com/docs/whatsapp/cloud-api/guides/set-up-webhooks/ | inbound message, message status (deep nested; set eventType to the change field like "messages"/"statuses"); verify header is x-hub-signature-256 sha256= +- jira | Jira | pm | first-class | github (VERIFY: Jira uses x-hub-signature WITHOUT -256, likely NO match) | hmac-sha256 x-hub-signature | body:webhookEvent | integrator | handauthored | https://developer.atlassian.com/cloud/jira/platform/webhooks/ | jira:issue_created, jira:issue_updated, comment_created (body {webhookEvent, issue, ...}); if header is x-hub-signature (no -256) it does NOT match our github preset -> downgrade to sample-only +- twilio | Twilio | communication | sample-only | none | hmac-sha1 | body (form-encoded; no type field) | integrator | handauthored | https://www.twilio.com/docs/usage/webhooks/webhooks-security | inbound SMS and call-status; body is a key/value object of form params; set eventType like "message.inbound" / "call.status-callback" +- telegram | Telegram | communication | sample-only | none | shared-secret | body (Update object; key present = type, e.g. message/callback_query) | integrator | handauthored | https://core.telegram.org/bots/api#setwebhook | message, edited_message, callback_query, inline_query; eventType = the update field name +- zoom | Zoom | communication | sample-only | none | hmac-sha256 | body:event | integrator | handauthored | https://developers.zoom.us/docs/api/webhooks/ | meeting.started, meeting.ended, recording.completed, meeting.participant_joined (body {event, payload}) +- sendgrid | SendGrid | email | sample-only | none | ecdsa | body[]:event (ARRAY of events) | provider | handauthored | https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/getting-started-event-webhook-security-features | delivered, open, click, bounce, spamreport; body is an ARRAY; each sample = array with one representative event, eventType = that event value +- postmark | Postmark | email | sample-only | none | basic | body:RecordType | integrator | handauthored | https://postmarkapp.com/developer/webhooks/inbound-webhook | Delivery, Bounce, SpamComplaint, Open, Inbound (body {RecordType, ...}) +- zendesk | Zendesk | support | sample-only | none | hmac-sha256 | body (trigger-defined; use a common ticket shape) | integrator | handauthored | https://developer.zendesk.com/documentation/webhooks/verifying/ | ticket.created, ticket.updated (payload is configurable; use a representative ticket event JSON) +- intercom | Intercom | support | sample-only | none | hmac-sha1 | body:topic | integrator | handauthored | https://developers.intercom.com/docs/references/webhooks/webhook-models | conversation.user.created, conversation.user.replied, contact.created (body {type:"notification_event", topic, data}) +- sentry | Sentry | observability | sample-only | none | hmac-sha256 | header:sentry-hook-resource (+ body.action) | integrator | handauthored | https://docs.sentry.io/product/integrations/integration-platform/webhooks/ | issue.created, issue.resolved, error.created (header sentry-hook-resource; body {action, data}); header-discriminated -> add extraHeaders +- pagerduty | PagerDuty | observability | sample-only | none | hmac-sha256 | body:event.event_type | integrator | handauthored | https://developer.pagerduty.com/docs/webhooks/webhook-signatures/ | incident.triggered, incident.acknowledged, incident.resolved (body {event:{event_type, data}}) +- vercel | Vercel | hosting-infra | sample-only | none | hmac-sha1 | body:type | integrator | handauthored | https://vercel.com/docs/webhooks | deployment.created, deployment.succeeded, deployment.error, deployment.canceled (body {type, id, payload}) +- linear | Linear | pm | sample-only | none | hmac-sha256 | body:type (+ body.action) | integrator | handauthored | https://linear.app/developers/webhooks | Issue create, Comment create (body {action, type, data, ...}) +- auth0 | Auth0 | auth-identity | sample-only | none | bearer | body[]:data.type (log-stream events array) | integrator | handauthored | https://auth0.com/docs/customize/log-streams/custom-log-streams | successful login (s), failed login (f), signup (ss); body is an array of log events; eventType from the log event type +- workos | WorkOS | auth-identity | sample-only | none | hmac-sha256 | body:event | provider | handauthored | https://workos.com/docs/events/data-syncing/webhooks | dsync.user.created, connection.activated, user.created (body {id, event, data}) +- supabase | Supabase | hosting-infra | sample-only | none | shared-secret | body:type (INSERT/UPDATE/DELETE) | integrator | handauthored | https://supabase.com/docs/guides/database/webhooks | INSERT, UPDATE, DELETE (body {type, table, schema, record, old_record}) +- notion | Notion | productivity | sample-only | none | hmac-sha256 | body:type | provider | handauthored | https://developers.notion.com/reference/webhooks | page.content_updated, page.created, database.content_updated (body {type, ...}); verify current event type names via docs +- attio | Attio | crm | sample-only | none | hmac-sha256 | body:events[].event_type | integrator | handauthored | https://docs.attio.com/rest-api/guides/webhooks | record.created, record.updated, list-entry.created (body {events:[{event_type, id}], webhook_id}) +- close-crm | Close | crm | sample-only | none | hmac-sha256 | body:event.action (+ object_type) | provider | handauthored | https://developer.close.com/topics/webhooks/ | lead.created, activity.created, opportunity.status_change (body {event:{action, object_type, data}}) +- hubspot | HubSpot | crm | sample-only | none | hmac-sha256 v3 | body[]:subscriptionType (ARRAY) | provider | hookdeck | https://developers.hubspot.com/docs/guides/api/app-management/webhooks | (registry only; ~3 hookdeck samples exist; report them) +- calendly | Calendly | calendar-scheduling | sample-only | none | hmac-sha256 | body:event | provider | handauthored | https://developer.calendly.com/api-docs/4c305798a61d3-webhook-signatures | invitee.created, invitee.canceled (body {event, created_at, payload}) +- cal-com | Cal.com | calendar-scheduling | sample-only | none | hmac-sha256 | body:triggerEvent | integrator | handauthored | https://cal.com/docs/developing/guides/automation/webhooks | BOOKING_CREATED, BOOKING_CANCELLED, BOOKING_RESCHEDULED, MEETING_ENDED (body {triggerEvent, createdAt, payload}) +- typeform | Typeform | forms | sample-only | none | hmac-sha256 | body:event_type | integrator | handauthored | https://www.typeform.com/developers/webhooks/secure-your-webhooks/ | form_response (body {event_id, event_type, form_response}) +- docusign | DocuSign | e-signature | sample-only | none | hmac-sha256 | body:event | integrator | handauthored | https://developers.docusign.com/platform/webhooks/connect/ | envelope-completed, envelope-sent, recipient-completed (Connect JSON body {event, apiVersion, data}) +- shopify | Shopify | commerce | sample-only | none | hmac-sha256 | header:x-shopify-topic | integrator | hookdeck | https://shopify.dev/docs/apps/build/webhooks/verify-deliveries | (registry only; ~181 hookdeck samples; header-discriminated, confirm samples carry x-shopify-topic) +- plaid | Plaid | fintech | sample-only | none | jwt es256 | body:webhook_type (+ webhook_code) | provider | handauthored | https://plaid.com/docs/api/webhooks/webhook-verification/ | TRANSACTIONS/SYNC_UPDATES_AVAILABLE, ITEM/ERROR, ITEM/LOGIN_REQUIRED (body {webhook_type, webhook_code, item_id, ...}); set eventType like "TRANSACTIONS.SYNC_UPDATES_AVAILABLE" +- elevenlabs | ElevenLabs | voice | sample-only | none | hmac-sha256 | body:type | provider | handauthored | https://elevenlabs.io/docs/eleven-agents/workflows/post-call-webhooks | post_call_transcription, post_call_audio, call_initiation_failure (body {type, event_timestamp, data}) +- vapi | Vapi | voice | sample-only | none | hmac-sha256 | body:message.type | integrator | handauthored | https://docs.vapi.ai/server-url/server-authentication | end-of-call-report, status-update, transcript, tool-calls (body {message:{type, ...}}) +- retell | Retell AI | voice | sample-only | none | hmac-sha256 | body:event | provider | handauthored | https://docs.retellai.com/features/secure-webhook | call_started, call_ended, call_analyzed (body {event, call}) +- assemblyai | AssemblyAI | ai-platform | sample-only | none | shared-secret | body:status | integrator | handauthored | https://www.assemblyai.com/docs/concepts/webhooks | transcript completed, error (body {transcript_id, status}) +- deepgram | Deepgram | ai-platform | sample-only | none | none-or-ip-allowlist | body (async transcript result; no type field) | integrator | handauthored | https://developers.deepgram.com/docs/callback | transcript ready (body = Deepgram results object); set eventType like "transcript.completed" diff --git a/internal-packages/webhook-sources/catalog/build-v1.ts b/internal-packages/webhook-sources/catalog/build-v1.ts new file mode 100644 index 0000000000..7bf2bb25ac --- /dev/null +++ b/internal-packages/webhook-sources/catalog/build-v1.ts @@ -0,0 +1,519 @@ +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +type Row = [ + id: string, + label: string, + category: string, + preset: string | null, + scheme: string, + popularity: string, + sampleSource: string, + docsUrl: string, +]; + +const PRESETS = new Set(["stripe", "github", "svix", "square", "discord"]); + +const DOD: Record = { + "first-class": ["registryEntry", "samples", "roundTrip", "producer"], + "sample-only": ["registryEntry", "samples"], +}; + +const rows: Row[] = [ + [ + "openai", + "OpenAI", + "ai-platform", + "svix", + "standard-webhooks", + "high", + "handauthored", + "https://platform.openai.com/docs/guides/webhooks", + ], + [ + "anthropic", + "Anthropic (Claude)", + "ai-platform", + "svix", + "standard-webhooks", + "high", + "handauthored", + "https://platform.claude.com/docs/en/managed-agents/webhooks", + ], + [ + "replicate", + "Replicate", + "ai-platform", + "svix", + "standard-webhooks", + "medium", + "handauthored", + "https://replicate.com/docs/topics/webhooks/verify-webhook", + ], + [ + "recall-ai", + "Recall.ai", + "ai-platform", + "svix", + "standard-webhooks", + "medium", + "handauthored", + "https://docs.recall.ai/docs/authenticating-requests-from-recallai", + ], + [ + "clerk", + "Clerk", + "auth-identity", + "svix", + "svix", + "high", + "handauthored", + "https://clerk.com/docs/guides/development/webhooks/overview", + ], + [ + "resend", + "Resend", + "email", + "svix", + "svix", + "high", + "handauthored", + "https://resend.com/docs/dashboard/webhooks/introduction", + ], + [ + "brex", + "Brex", + "fintech", + "svix", + "svix", + "medium", + "handauthored", + "https://developer.brex.com/docs/webhooks/", + ], + [ + "gitlab", + "GitLab", + "source-control", + "svix", + "standard-webhooks", + "high", + "hookdeck", + "https://docs.gitlab.com/user/project/integrations/webhooks/", + ], + [ + "github", + "GitHub", + "source-control", + "github", + "hmac-sha256", + "high", + "hookdeck", + "https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries", + ], + [ + "whatsapp", + "WhatsApp (Meta)", + "communication", + "github", + "hmac-sha256", + "high", + "handauthored", + "https://developers.facebook.com/docs/whatsapp/cloud-api/guides/set-up-webhooks/", + ], + [ + "jira", + "Jira", + "pm", + "github", + "hmac-sha256", + "high", + "handauthored", + "https://developer.atlassian.com/cloud/jira/platform/webhooks/", + ], + [ + "stripe", + "Stripe", + "payments", + "stripe", + "stripe", + "high", + "hookdeck", + "https://docs.stripe.com/webhooks", + ], + [ + "square", + "Square", + "payments", + "square", + "hmac-sha256", + "medium", + "handauthored", + "https://developer.squareup.com/docs/webhooks/step3validate", + ], + [ + "discord", + "Discord", + "communication", + "discord", + "ed25519", + "high", + "handauthored", + "https://docs.discord.com/developers/events/webhook-events", + ], + + [ + "elevenlabs", + "ElevenLabs", + "voice", + null, + "hmac-sha256", + "high", + "handauthored", + "https://elevenlabs.io/docs/eleven-agents/workflows/post-call-webhooks", + ], + [ + "vapi", + "Vapi", + "voice", + null, + "hmac-sha256", + "medium", + "handauthored", + "https://docs.vapi.ai/server-url/server-authentication", + ], + [ + "retell", + "Retell AI", + "voice", + null, + "hmac-sha256", + "medium", + "handauthored", + "https://docs.retellai.com/features/secure-webhook", + ], + [ + "assemblyai", + "AssemblyAI", + "ai-platform", + null, + "shared-secret", + "medium", + "handauthored", + "https://www.assemblyai.com/docs/concepts/webhooks", + ], + [ + "deepgram", + "Deepgram", + "ai-platform", + null, + "none-or-ip-allowlist", + "medium", + "handauthored", + "https://developers.deepgram.com/docs/callback", + ], + [ + "slack", + "Slack", + "communication", + null, + "hmac-sha256", + "high", + "handauthored", + "https://docs.slack.dev/authentication/verifying-requests-from-slack/", + ], + [ + "telegram", + "Telegram", + "communication", + null, + "shared-secret", + "medium", + "handauthored", + "https://core.telegram.org/bots/api#setwebhook", + ], + [ + "twilio", + "Twilio", + "communication", + null, + "hmac-sha1", + "high", + "handauthored", + "https://www.twilio.com/docs/usage/webhooks/webhooks-security", + ], + [ + "zoom", + "Zoom", + "communication", + null, + "hmac-sha256", + "high", + "handauthored", + "https://developers.zoom.us/docs/api/webhooks/", + ], + [ + "sendgrid", + "SendGrid", + "email", + null, + "ecdsa", + "high", + "handauthored", + "https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/getting-started-event-webhook-security-features", + ], + [ + "postmark", + "Postmark", + "email", + null, + "basic", + "medium", + "handauthored", + "https://postmarkapp.com/developer/webhooks/inbound-webhook", + ], + [ + "zendesk", + "Zendesk", + "support", + null, + "hmac-sha256", + "high", + "handauthored", + "https://developer.zendesk.com/documentation/webhooks/verifying/", + ], + [ + "intercom", + "Intercom", + "support", + null, + "hmac-sha1", + "high", + "handauthored", + "https://developers.intercom.com/docs/references/webhooks/webhook-models", + ], + [ + "cal-com", + "Cal.com", + "calendar-scheduling", + null, + "hmac-sha256", + "medium", + "handauthored", + "https://cal.com/docs/developing/guides/automation/webhooks", + ], + [ + "calendly", + "Calendly", + "calendar-scheduling", + null, + "hmac-sha256", + "high", + "handauthored", + "https://developer.calendly.com/api-docs/4c305798a61d3-webhook-signatures", + ], + [ + "linear", + "Linear", + "pm", + null, + "hmac-sha256", + "medium", + "handauthored", + "https://linear.app/developers/webhooks", + ], + [ + "sentry", + "Sentry", + "observability", + null, + "hmac-sha256", + "high", + "handauthored", + "https://docs.sentry.io/product/integrations/integration-platform/webhooks/", + ], + [ + "vercel", + "Vercel", + "hosting-infra", + null, + "hmac-sha1", + "high", + "handauthored", + "https://vercel.com/docs/webhooks", + ], + [ + "pagerduty", + "PagerDuty", + "observability", + null, + "hmac-sha256", + "high", + "handauthored", + "https://developer.pagerduty.com/docs/webhooks/webhook-signatures/", + ], + [ + "auth0", + "Auth0", + "auth-identity", + null, + "bearer", + "high", + "handauthored", + "https://auth0.com/docs/customize/log-streams/custom-log-streams", + ], + [ + "workos", + "WorkOS", + "auth-identity", + null, + "hmac-sha256", + "medium", + "handauthored", + "https://workos.com/docs/events/data-syncing/webhooks", + ], + [ + "supabase", + "Supabase", + "hosting-infra", + null, + "shared-secret", + "high", + "handauthored", + "https://supabase.com/docs/guides/database/webhooks", + ], + [ + "notion", + "Notion", + "productivity", + null, + "hmac-sha256", + "high", + "handauthored", + "https://developers.notion.com/reference/webhooks", + ], + [ + "attio", + "Attio", + "crm", + null, + "hmac-sha256", + "medium", + "handauthored", + "https://docs.attio.com/rest-api/guides/webhooks", + ], + [ + "close-crm", + "Close", + "crm", + null, + "hmac-sha256", + "medium", + "handauthored", + "https://developer.close.com/topics/webhooks/", + ], + [ + "hubspot", + "HubSpot", + "crm", + null, + "hmac-sha256", + "high", + "hookdeck", + "https://developers.hubspot.com/docs/guides/api/app-management/webhooks", + ], + [ + "typeform", + "Typeform", + "forms", + null, + "hmac-sha256", + "high", + "handauthored", + "https://www.typeform.com/developers/webhooks/secure-your-webhooks/", + ], + [ + "docusign", + "DocuSign", + "e-signature", + null, + "hmac-sha256", + "high", + "handauthored", + "https://developers.docusign.com/platform/webhooks/connect/", + ], + [ + "shopify", + "Shopify", + "commerce", + null, + "hmac-sha256", + "high", + "hookdeck", + "https://shopify.dev/docs/apps/build/webhooks/verify-deliveries", + ], + [ + "plaid", + "Plaid", + "fintech", + null, + "jwt", + "high", + "handauthored", + "https://plaid.com/docs/api/webhooks/webhook-verification/", + ], +]; + +const popRank: Record = { high: 0, "medium-high": 1, medium: 2, low: 3 }; + +const providers = rows.map( + ([id, label, category, preset, scheme, popularity, sampleSource, docsUrl]) => { + const tier = preset ? "first-class" : "sample-only"; + const checklist = Object.fromEntries(DOD[tier].map((k) => [k, false])); + return { + id, + label, + category, + tier, + preset, + signatureScheme: scheme, + aiRelevance: "high", + popularity, + sampleSource, + docsUrl, + status: "not-started", + owner: null, + checklist, + notes: "", + }; + } +); + +providers.sort((a, b) => { + const t = (a.tier === "first-class" ? 0 : 1) - (b.tier === "first-class" ? 0 : 1); + if (t !== 0) return t; + const p = (popRank[a.popularity] ?? 9) - (popRank[b.popularity] ?? 9); + if (p !== 0) return p; + return a.label.localeCompare(b.label); +}); + +const seen = new Set(); +for (const p of providers) { + if (seen.has(p.id)) throw new Error(`duplicate provider id: ${p.id}`); + seen.add(p.id); + if (p.preset && !PRESETS.has(p.preset)) throw new Error(`unknown preset ${p.preset} on ${p.id}`); +} + +const catalog = { + release: "webhooks-v1", + scope: "ai-agent-first", + updatedAt: null, + definitionOfDone: DOD, + statusValues: ["not-started", "in-progress", "complete", "blocked"], + providers, +}; + +const outPath = fileURLToPath(new URL("./providers.json", import.meta.url)); +writeFileSync(outPath, JSON.stringify(catalog, null, 2) + "\n"); +console.log(`Wrote ${providers.length} providers to providers.json`); +console.log(` first-class: ${providers.filter((p) => p.tier === "first-class").length}`); +console.log(` sample-only: ${providers.filter((p) => p.tier === "sample-only").length}`); diff --git a/internal-packages/webhook-sources/catalog/mark.ts b/internal-packages/webhook-sources/catalog/mark.ts new file mode 100644 index 0000000000..1f1047532f --- /dev/null +++ b/internal-packages/webhook-sources/catalog/mark.ts @@ -0,0 +1,76 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const args = process.argv.slice(2); +const id = args[0]; +if (!id || id.startsWith("--")) { + console.error( + "usage: mark.ts [--status ] [--check k1,k2,...] [--owner ] [--note ]" + ); + process.exit(1); +} + +const flag = (name: string) => { + const i = args.indexOf(`--${name}`); + return i === -1 ? undefined : args[i + 1]; +}; + +const path = fileURLToPath(new URL("./providers.json", import.meta.url)); +const catalog = JSON.parse(readFileSync(path, "utf8")); +const provider = catalog.providers.find((p: { id: string }) => p.id === id); +if (!provider) { + console.error(`provider not found: ${id}`); + process.exit(1); +} + +const status = flag("status"); +if (status) { + if (!catalog.statusValues.includes(status)) { + console.error(`invalid status ${status}; one of ${catalog.statusValues.join(", ")}`); + process.exit(1); + } + provider.status = status; +} + +const newTier = flag("tier"); +if (newTier) { + const keys: string[] | undefined = catalog.definitionOfDone[newTier]; + if (!keys) { + console.error( + `invalid tier ${newTier}; one of ${Object.keys(catalog.definitionOfDone).join(", ")}` + ); + process.exit(1); + } + provider.tier = newTier; + provider.checklist = Object.fromEntries(keys.map((k) => [k, provider.checklist[k] ?? false])); +} + +const newPreset = flag("preset"); +if (newPreset !== undefined) provider.preset = newPreset === "null" ? null : newPreset; + +const check = flag("check"); +if (check) { + const dodKeys: string[] = catalog.definitionOfDone[provider.tier] ?? []; + for (const key of check + .split(",") + .map((s) => s.trim()) + .filter(Boolean)) { + if (!dodKeys.includes(key)) { + console.error(`checklist key '${key}' not in ${provider.tier} DoD (${dodKeys.join(", ")})`); + process.exit(1); + } + provider.checklist[key] = true; + } +} + +const owner = flag("owner"); +if (owner !== undefined) provider.owner = owner === "null" ? null : owner; + +const note = flag("note"); +if (note !== undefined) provider.notes = note; + +writeFileSync(path, JSON.stringify(catalog, null, 2) + "\n"); +const done = Object.values(provider.checklist).filter(Boolean).length; +console.log( + `${id}: status=${provider.status} checklist=${done}/${Object.keys(provider.checklist).length} owner=${provider.owner ?? "-"}` +); diff --git a/internal-packages/webhook-sources/catalog/providers.json b/internal-packages/webhook-sources/catalog/providers.json new file mode 100644 index 0000000000..5aa11d0c07 --- /dev/null +++ b/internal-packages/webhook-sources/catalog/providers.json @@ -0,0 +1,912 @@ +{ + "release": "webhooks-v1", + "scope": "ai-agent-first", + "updatedAt": null, + "definitionOfDone": { + "first-class": ["registryEntry", "samples", "roundTrip", "producer"], + "sample-only": ["registryEntry", "samples"] + }, + "statusValues": ["not-started", "in-progress", "complete", "blocked"], + "providers": [ + { + "id": "anthropic", + "label": "Anthropic (Claude)", + "category": "ai-platform", + "tier": "sample-only", + "preset": null, + "signatureScheme": "standard-webhooks", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://platform.claude.com/docs/en/managed-agents/webhooks", + "status": "complete", + "owner": "batch1", + "checklist": { + "registryEntry": true, + "samples": true + }, + "notes": "downgraded: single X-Webhook-Signature header, not 3-header svix scheme" + }, + { + "id": "clerk", + "label": "Clerk", + "category": "auth-identity", + "tier": "first-class", + "preset": "svix", + "signatureScheme": "svix", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://clerk.com/docs/guides/development/webhooks/overview", + "status": "complete", + "owner": null, + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "discord", + "label": "Discord", + "category": "communication", + "tier": "first-class", + "preset": "discord", + "signatureScheme": "ed25519", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://docs.discord.com/developers/events/webhook-events", + "status": "complete", + "owner": "batch2", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "ed25519 asymmetric: preset exact-match verified structurally, not sign/verify round-tripped" + }, + { + "id": "github", + "label": "GitHub", + "category": "source-control", + "tier": "first-class", + "preset": "github", + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "hookdeck", + "docsUrl": "https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries", + "status": "complete", + "owner": null, + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "gitlab", + "label": "GitLab", + "category": "source-control", + "tier": "first-class", + "preset": "svix", + "signatureScheme": "standard-webhooks", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "hookdeck", + "docsUrl": "https://docs.gitlab.com/user/project/integrations/webhooks/", + "status": "complete", + "owner": "batch2", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "jira", + "label": "Jira", + "category": "pm", + "tier": "first-class", + "preset": null, + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://developer.atlassian.com/cloud/jira/platform/webhooks/", + "status": "complete", + "owner": "batch2", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "downgraded: x-hub-signature (no -256) != github preset" + }, + { + "id": "openai", + "label": "OpenAI", + "category": "ai-platform", + "tier": "first-class", + "preset": "svix", + "signatureScheme": "standard-webhooks", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://platform.openai.com/docs/guides/webhooks", + "status": "complete", + "owner": null, + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "resend", + "label": "Resend", + "category": "email", + "tier": "first-class", + "preset": "svix", + "signatureScheme": "svix", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://resend.com/docs/dashboard/webhooks/introduction", + "status": "complete", + "owner": "batch1", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "stripe", + "label": "Stripe", + "category": "payments", + "tier": "first-class", + "preset": "stripe", + "signatureScheme": "stripe", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "hookdeck", + "docsUrl": "https://docs.stripe.com/webhooks", + "status": "complete", + "owner": "batch1", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "whatsapp", + "label": "WhatsApp (Meta)", + "category": "communication", + "tier": "first-class", + "preset": "github", + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://developers.facebook.com/docs/whatsapp/cloud-api/guides/set-up-webhooks/", + "status": "complete", + "owner": "batch2", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "brex", + "label": "Brex", + "category": "fintech", + "tier": "first-class", + "preset": "svix", + "signatureScheme": "svix", + "aiRelevance": "high", + "popularity": "medium", + "sampleSource": "handauthored", + "docsUrl": "https://developer.brex.com/docs/webhooks/", + "status": "complete", + "owner": "batch2", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "recall-ai", + "label": "Recall.ai", + "category": "ai-platform", + "tier": "first-class", + "preset": "svix", + "signatureScheme": "standard-webhooks", + "aiRelevance": "high", + "popularity": "medium", + "sampleSource": "handauthored", + "docsUrl": "https://docs.recall.ai/docs/authenticating-requests-from-recallai", + "status": "complete", + "owner": "batch1", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "replicate", + "label": "Replicate", + "category": "ai-platform", + "tier": "first-class", + "preset": "svix", + "signatureScheme": "standard-webhooks", + "aiRelevance": "high", + "popularity": "medium", + "sampleSource": "handauthored", + "docsUrl": "https://replicate.com/docs/topics/webhooks/verify-webhook", + "status": "complete", + "owner": "batch1", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "square", + "label": "Square", + "category": "payments", + "tier": "first-class", + "preset": "square", + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "medium", + "sampleSource": "handauthored", + "docsUrl": "https://developer.squareup.com/docs/webhooks/step3validate", + "status": "complete", + "owner": "batch1", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "auth0", + "label": "Auth0", + "category": "auth-identity", + "tier": "sample-only", + "preset": null, + "signatureScheme": "bearer", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://auth0.com/docs/customize/log-streams/custom-log-streams", + "status": "complete", + "owner": "batch3", + "checklist": { + "registryEntry": true, + "samples": true + }, + "notes": "" + }, + { + "id": "calendly", + "label": "Calendly", + "category": "calendar-scheduling", + "tier": "first-class", + "preset": null, + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://developer.calendly.com/api-docs/4c305798a61d3-webhook-signatures", + "status": "complete", + "owner": "batch1", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "docusign", + "label": "DocuSign", + "category": "e-signature", + "tier": "first-class", + "preset": null, + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://developers.docusign.com/platform/webhooks/connect/", + "status": "complete", + "owner": "batch3", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "elevenlabs", + "label": "ElevenLabs", + "category": "voice", + "tier": "first-class", + "preset": null, + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://elevenlabs.io/docs/eleven-agents/workflows/post-call-webhooks", + "status": "complete", + "owner": "batch3", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "hubspot", + "label": "HubSpot", + "category": "crm", + "tier": "sample-only", + "preset": null, + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "hookdeck", + "docsUrl": "https://developers.hubspot.com/docs/guides/api/app-management/webhooks", + "status": "complete", + "owner": "batch1", + "checklist": { + "registryEntry": true, + "samples": true + }, + "notes": "hookdeck samples are CRM-object shape, not App-Subscription array; revisit eventTypeSource/handauthor later" + }, + { + "id": "intercom", + "label": "Intercom", + "category": "support", + "tier": "first-class", + "preset": null, + "signatureScheme": "hmac-sha1", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://developers.intercom.com/docs/references/webhooks/webhook-models", + "status": "complete", + "owner": "batch3", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "notion", + "label": "Notion", + "category": "productivity", + "tier": "first-class", + "preset": null, + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://developers.notion.com/reference/webhooks", + "status": "complete", + "owner": "batch2", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "pagerduty", + "label": "PagerDuty", + "category": "observability", + "tier": "first-class", + "preset": null, + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://developer.pagerduty.com/docs/webhooks/webhook-signatures/", + "status": "complete", + "owner": "batch3", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "plaid", + "label": "Plaid", + "category": "fintech", + "tier": "sample-only", + "preset": null, + "signatureScheme": "jwt", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://plaid.com/docs/api/webhooks/webhook-verification/", + "status": "complete", + "owner": "batch3", + "checklist": { + "registryEntry": true, + "samples": true + }, + "notes": "" + }, + { + "id": "sendgrid", + "label": "SendGrid", + "category": "email", + "tier": "sample-only", + "preset": null, + "signatureScheme": "ecdsa", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/getting-started-event-webhook-security-features", + "status": "complete", + "owner": "batch3", + "checklist": { + "registryEntry": true, + "samples": true + }, + "notes": "" + }, + { + "id": "sentry", + "label": "Sentry", + "category": "observability", + "tier": "first-class", + "preset": null, + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://docs.sentry.io/product/integrations/integration-platform/webhooks/", + "status": "complete", + "owner": "batch2", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "shopify", + "label": "Shopify", + "category": "commerce", + "tier": "first-class", + "preset": null, + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "hookdeck", + "docsUrl": "https://shopify.dev/docs/apps/build/webhooks/verify-deliveries", + "status": "complete", + "owner": "batch1", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "slack", + "label": "Slack", + "category": "communication", + "tier": "first-class", + "preset": null, + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://docs.slack.dev/authentication/verifying-requests-from-slack/", + "status": "complete", + "owner": null, + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "supabase", + "label": "Supabase", + "category": "hosting-infra", + "tier": "sample-only", + "preset": null, + "signatureScheme": "shared-secret", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://supabase.com/docs/guides/database/webhooks", + "status": "complete", + "owner": "batch3", + "checklist": { + "registryEntry": true, + "samples": true + }, + "notes": "" + }, + { + "id": "twilio", + "label": "Twilio", + "category": "communication", + "tier": "sample-only", + "preset": null, + "signatureScheme": "hmac-sha1", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://www.twilio.com/docs/usage/webhooks/webhooks-security", + "status": "complete", + "owner": "batch2", + "checklist": { + "registryEntry": true, + "samples": true + }, + "notes": "two samples share eventType call.status-callback; split in cleanup" + }, + { + "id": "typeform", + "label": "Typeform", + "category": "forms", + "tier": "first-class", + "preset": null, + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://www.typeform.com/developers/webhooks/secure-your-webhooks/", + "status": "complete", + "owner": "batch3", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "vercel", + "label": "Vercel", + "category": "hosting-infra", + "tier": "first-class", + "preset": null, + "signatureScheme": "hmac-sha1", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://vercel.com/docs/webhooks", + "status": "complete", + "owner": "batch3", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "zendesk", + "label": "Zendesk", + "category": "support", + "tier": "first-class", + "preset": null, + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://developer.zendesk.com/documentation/webhooks/verifying/", + "status": "complete", + "owner": "batch3", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "zoom", + "label": "Zoom", + "category": "communication", + "tier": "first-class", + "preset": null, + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "high", + "sampleSource": "handauthored", + "docsUrl": "https://developers.zoom.us/docs/api/webhooks/", + "status": "complete", + "owner": "batch1", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "assemblyai", + "label": "AssemblyAI", + "category": "ai-platform", + "tier": "sample-only", + "preset": null, + "signatureScheme": "shared-secret", + "aiRelevance": "high", + "popularity": "medium", + "sampleSource": "handauthored", + "docsUrl": "https://www.assemblyai.com/docs/concepts/webhooks", + "status": "complete", + "owner": "batch3", + "checklist": { + "registryEntry": true, + "samples": true + }, + "notes": "" + }, + { + "id": "attio", + "label": "Attio", + "category": "crm", + "tier": "first-class", + "preset": null, + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "medium", + "sampleSource": "handauthored", + "docsUrl": "https://docs.attio.com/rest-api/guides/webhooks", + "status": "complete", + "owner": "batch3", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "cal-com", + "label": "Cal.com", + "category": "calendar-scheduling", + "tier": "first-class", + "preset": null, + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "medium", + "sampleSource": "handauthored", + "docsUrl": "https://cal.com/docs/developing/guides/automation/webhooks", + "status": "complete", + "owner": "batch3", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "close-crm", + "label": "Close", + "category": "crm", + "tier": "sample-only", + "preset": null, + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "medium", + "sampleSource": "handauthored", + "docsUrl": "https://developer.close.com/topics/webhooks/", + "status": "complete", + "owner": "batch3", + "checklist": { + "registryEntry": true, + "samples": true + }, + "notes": "" + }, + { + "id": "deepgram", + "label": "Deepgram", + "category": "ai-platform", + "tier": "sample-only", + "preset": null, + "signatureScheme": "none-or-ip-allowlist", + "aiRelevance": "high", + "popularity": "medium", + "sampleSource": "handauthored", + "docsUrl": "https://developers.deepgram.com/docs/callback", + "status": "complete", + "owner": "batch3", + "checklist": { + "registryEntry": true, + "samples": true + }, + "notes": "" + }, + { + "id": "linear", + "label": "Linear", + "category": "pm", + "tier": "first-class", + "preset": null, + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "medium", + "sampleSource": "handauthored", + "docsUrl": "https://linear.app/developers/webhooks", + "status": "complete", + "owner": "batch2", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "postmark", + "label": "Postmark", + "category": "email", + "tier": "sample-only", + "preset": null, + "signatureScheme": "basic", + "aiRelevance": "high", + "popularity": "medium", + "sampleSource": "handauthored", + "docsUrl": "https://postmarkapp.com/developer/webhooks/inbound-webhook", + "status": "complete", + "owner": "batch3", + "checklist": { + "registryEntry": true, + "samples": true + }, + "notes": "" + }, + { + "id": "retell", + "label": "Retell AI", + "category": "voice", + "tier": "first-class", + "preset": null, + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "medium", + "sampleSource": "handauthored", + "docsUrl": "https://docs.retellai.com/features/secure-webhook", + "status": "complete", + "owner": "batch3", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "telegram", + "label": "Telegram", + "category": "communication", + "tier": "sample-only", + "preset": null, + "signatureScheme": "shared-secret", + "aiRelevance": "high", + "popularity": "medium", + "sampleSource": "handauthored", + "docsUrl": "https://core.telegram.org/bots/api#setwebhook", + "status": "complete", + "owner": "batch2", + "checklist": { + "registryEntry": true, + "samples": true + }, + "notes": "" + }, + { + "id": "vapi", + "label": "Vapi", + "category": "voice", + "tier": "first-class", + "preset": null, + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "medium", + "sampleSource": "handauthored", + "docsUrl": "https://docs.vapi.ai/server-url/server-authentication", + "status": "complete", + "owner": "batch3", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + }, + { + "id": "workos", + "label": "WorkOS", + "category": "auth-identity", + "tier": "first-class", + "preset": null, + "signatureScheme": "hmac-sha256", + "aiRelevance": "high", + "popularity": "medium", + "sampleSource": "handauthored", + "docsUrl": "https://workos.com/docs/events/data-syncing/webhooks", + "status": "complete", + "owner": "batch3", + "checklist": { + "registryEntry": true, + "samples": true, + "roundTrip": true, + "producer": true + }, + "notes": "" + } + ] +} diff --git a/internal-packages/webhook-sources/catalog/research-notes-wave2.md b/internal-packages/webhook-sources/catalog/research-notes-wave2.md new file mode 100644 index 0000000000..79012cde13 --- /dev/null +++ b/internal-packages/webhook-sources/catalog/research-notes-wave2.md @@ -0,0 +1,114 @@ +# Webhook provider seed research, wave 2 (raw notes, 2026-07-14) + +Same format as research-notes.md. `preset` = exact match to one of ours (stripe/github/svix/square/ +discord) else null. Finalize tier/rank in the merge. + +## Support / helpdesk / live-chat + +- zendesk | Zendesk | support | hmac-sha256 base64 (X-Zendesk-Webhook-Signature over ts+body; X-Zendesk-Webhook-Signature-Timestamp; test secret documented) | preset=null | AI:high pop:high | https://developer.zendesk.com/documentation/webhooks/verifying/ +- intercom | Intercom | support | hmac-sha1 (X-Hub-Signature sha1= over raw body, key=client secret) | preset=null | AI:high pop:high | https://developers.intercom.com/docs/references/webhooks/webhook-models +- front | Front | support | hmac-sha256 base64 (X-Front-Signature over ts+":"+body; X-Front-Request-Timestamp) | preset=null | AI:med pop:med | https://dev.frontapp.com/docs/application-webhooks +- helpscout | Help Scout | support | hmac-sha1 base64 (X-HelpScout-Signature over raw body) | preset=null | AI:med pop:med | https://developer.helpscout.com/webhooks/ +- crisp | Crisp | support | hmac-sha256 (X-Crisp-Signature over "[ts;body]"; X-Crisp-Request-Timestamp) | preset=null | AI:med pop:med | https://docs.crisp.chat/references/web-hooks/v1/ +- freshdesk | Freshdesk | support | none (basic auth / custom header you configure) | preset=null | AI:high pop:high | https://support.freshdesk.com/support/solutions/articles/50000009511 +- freshchat | Freshchat | support | rsa-sha256 asymmetric (X-Freshchat-Signature, provider public key) | preset=null | AI:med pop:med | https://crmsupport.freshworks.com/support/solutions/articles/50000004461 +- gorgias | Gorgias | support | none (custom headers / OAuth2) | preset=null | AI:med pop:med (Shopify e-comm) | https://docs.gorgias.com/http-integrations-81822 +- livechat | LiveChat (Text Platform) | support | shared-secret in payload (secret_key field in body) | preset=null | AI:med pop:med | https://platform.text.com/docs/management/webhooks +- kustomer | Kustomer | support | none (custom headers) | preset=null | AI:med pop:med (enterprise) | https://developer.kustomer.com/kustomer-apps-platform/docs/outbound-webhooks +- chatwoot | Chatwoot | support | hmac-sha256 (X-Chatwoot-Signature sha256= over ts.body; caveat self-hosted secret bug) | preset=null | AI:med pop:med (self-host) | https://developers.chatwoot.com/api-reference/webhooks/add-a-webhook +- aircall | Aircall | voice/support | token-in-body (no hmac in official docs) | preset=null | AI:med pop:med | https://developer.aircall.io/tutorials/webhooks-guide/ +- EXCLUDE drift (product sunsetting 2026-03; docs 404) + +## Forms / e-signature / storage / CMS + +- typeform | Typeform | forms | hmac-sha256 base64 (Typeform-Signature sha256= over raw body) | preset=null | AI:high pop:high | https://www.typeform.com/developers/webhooks/secure-your-webhooks/ +- tally | Tally | forms | hmac-sha256 base64 (Tally-Signature; signingSecret optional) | preset=null | AI:med pop:med | https://tally.so/help/webhooks +- docusign | DocuSign (Connect) | e-signature | hmac-sha256 base64 (X-DocuSign-Signature-1..N; up to 100 keys for rotation) | preset=null | AI:high pop:high | https://developers.docusign.com/platform/webhooks/connect/validate/ +- dropbox-sign | Dropbox Sign (HelloSign) | e-signature | hmac-sha256 hex (event_hash over event_time+event_type, key=API key; not body; reply "Hello API Event Received") | preset=null | AI:med pop:med | https://developers.hellosign.com/docs/events/walkthrough/ +- box | Box | storage | hmac-sha256 base64 (BOX-SIGNATURE-PRIMARY/SECONDARY over body+BOX-DELIVERY-TIMESTAMP; 10min replay) | preset=null | AI:med pop:med | https://developer.box.com/guides/webhooks/v2/signatures-v2 +- dropbox | Dropbox (storage) | storage | hmac-sha256 hex (X-Dropbox-Signature over raw body, app secret; GET ?challenge= verify; change-ping only) | preset=null | AI:med pop:high | https://www.dropbox.com/developers/reference/webhooks +- google-drive | Google Drive (push/watch) | storage | none (X-Goog-Channel-Token equality; empty-body pings, re-fetch; channels expire) | preset=null | AI:high pop:high | https://developers.google.com/workspace/drive/api/guides/push +- segment | Segment | data | hmac-sha1 hex (X-Signature over raw body; optional; batching signs only first event) | preset=null | AI:med pop:high | https://segment.com/docs/connections/destinations/catalog/actions-webhook/ +- contentful | Contentful | cms | hmac-sha256 hex (x-contentful-signature over CANONICAL request string; x-contentful-signed-headers; x-contentful-timestamp) | preset=null | AI:med pop:med | https://www.contentful.com/developers/docs/webhooks/request-verification/ +- sanity | Sanity | cms | hmac-sha256 (sanity-webhook-signature t=,v1= over ts.body; @sanity/webhook pkg) | preset=null | AI:med pop:med | https://www.sanity.io/docs/compute-and-ai/webhooks +- webflow | Webflow | cms | hmac-sha256 hex (x-webflow-signature over "{x-webflow-timestamp}:{body}"; 5min replay) | preset=null | AI:med pop:med | https://developers.webflow.com/data/reference/request-signatures + +## Devtools / source control / CI / hosting / infra / observability + +- github | GitHub | source-control | hmac-sha256 hex (X-Hub-Signature-256 sha256=; legacy sha1 too) | preset=github | AI:high pop:high | https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries | (in 19) +- gitlab | GitLab | source-control | default X-Gitlab-Token plaintext equality; newer webhook-signature v1, HMAC-SHA256 over message_id.ts.body (standard-webhooks) | preset=null (svix-family for signing token) | AI:high pop:high | https://docs.gitlab.com/user/project/integrations/webhooks/ | (in 19) +- bitbucket | Bitbucket | source-control | hmac-sha256 (X-Hub-Signature sha256=, recent; older unsigned) | preset=null (github-like) | AI:med pop:med | https://support.atlassian.com/bitbucket-cloud/docs/manage-webhooks/ | (in 19) +- circleci | CircleCI | ci-cd | hmac-sha256 hex (circleci-signature v1=) | preset=null | AI:med pop:med | https://circleci.com/docs/guides/integration/outbound-webhooks/ +- buildkite | Buildkite | ci-cd | hmac-sha256 (X-Buildkite-Signature timestamp=,signature= over ts.body; or X-Buildkite-Token plaintext) | preset=null | AI:med pop:med | https://buildkite.com/docs/apis/webhooks/pipelines +- vercel | Vercel | hosting-infra | hmac-sha1 hex (x-vercel-signature over raw body; account webhooks Pro/Ent) | preset=null | AI:med pop:high | https://vercel.com/docs/webhooks +- netlify | Netlify | hosting-infra | jws/jwt HS256 (X-Webhook-Signature JWT carries sha256 body-hash claim) | preset=null | AI:med pop:med | https://docs.netlify.com/site-deploys/notifications/ +- cloudflare | Cloudflare (Notifications) | hosting-infra | shared-secret (cf-webhook-auth plaintext; no body sig) | preset=null | AI:med pop:high | https://developers.cloudflare.com/notifications/get-started/configure-webhooks/ +- render | Render | hosting-infra | svix/standard-webhooks (webhook-id/timestamp/signature over id.ts.body) | preset=svix | AI:med pop:med | https://render.com/docs/webhooks +- aws-sns | AWS SNS (HTTP subscription) | hosting-infra | rsa asymmetric (Signature+SigningCertURL in body; v2=SHA256; SubscriptionConfirmation handshake) | preset=null | AI:med pop:high | https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html +- snyk | Snyk | observability/security | hmac-sha256 hex (X-Hub-Signature sha256=) | preset=null (github-like) | AI:med pop:med | https://docs.snyk.io/snyk-api/using-specific-snyk-apis/webhooks-apis/about-webhooks +- EXCLUDE fly-io (not self-serve; extension/partner only) unless we want it flagged + +## CRM / PM / productivity + +- linear | Linear | productivity/pm | hmac-sha256 hex (Linear-Signature over raw body; webhookTimestamp in body ~1min replay; Linear-Delivery) | preset=null | AI:high pop:high | https://linear.app/developers/webhooks +- jira | Jira (Atlassian Cloud) | pm | classic X-Hub-Signature HMAC (secret optional); dynamic webhooks JWT/bearer | preset=null (github-like for classic) | AI:med pop:high | https://developer.atlassian.com/cloud/jira/platform/webhooks/ +- notion | Notion | productivity | hmac-sha256 (X-Notion-Signature over raw body, verification_token from one-time setup; SDK verifyWebhookSignature) | preset=null | AI:high pop:high | https://developers.notion.com/reference/webhooks +- airtable | Airtable | productivity | hmac-sha256 (X-Airtable-Content-MAC hmac-sha256=; PING/POLL, then fetch payloads) | preset=null | AI:high pop:high | https://airtable.com/developers/web/api/webhooks-overview +- hubspot | HubSpot | crm | v3 HMAC-SHA256 base64 (X-HubSpot-Signature-v3 over method+uri+body+ts; v1/v2 are plain sha256(secret+body)) | preset=null | AI:high pop:high | https://developers.hubspot.com/docs/guides/api/app-management/webhooks | (in 19) +- salesforce | Salesforce | crm | none (Outbound Messages SOAP unsigned; or Event Relay to EventBridge) | preset=null | AI:high pop:high | https://developer.salesforce.com/docs/atlas.en-us.api.meta/api/sforce_api_om_outboundmessaging_understanding.htm | CAVEAT: SOAP/unsigned or EventBridge-only, odd fit +- pipedrive | Pipedrive | crm | none (HTTP Basic Auth http_auth_user/password) | preset=null | AI:med pop:med | https://developers.pipedrive.com/docs/api/v1/Webhooks | (in 19) +- attio | Attio | crm | hmac-sha256 hex (Attio-Signature over raw body) | preset=null | AI:high pop:med | https://docs.attio.com/rest-api/guides/webhooks +- asana | Asana | pm | hmac-sha256 (X-Hook-Signature; X-Hook-Secret handshake echo; compact payloads, then fetch) | preset=null | AI:med pop:high | https://developers.asana.com/docs/webhooks-guide +- trello | Trello | pm | hmac-sha1 base64 (X-Trello-Webhook over body+callbackURL, key=OAuth secret) | preset=null | AI:med pop:high | https://developer.atlassian.com/cloud/trello/guides/rest-api/webhooks/ +- pandadoc | PandaDoc | e-signature | hmac-sha256 hex (signature in ?signature= QUERY PARAM, not header) | preset=null | AI:med pop:med | https://developers.pandadoc.com/docs/webhook-verification + +## Billing / subscriptions / commerce + +- paddlebilling | Paddle Billing | billing | hmac-sha256 hex (Paddle-Signature ts=,h1= over ts:body) | preset=null | AI:high pop:high | https://developer.paddle.com/webhooks/signature-verification | (in 19) +- paddleclassic | Paddle Classic | billing | rsa public-key (p_signature in form body, DKIM-style) | preset=null | AI:low pop:med (legacy) | https://developer.paddle.com/classic/reference/ZG9jOjI1MzUzOTg2-verifying-webhooks | (in 19) +- lemonsqueezy | Lemon Squeezy | billing | hmac-sha256 hex (X-Signature over raw body) | preset=null | AI:med pop:high | https://docs.lemonsqueezy.com/help/webhooks/signing-requests +- chargebee | Chargebee | billing | none (basic auth / URL secret) | preset=null | AI:med pop:high | https://www.chargebee.com/docs/2.0/webhook_settings.html +- recurly | Recurly | billing | hmac-sha256 (recurly-signature ts,sig; older XML unsigned) | preset=null | AI:med pop:med | https://recurly.com/developers/reference/webhooks/ +- revenuecat | RevenueCat | billing | static Authorization header default; optional hmac-sha256 (X-RevenueCat-Webhook-Signature t=,v1= over ts.body) | preset=null | AI:high pop:high (mobile subs) | https://www.revenuecat.com/docs/integrations/webhooks +- shopify | Shopify | commerce | hmac-sha256 base64 (X-Shopify-Hmac-Sha256 over raw body, key=app secret) | preset=null | AI:high pop:high | https://shopify.dev/docs/apps/build/webhooks/verify-deliveries | (in 19) +- bigcommerce | BigCommerce | commerce | none (custom headers you set; payload hash field; no official HMAC) | preset=null | AI:med pop:med | https://developer.bigcommerce.com/docs/integrations/webhooks/https | (in 19) +- woocommerce | WooCommerce | commerce | hmac-sha256 base64 (X-WC-Webhook-Signature over raw body) | preset=null | AI:med pop:high | https://woocommerce.github.io/woocommerce-rest-api-docs/#webhooks | (in 19) +- wix | Wix | commerce | jwt rsa (event data delivered as signed JWT; verify w/ app public key) | preset=null | AI:med pop:med | https://dev.wix.com/docs/build-apps/develop-your-app/access/authentication/verify-requests-received-from-wix +- commercelayer | Commerce Layer | commerce | hmac-sha256 (x-commercelayer-signature over raw body) | preset=null | AI:low pop:low-med | https://docs.commercelayer.io/core/callbacks-security | (in 19) +- fastspring | FastSpring | billing | hmac-sha256 base64 (X-FS-Signature; optional) | preset=null | AI:med pop:med | https://developer.fastspring.com/docs/message-security +- gumroad | Gumroad | commerce | none (form-urlencoded ping, seller_id check, URL secrecy) | preset=null | AI:low pop:med | https://gumroad.com/ping + +## Email / marketing + +- resend | Resend | email | svix (svix-id/timestamp/signature; SDK webhooks.verify) | preset=svix | AI:high pop:high | https://resend.com/docs/dashboard/webhooks/introduction +- sendgrid | SendGrid | email | ecdsa asymmetric (X-Twilio-Email-Event-Webhook-Signature + -Timestamp; EC public key) | preset=null | AI:high pop:high | https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/getting-started-event-webhook-security-features +- postmark | Postmark | email | none (basic auth / IP allowlist; no HMAC) | preset=null | AI:high pop:high | https://postmarkapp.com/developer/webhooks/webhooks-overview +- mailgun | Mailgun | email | hmac-sha256 hex (signature object IN BODY: timestamp+token; not header) | preset=null | AI:high pop:high | https://documentation.mailgun.com/docs/mailgun/user-manual/webhooks/securing-webhooks +- loops | Loops | email | hmac-sha256 (Webhook-Id/Timestamp/Signature over id.ts.body = standard-webhooks; svix-compatible) | preset=svix? (standard-webhooks) | AI:med pop:med | https://loops.so/docs/webhooks +- customerio | Customer.io | email/marketing | hmac-sha256 hex (X-CIO-Signature over "v0:{ts}:{body}"; X-CIO-Timestamp) | preset=null | AI:high pop:high | https://docs.customer.io/integrations/api/webhooks/ +- mailchimp | Mailchimp (Marketing) | marketing | none (URL secret only; x-www-form-urlencoded) | preset=null | AI:med pop:high | https://mailchimp.com/developer/marketing/guides/sync-audience-data-webhooks/ +- mandrill | Mandrill (MC Transactional) | email | hmac-sha1 base64 (X-Mandrill-Signature over URL+sorted params) | preset=null | AI:med pop:med | https://mailchimp.com/developer/transactional/guides/track-respond-activity-webhooks/ +- klaviyo | Klaviyo | marketing | hmac-sha256 (Klaviyo-Signature + Klaviyo-Timestamp; batched) | preset=null | AI:high pop:high (ecomm) | https://developers.klaviyo.com/en/docs/working_with_system_webhooks +- braze | Braze (Currents Custom HTTP) | marketing | none (optional bearer token; Currents-gated) | preset=null | AI:med pop:med | https://www.braze.com/docs/user_guide/data/distribution/braze_currents/setting_up_currents/custom_http_connector + +## Fintech / banking / crypto + +- plaid | Plaid | fintech | jwt es256 asymmetric (Plaid-Verification JWT; JWK via /webhook_verification_key/get; body sha256 claim; 5min) | preset=null | AI:high pop:high | https://plaid.com/docs/api/webhooks/webhook-verification/ +- coinbase-commerce | Coinbase Commerce | fintech/crypto | hmac-sha256 (X-CC-Webhook-Signature over raw body) | preset=null | AI:med pop:med | https://docs.cdp.coinbase.com/commerce/api-arcitecture/webhooks-security +- gocardless | GoCardless | fintech | hmac-sha256 (Webhook-Signature over raw body) | preset=null | AI:med pop:med | https://developer.gocardless.com/getting-started/stay-up-to-date-with-webhooks-v2/ +- dwolla | Dwolla | fintech | hmac-sha256 (X-Request-Signature-SHA-256 over raw body) | preset=null | AI:med pop:med | https://developers.dwolla.com/docs/balance/webhooks/process-validate +- ramp | Ramp | fintech | hmac-sha256 (X-Ramp-Signature over raw body) | preset=null | AI:high pop:high | https://docs.ramp.com/developer-api/v1/webhooks +- brex | Brex | fintech | svix (svix-id/timestamp/signature, whsec_) | preset=svix | AI:med pop:med-high | https://developer.brex.com/docs/webhooks/ +- mercury | Mercury | fintech | svix-style (whsec_, hmac-sha256; header name unconfirmed) | preset=svix? | AI:med pop:med-high | https://docs.mercury.com/reference/webhooks +- mollie | Mollie | fintech | classic=none (re-fetch); next-gen X-Mollie-Signature sha256= hmac-sha256 | preset=null | AI:med pop:high | https://docs.mollie.com/reference/webhooks-new +- razorpay | Razorpay | fintech | hmac-sha256 hex (X-Razorpay-Signature; x-razorpay-event-id for dedup) | preset=null | AI:high pop:high | https://razorpay.com/docs/webhooks/validate-test/ +- wise | Wise | fintech | rsa asymmetric (X-Signature-SHA256 base64; Wise public key) | preset=null | AI:med pop:med-high | https://docs.wise.com/api-docs/features/webhooks-notifications/event-handling +- bitpay | BitPay | fintech/crypto | none by default (re-fetch invoice via API) | preset=null | AI:low pop:med | https://developer.bitpay.com/docs/invoice-webhooks +- modern-treasury | Modern Treasury | fintech | hmac-sha256 hex (X-Signature over raw body) | preset=null | AI:med pop:med | https://docs.moderntreasury.com/docs/verifying-webhooks + +## Wave 2 preset/cluster notes + +- svix preset cluster grows: resend, brex, mercury (+ replicate/openai/anthropic/clerk from wave 1). standard-webhooks-compatible (same wire format, verify as svix): loops, render, gitlab-signing-token. +- github preset (X-Hub-Signature-256 sha256= HMAC over raw body): github, whatsapp/meta. Close cousins on X-Hub-Signature (no -256): bitbucket (sha256), snyk (sha256), intercom (sha1), jira-classic. Worth a "github/x-hub" verifier family. +- Distinct schemes needing custom configs: ecdsa (sendgrid), es256-jwt (plaid), rsa (paypal, wise, aws-sns, freshchat, paddle-classic, wix), in-body hmac (adyen, mailgun), canonical-request (contentful), url+params hmac (mandrill, trello), query-param sig (pandadoc), ping/poll (airtable, dropbox, google-drive, asana). +- No-signature (sample-only, ship as picker samples but no round-trip): freshdesk, gorgias, livechat, kustomer, aircall, google-drive, mailchimp-marketing, postmark, chargebee, bigcommerce, gumroad, pipedrive, salesforce, cloudflare, revenuecat-default, bitpay, mollie-classic, braze. diff --git a/internal-packages/webhook-sources/catalog/research-notes-wave3.md b/internal-packages/webhook-sources/catalog/research-notes-wave3.md new file mode 100644 index 0000000000..03c7a2d490 --- /dev/null +++ b/internal-packages/webhook-sources/catalog/research-notes-wave3.md @@ -0,0 +1,35 @@ +# Webhook provider seed research, wave 3 (auth / observability / AI-infra additions, 2026-07-14) + +New providers not already in research-notes.md or wave2. + +## Observability + +- sentry | Sentry | observability | hmac-sha256 hex (sentry-hook-signature) | preset=null | AI:high pop:high | https://docs.sentry.io/product/integrations/integration-platform/webhooks/ +- datadog | Datadog | observability | none (custom static header only, e.g. API key) | preset=null | AI:high pop:high | https://docs.datadoghq.com/integrations/webhooks/ +- pagerduty | PagerDuty | observability | hmac-sha256 hex (x-pagerduty-signature v1=; v3 webhooks) | preset=null | AI:high pop:high | https://developer.pagerduty.com/docs/webhooks/webhook-signatures/ + +## Auth / identity + +- auth0 | Auth0 | auth-identity | bearer-token (Custom Webhook log streams; static Authorization) | preset=null | AI:high pop:high | https://auth0.com/docs/customize/log-streams/custom-log-streams +- workos | WorkOS | auth-identity | hmac-sha256 (WorkOS-Signature t=,v= comma-delimited; svix-like construction) | preset=null | AI:high pop:med | https://workos.com/docs/events/data-syncing/webhooks +- okta | Okta | auth-identity | shared-secret header + one-time GET challenge (no HMAC) | preset=null | AI:high pop:high | https://developer.okta.com/docs/concepts/event-hooks/ +- supabase | Supabase | hosting-infra/data | custom header shared secret (pg_net db webhooks; no built-in sig) | preset=null | AI:high pop:high | https://supabase.com/docs/guides/database/webhooks +- EXCLUDE firebase (Firestore/Auth triggers only invoke your own Cloud Functions; no outbound webhook) + +## AI infra / meeting + +- recall-ai | Recall.ai | ai-platform | svix (webhook-id/timestamp/signature, whsec_) | preset=svix | AI:high pop:med | https://docs.recall.ai/docs/authenticating-requests-from-recallai +- fireflies | Fireflies.ai | ai-platform | none/unknown | preset=null | AI:med pop:med | https://docs.fireflies.ai/graphql-api/webhooks + +## CRM / chat additions + +- close-crm | Close | crm | hmac-sha256 (close-sig-hash over ts+payload) | preset=null | AI:high pop:med | https://developer.close.com/topics/webhooks/ +- telegram | Telegram Bot API | communication | shared-secret header (X-Telegram-Bot-Api-Secret-Token, string compare) | preset=null | AI:high pop:high | https://core.telegram.org/bots/api#setwebhook + +## Forms addition + +- jotform | Jotform | forms | none (URL secret / re-fetch by submissionID) | preset=null | AI:med pop:med | https://www.jotform.com/help/245-how-to-send-submission-data-via-a-webhook/ + +## svix cluster now (round-trippable against our svix preset today) + +clerk, resend, openai, anthropic, replicate, brex, mercury, render, recall-ai (+ standard-webhooks-compatible: gitlab-signing-token, loops). diff --git a/internal-packages/webhook-sources/catalog/research-notes.md b/internal-packages/webhook-sources/catalog/research-notes.md new file mode 100644 index 0000000000..6c057885aa --- /dev/null +++ b/internal-packages/webhook-sources/catalog/research-notes.md @@ -0,0 +1,84 @@ +# Webhook provider seed research (raw notes, 2026-07-14) + +Findings from the seed-research subagents. `preset` = one of our exact presets (stripe/github/svix/ +square/discord) where the scheme matches exactly, else null (needs a custom config or ships sample-only). +Rank/tier finalized during the merge into providers.json. + +## Voice AI / conversational voice + +- elevenlabs | ElevenLabs | voice | hmac-sha256 (ElevenLabs-Signature, t=,v0=, over raw bytes; SDK constructEvent) | preset=null | AI:high pop:high | https://elevenlabs.io/docs/eleven-agents/workflows/post-call-webhooks +- vapi | Vapi | voice | hmac-sha256 (X-Vapi-Signature when secret set; else X-Vapi-Secret static) | preset=null | AI:high pop:high | https://docs.vapi.ai/server-url/server-authentication +- retell | Retell AI | voice | hmac-sha256 (x-retell-signature v=,d=; HMAC(body+ts, api_key); 5min replay; SDK verify) | preset=null | AI:high pop:high | https://docs.retellai.com/features/secure-webhook +- bland | Bland AI | voice | hmac-sha256 (X-Webhook-Signature; per webhook-signing doc) | preset=null | AI:med pop:med | https://docs.bland.ai/tutorials/webhook-signing + +## AI / ML compute / STT + +- replicate | Replicate | ai-platform | svix (standard-webhooks exactly, whsec_) | preset=svix | AI:high pop:high | https://replicate.com/docs/topics/webhooks/verify-webhook +- openai | OpenAI | ai-platform | svix (standard-webhooks, whsec_, SDK unwrap) | preset=svix | AI:high pop:high | https://developers.openai.com/api/docs/guides/webhooks +- anthropic | Anthropic (Claude Managed Agents) | ai-platform | svix (X-Webhook-Signature, whsec_, unwrap; NOT for Message Batches) | preset=svix | AI:high pop:high | https://platform.claude.com/docs/en/managed-agents/webhooks +- assemblyai | AssemblyAI | ai-platform | shared-secret (caller-defined webhook_auth_header_name/value, echoed; NOT hmac) | preset=null | AI:med pop:med | https://www.assemblyai.com/docs/concepts/webhooks +- deepgram | Deepgram | ai-platform | none/callback (per-request ?callback=; dg-token or basic auth; no hmac) | preset=null | AI:med pop:med | https://developers.deepgram.com/docs/callback +- huggingface | Hugging Face | ai-platform | basic/shared-secret (X-Webhook-Secret static, or ?secret=) | preset=null | AI:med pop:med | https://huggingface.co/docs/hub/en/webhooks +- runpod | RunPod | ai-platform | none (per-request webhook field; no signing) | preset=null | AI:med pop:med | https://docs.runpod.io/serverless/endpoints/send-requests +- EXCLUDE together-ai (Batch API poll-only, no webhook) +- EXCLUDE modal (inbound web functions only, no outbound webhook) + +## Communication / video + +- slack | Slack | communication | hmac-sha256 (v0:{ts}:{body}, X-Slack-Signature v0=, X-Slack-Request-Timestamp, 5min replay) | preset=null | AI:high pop:high | https://docs.slack.dev/authentication/verifying-requests-from-slack/ +- discord | Discord | communication | ed25519 (X-Signature-Ed25519 + X-Signature-Timestamp over ts+body; both Webhook Events + Interactions) | preset=discord | AI:high pop:high | https://docs.discord.com/developers/events/webhook-events +- microsoft-teams | Microsoft Teams (Outgoing Webhooks) | communication | hmac-sha256 (over raw body, base64 token, Authorization header; NOT the Workflows connector) | preset=null | AI:high pop:high | https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-outgoing-webhook +- zoom | Zoom | communication | hmac-sha256 (v0:{x-zm-request-timestamp}:{body}, x-zm-signature v0=; URL-validation challenge on setup) | preset=null | AI:high pop:high | https://developers.zoom.us/docs/api/webhooks/ +- webex | Cisco Webex | communication | hmac-sha1 (X-Spark-Signature over raw body) | preset=null | AI:med pop:med | https://developer.webex.com/messaging/docs/api/guides/webhooks +- google-calendar | Google Calendar (push/watch) | calendar-scheduling | shared-secret (X-Goog-Channel-Token echo, no crypto sig; thin notification, re-fetch) | preset=null | AI:high pop:high | https://developers.google.com/workspace/calendar/api/guides/push +- microsoft-graph | Microsoft Graph / Outlook | communication | clientState shared-secret default + signed JWT (validationTokens) for rich notifications; setup handshake echoes validationToken | preset=null | AI:high pop:high | https://learn.microsoft.com/en-us/graph/change-notifications-delivery-webhooks +- daily | Daily.co | communication | hmac-sha256 ({X-Webhook-Timestamp}.{body}, base64 secret, X-Webhook-Signature) | preset=null | AI:med pop:med | https://docs.daily.co/reference/rest-api/webhooks +- livekit | LiveKit | communication | jwt (Authorization JWT signed w/ api secret, carries sha256 body hash; SDK WebhookReceiver) | preset=null | AI:med pop:med | https://docs.livekit.io/home/server/webhooks/ +- whereby | Whereby | communication | hmac-sha256 (Stripe-style Whereby-Signature t=,v1=; {ts}.{body}) | preset=null | AI:low pop:med | https://docs.whereby.com/meeting-content-and-quality/insights-suite-and-api/webhooks +- EXCLUDE google-meet (Workspace Events API delivers to Pub/Sub only, no HTTP webhook) + +## SMS / voice / CPaaS + +- twilio | Twilio | communication | hmac-sha1 (X-Twilio-Signature base64 over URL + sorted POST params, key=Auth Token) | preset=null | AI:high pop:high | https://www.twilio.com/docs/usage/webhooks/webhooks-security +- vonage | Vonage | communication | jwt HS256 (Authorization: Bearer, signature secret; optional payload_hash claim) | preset=null | AI:med pop:med | https://developer.vonage.com/en/messages/code-snippets/configure-webhooks +- telnyx | Telnyx | communication | ed25519 (telnyx-signature-ed25519 + telnyx-timestamp over ts|payload; asymmetric public key) | preset=null | AI:med pop:med | https://developers.telnyx.com/docs/voice/programmable-voice/voice-api-webhooks +- bird | Bird (MessageBird) | communication | jwt HS256 (MessageBird-Signature-JWT over URL + body sha256 + ts; legacy raw hmac-sha256) | preset=null | AI:med pop:med | https://docs.bird.com/api/notifications-api/api-reference/webhook-subscriptions/verifying-a-webhook-subscription +- whatsapp | WhatsApp / Meta (Graph) | communication | hmac-sha256 (X-Hub-Signature-256 sha256= over raw body, app secret; GET subscribe handshake) SAME SCHEME AS GITHUB | preset=github? (verify header/format) | AI:high pop:high | https://developers.facebook.com/docs/graph-api/webhooks/getting-started +- plivo | Plivo | communication | hmac-sha256 (X-Plivo-Signature-V3 over URL + sorted params + nonce, base64) | preset=null | AI:med pop:med | https://www.plivo.com/docs/voice/concepts/signature-validation +- sinch | Sinch | communication | hmac-sha256 (X-Sinch-Signature over body.nonce.timestamp) | preset=null | AI:med pop:med | https://developers.sinch.com/docs/conversation/callbacks +- bandwidth | Bandwidth | communication | basic-auth (challenge-response 401 then Basic creds; no payload sig) | preset=null | AI:med pop:med | https://dev.bandwidth.com/docs/numbers/webhooks/ +- ringcentral | RingCentral | communication | shared-secret (Validation-Token echo on setup + optional static Verification Token header; no per-payload sig) | preset=null | AI:med pop:med | https://developers.ringcentral.com/guide/notifications/webhooks/creating-webhooks + +## Payments / commerce / fintech + +- stripe | Stripe | payments | stripe (Stripe-Signature t=,v1=, HMAC-SHA256 {ts}.{body}) | preset=stripe | AI:high pop:high | https://docs.stripe.com/webhooks/signature | (already in 19) +- stripe-issuing | Stripe Issuing | payments | stripe (identical to core Stripe; real-time auth events) | preset=stripe | AI:med pop:med | https://docs.stripe.com/issuing/controls/real-time-authorizations +- paypal | PayPal | payments | rsa-sha256 asymmetric (PAYPAL-TRANSMISSION-SIG + cert URL; verify offline or via API) | preset=null | AI:high pop:high | https://developer.paypal.com/api/rest/webhooks/rest/ | (already in 19) +- square | Square | payments/commerce | hmac-sha256 (x-square-hmacsha256-signature over notificationURL + body; URL in signed string) | preset=square | AI:high pop:high | https://developer.squareup.com/docs/webhooks/step3validate +- adyen | Adyen | payments | hmac-sha256 (signature IN PAYLOAD additionalData.hmacSignature, not header) | preset=null | AI:high pop:high | https://docs.adyen.com/development-resources/webhooks/secure-webhooks/verify-hmac-signatures | (already in 19) +- braintree | Braintree | payments | sdk form-param (bt_signature + bt_payload, hmac-sha1 based; SDK parse only) | preset=null | AI:med pop:med | https://developer.paypal.com/braintree/docs/guides/webhooks/parse/node +- checkout-com | Checkout.com | payments | hmac-sha256 (Cko-Signature over raw body, hex) | preset=null | AI:med pop:med | https://www.checkout.com/docs/developer-resources/event-notifications/receive-webhooks | (maps to existing "checkout" samples) +- authorize-net | Authorize.Net | payments | hmac-sha512 (X-ANET-Signature sha512= over JSON body) | preset=null | AI:med pop:med | https://developer.authorize.net/api/reference/features/webhooks.html +- klarna | Klarna | payments/bnpl | hmac-sha256 (Klarna-Signature over raw body + Klarna-Signing-Key-Id) | preset=null | AI:med pop:high | https://docs.klarna.com/ | CAVEAT: acquirer/route-dependent +- affirm | Affirm | payments/bnpl | hmac-sha512 (X-Affirm-Signature) | preset=null | AI:low pop:med | https://docs.affirm.com/developers/v1.1-developer-reference/docs/about-webhooks | CAVEAT: Key/Enterprise only, not self-serve + +## Calendar / scheduling + +- cal-com | Cal.com | calendar-scheduling | hmac-sha256 (X-Cal-Signature-256 hex over raw body) | preset=null | AI:high pop:high | https://cal.com/docs/developing/guides/automation/webhooks +- calendly | Calendly | calendar-scheduling | hmac-sha256 (Calendly-Webhook-Signature t=,v1= Stripe-style {ts}.{body}) | preset=null | AI:high pop:high | https://developer.calendly.com/api-docs/4c305798a61d3-webhook-signatures +- acuity | Acuity Scheduling | calendar-scheduling | hmac-sha256 BASE64 (X-Acuity-Signature, key=API key) | preset=null | AI:med pop:high | https://developers.acuityscheduling.com/docs/webhooks +- nylas | Nylas | calendar-scheduling | hmac-sha256 (X-Nylas-Signature hex over raw body; compressed = hmac over compressed bytes) | preset=null | AI:med pop:med | https://developer.nylas.com/docs/v3/webhooks/ +- savvycal | SavvyCal | calendar-scheduling | hmac-sha256 (X-Savvycal-Signature sha256=hex) | preset=null | AI:low pop:med | https://savvycal.com/docs/api/webhooks/ +- chili-piper | Chili Piper | calendar-scheduling | none documented (URL secrecy / custom headers) | preset=null | AI:med pop:med | https://help.chilipiper.com | CAVEAT: no verifiable signature +- EXCLUDE doodle (enterprise-gated, undocumented, Zapier-only path) +- EXCLUDE zoho-bookings / zoho-calendar (no native outbound webhooks; webhooks live in Zoho Flow/CRM) + +## Preset-mapping notes for the merge + +- svix preset: replicate, openai, anthropic (+ clerk, resend already planned). +- stripe preset: stripe, stripe-issuing. +- square preset: square. +- discord preset: discord. +- github preset: github; VERIFY whatsapp/meta (X-Hub-Signature-256 sha256= looks identical to GitHub) as a potential github-preset reuse. +- Everything else (generic hmac variants w/ own header+template, ed25519 non-discord, jwt, rsa, basic, shared-secret) = custom verifier config authored later, or ships sample-only for v1. +- Notable signature-scheme diversity to cover in the sample library: hmac-sha1 (twilio, webex, braintree-ish), hmac-sha512 (authorize-net, affirm), rsa-sha256 (paypal), ed25519 (discord, telnyx), jwt-in-authorization (livekit, vonage, bird), in-payload hmac (adyen), shared-secret/echo (google-calendar, ms-graph default, ringcentral, assemblyai, huggingface, bland-alt). diff --git a/internal-packages/webhook-sources/catalog/status.ts b/internal-packages/webhook-sources/catalog/status.ts new file mode 100644 index 0000000000..a0b62f2b46 --- /dev/null +++ b/internal-packages/webhook-sources/catalog/status.ts @@ -0,0 +1,114 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +type Tier = "first-class" | "sample-only"; +type Status = "not-started" | "in-progress" | "complete" | "blocked"; + +type Provider = { + id: string; + label: string; + tier: Tier; + preset: string | null; + sampleSource: string; + sampleCount: number; + status: Status; + owner: string | null; + checklist: Record; + notes: string; +}; + +type Catalog = { + release: string; + updatedAt: string | null; + definitionOfDone: Record; + statusValues: Status[]; + providers: Provider[]; +}; + +const catalogPath = fileURLToPath(new URL("./providers.json", import.meta.url)); +const catalog: Catalog = JSON.parse(readFileSync(catalogPath, "utf8")); + +const dodProgress = (p: Provider) => { + const keys = catalog.definitionOfDone[p.tier] ?? []; + const done = keys.filter((k) => p.checklist[k] === true).length; + return { done, total: keys.length, keys }; +}; + +const derivedComplete = (p: Provider) => { + const { done, total } = dodProgress(p); + return total > 0 && done === total; +}; + +const byStatus = (s: Status) => catalog.providers.filter((p) => p.status === s); + +const args = process.argv.slice(2); + +if (args.includes("--json")) { + const summary = { + release: catalog.release, + updatedAt: catalog.updatedAt, + total: catalog.providers.length, + counts: Object.fromEntries(catalog.statusValues.map((s) => [s, byStatus(s).length])), + remaining: catalog.providers.filter((p) => p.status !== "complete").length, + providers: catalog.providers.map((p) => ({ + id: p.id, + tier: p.tier, + status: p.status, + owner: p.owner, + progress: dodProgress(p), + derivedComplete: derivedComplete(p), + })), + }; + console.log(JSON.stringify(summary, null, 2)); + process.exit(0); +} + +const nextFlagIdx = args.indexOf("--next"); +if (nextFlagIdx !== -1) { + const n = Number(args[nextFlagIdx + 1] ?? "1") || 1; + const claimable = catalog.providers + .filter((p) => p.status === "not-started" && p.owner === null) + .slice(0, n) + .map((p) => p.id); + console.log(claimable.join("\n")); + process.exit(0); +} + +const line = (p: Provider) => { + const { done, total } = dodProgress(p); + const owner = p.owner ? ` owner=${p.owner}` : ""; + const drift = p.status === "complete" && !derivedComplete(p) ? " [!] checklist incomplete" : ""; + const samples = p.sampleCount > 0 ? `${p.sampleCount} samples` : "no samples yet"; + return ` ${p.id.padEnd(12)} [${p.tier.padEnd(11)}] ${done}/${total} preset=${ + p.preset ?? "none" + } ${samples}${owner}${drift}`; +}; + +const total = catalog.providers.length; +const counts = Object.fromEntries(catalog.statusValues.map((s) => [s, byStatus(s).length])); +const remaining = catalog.providers.filter((p) => p.status !== "complete").length; + +console.log(`\nWebhook provider catalog: ${catalog.release}`); +console.log(`Updated: ${catalog.updatedAt ?? "never"}`); +console.log( + `Total ${total} | complete ${counts.complete} in-progress ${counts["in-progress"]} not-started ${counts["not-started"]} blocked ${counts.blocked}` +); +console.log(`Remaining (not complete): ${remaining}\n`); + +for (const s of catalog.statusValues) { + const group = byStatus(s); + if (group.length === 0) continue; + console.log(` ${s} (${group.length}):`); + for (const p of group) console.log(line(p)); + console.log(""); +} + +const claimable = catalog.providers.filter((p) => p.status === "not-started" && p.owner === null); +if (claimable.length > 0) { + console.log(`Next claimable: ${claimable.map((p) => p.id).join(", ")}`); +} else if (remaining === 0) { + console.log("All providers complete."); +} else { + console.log("Nothing claimable (remaining work is in-progress or blocked)."); +} +console.log(""); diff --git a/internal-packages/webhook-sources/package.json b/internal-packages/webhook-sources/package.json new file mode 100644 index 0000000000..fcf013e699 --- /dev/null +++ b/internal-packages/webhook-sources/package.json @@ -0,0 +1,22 @@ +{ + "name": "@internal/webhook-sources", + "private": true, + "version": "0.0.1", + "main": "./src/index.ts", + "types": "./src/index.ts", + "type": "module", + "dependencies": { + "@trigger.dev/core": "workspace:*", + "zod": "3.25.76" + }, + "devDependencies": { + "@internal/webhook-engine": "workspace:*" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest", + "ingest": "tsx src/ingest.ts", + "catalog:status": "tsx catalog/status.ts", + "catalog:aggregate": "tsx catalog/build-aggregates.ts" + } +} diff --git a/internal-packages/webhook-sources/src/generated/hookdeck-samples.json b/internal-packages/webhook-sources/src/generated/hookdeck-samples.json new file mode 100644 index 0000000000..529a000bf2 --- /dev/null +++ b/internal-packages/webhook-sources/src/generated/hookdeck-samples.json @@ -0,0 +1,32875 @@ +[ + { + "provider": "adyen", + "providerLabel": "Adyen", + "eventType": "AUTHENTICATION", + "name": "AUTHENTICATION", + "body": { + "live": "false", + "notificationItems": [ + { + "NotificationRequestItem": { + "additionalData": { + "hmacSignature": "fVt4uUxoilauNk+iAynyImnjEmhfL5TsMgEy8sr3Qts=" + }, + "amount": { + "currency": "EUR", + "value": 0 + }, + "eventCode": "AUTHENTICATION", + "eventDate": "2023-03-08T10:44:17+01:00", + "merchantAccountCode": "OctovieECOM", + "merchantReference": "testMerchantRef1", + "pspReference": "IAIBZKUKOJ77VBVN", + "reason": "", + "success": "true" + } + } + ] + }, + "extraHeaders": { + "is-test-event": "true" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:adyen/1", + "snapshotDate": "2026-07" + } + }, + { + "provider": "adyen", + "providerLabel": "Adyen", + "eventType": "AUTHORISATION", + "name": "AUTHORISATION", + "body": { + "live": "false", + "notificationItems": [ + { + "NotificationRequestItem": { + "additionalData": { + "expiryDate": "12/2012", + "authCode": "1234", + "cardSummary": "7777", + "totalFraudScore": "10", + "hmacSignature": "5j+QNzQ+JjZUZ4ELfDyHRk073Qtbj3vsrYbbswhSjfY=", + "NAME2": "VALUE2", + "NAME1": "VALUE1", + "fraudCheck-6-ShopperIpUsage": "10" + }, + "amount": { + "currency": "EUR", + "value": 10100 + }, + "eventCode": "AUTHORISATION", + "eventDate": "2023-03-08T10:45:05+01:00", + "merchantAccountCode": "OctovieECOM", + "merchantReference": "8313842560770001", + "operations": ["CANCEL", "CAPTURE", "REFUND"], + "paymentMethod": "visa", + "pspReference": "NGAO2BVQFXJ7XZDP", + "reason": "1234:7777:12/2012", + "success": "true" + } + } + ] + }, + "extraHeaders": { + "is-test-event": "true" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:adyen/1", + "snapshotDate": "2026-07" + } + }, + { + "provider": "adyen", + "providerLabel": "Adyen", + "eventType": "AUTHORISATION_ADJUSTMENT", + "name": "AUTHORISATION_ADJUSTMENT", + "body": { + "live": "false", + "notificationItems": [ + { + "NotificationRequestItem": { + "additionalData": { + "hmacSignature": "Rt0X39s+cega7fRFLw7/oz+ntEpvYnjIrM0f4wePr5A=" + }, + "amount": { + "currency": "EUR", + "value": 0 + }, + "eventCode": "AUTHORISATION_ADJUSTMENT", + "eventDate": "2023-03-08T10:45:54+01:00", + "merchantAccountCode": "OctovieECOM", + "merchantReference": "testMerchantRef1", + "pspReference": "FXVJF2KDXLRKMP13", + "reason": "", + "success": "true" + } + } + ] + }, + "extraHeaders": { + "is-test-event": "true" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:adyen/1", + "snapshotDate": "2026-07" + } + }, + { + "provider": "adyen", + "providerLabel": "Adyen", + "eventType": "CANCEL_AUTORESCUE", + "name": "CANCEL_AUTORESCUE", + "body": { + "live": "false", + "notificationItems": [ + { + "NotificationRequestItem": { + "additionalData": { + "hmacSignature": "8qwU5DK3kyotQyuZ065ERccqUZZN6kRiToh0dujLHL8=" + }, + "amount": { + "currency": "EUR", + "value": 0 + }, + "eventCode": "CANCEL_AUTORESCUE", + "eventDate": "2023-03-08T10:50:42+01:00", + "merchantAccountCode": "OctovieECOM", + "merchantReference": "testMerchantRef1", + "pspReference": "1GOILQMXPH5TWPMR", + "reason": "", + "success": "true" + } + } + ] + }, + "extraHeaders": { + "is-test-event": "true" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:adyen/1", + "snapshotDate": "2026-07" + } + }, + { + "provider": "adyen", + "providerLabel": "Adyen", + "eventType": "CANCEL_OR_REFUND", + "name": "CANCEL_OR_REFUND", + "body": { + "live": "false", + "notificationItems": [ + { + "NotificationRequestItem": { + "additionalData": { + "hmacSignature": "7nZNwR2fG0eGoUHk04GAmJ6AZYRMjVlkXXouO/CuLrM=" + }, + "amount": { + "currency": "EUR", + "value": 0 + }, + "eventCode": "CANCEL_OR_REFUND", + "eventDate": "2023-03-08T10:51:22+01:00", + "merchantAccountCode": "OctovieECOM", + "merchantReference": "testMerchantRef1", + "pspReference": "SVHWQYDNR0FWXQA0", + "reason": "", + "success": "true" + } + } + ] + }, + "extraHeaders": { + "is-test-event": "true" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:adyen/1", + "snapshotDate": "2026-07" + } + }, + { + "provider": "adyen", + "providerLabel": "Adyen", + "eventType": "CANCELLATION", + "name": "CANCELLATION", + "body": { + "live": "false", + "notificationItems": [ + { + "NotificationRequestItem": { + "additionalData": { + "hmacSignature": "8AitW6J9BnuCrcGpkNfnF/Td44Lf/WYkTY48GPfMpdQ=" + }, + "amount": { + "currency": "EUR", + "value": 0 + }, + "eventCode": "CANCELLATION", + "eventDate": "2023-03-08T10:46:31+01:00", + "merchantAccountCode": "OctovieECOM", + "merchantReference": "testMerchantRef1", + "pspReference": "HLAESRA2W4YFAN01", + "reason": "", + "success": "true" + } + } + ] + }, + "extraHeaders": { + "is-test-event": "true" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:adyen/1", + "snapshotDate": "2026-07" + } + }, + { + "provider": "adyen", + "providerLabel": "Adyen", + "eventType": "CAPTURE", + "name": "CAPTURE", + "body": { + "live": "false", + "notificationItems": [ + { + "NotificationRequestItem": { + "additionalData": { + "hmacSignature": "M7Qsb8BCxhNTuXNQabdsSvp875QK48Z6qMGz0eHp5Ik=" + }, + "amount": { + "currency": "EUR", + "value": 0 + }, + "eventCode": "CAPTURE", + "eventDate": "2023-03-08T10:52:06+01:00", + "merchantAccountCode": "OctovieECOM", + "merchantReference": "testMerchantRef1", + "pspReference": "QBPLK35VG3MN6HXE", + "reason": "", + "success": "true" + } + } + ] + }, + "extraHeaders": { + "is-test-event": "true" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:adyen/1", + "snapshotDate": "2026-07" + } + }, + { + "provider": "ampersand", + "providerLabel": "Ampersand", + "eventType": "intercom", + "name": "intercom", + "body": { + "projectId": "ampersand-project-id", + "provider": "intercom", + "groupRef": "xyz-company", + "groupName": "XYZ Company", + "installationId": "installation-id", + "objectName": "contacts", + "result": [ + { + "fields": { + "id": "6642abc67d11ea8432bd6029", + "location": { + "city": "Dublin", + "continent_code": "EU", + "country": "Ireland", + "country_code": "IRL", + "region": "Leinster", + "type": "location" + }, + "name": "Example User", + "notes": { + "data": [ + { + "id": "129149253", + "type": "note", + "url": "/notes/129149253" + }, + { + "id": "129149227", + "type": "note", + "url": "/notes/129149227" + } + ], + "has_more": false, + "total_count": 2, + "type": "list", + "url": "/contacts/6642abc67d11ea8432bd6029/notes" + }, + "workspace_id": "xyz" + }, + "raw": { + "android_app_name": null, + "android_app_version": null, + "android_device": null, + "android_last_seen_at": null, + "android_os_version": null, + "android_sdk_version": null, + "avatar": null, + "browser": null, + "browser_language": null, + "browser_version": null, + "companies": { + "data": [ + { + "id": "6642abc67d11ea8432bd602a", + "type": "company", + "url": "/companies/6642abc67d11ea8432bd602a" + } + ], + "has_more": false, + "total_count": 1, + "type": "list", + "url": "/contacts/6642abc67d11ea8432bd6029/companies" + }, + "created_at": 1715645382, + "custom_attributes": {}, + "email": "example.user@projectmap.com", + "external_id": "6ef30a34-914f-4f73-94b8-810758f516bc", + "has_hard_bounced": false, + "id": "6642abc67d11ea8432bd6029", + "ios_app_name": null, + "ios_app_version": null, + "ios_device": null, + "ios_last_seen_at": null, + "ios_os_version": null, + "ios_sdk_version": null, + "language_override": null, + "last_contacted_at": null, + "last_email_clicked_at": null, + "last_email_opened_at": null, + "last_replied_at": null, + "last_seen_at": 1715645382, + "location": { + "city": "Dublin", + "continent_code": "EU", + "country": "Ireland", + "country_code": "IRL", + "region": "Leinster", + "type": "location" + }, + "marked_email_as_spam": false, + "name": "Example User", + "notes": { + "data": [ + { + "id": "129149253", + "type": "note", + "url": "/notes/129149253" + }, + { + "id": "129149227", + "type": "note", + "url": "/notes/129149227" + } + ], + "has_more": false, + "total_count": 2, + "type": "list", + "url": "/contacts/6642abc67d11ea8432bd6029/notes" + }, + "opted_in_subscription_types": { + "data": [], + "has_more": false, + "total_count": 0, + "type": "list", + "url": "/contacts/6642abc67d11ea8432bd6029/subscriptions" + }, + "opted_out_subscription_types": { + "data": [], + "has_more": false, + "total_count": 0, + "type": "list", + "url": "/contacts/6642abc67d11ea8432bd6029/subscriptions" + }, + "os": null, + "owner_id": null, + "phone": null, + "referrer": null, + "role": "user", + "signed_up_at": 1715645382, + "sms_consent": false, + "social_profiles": { + "data": [], + "type": "list" + }, + "tags": { + "data": [], + "has_more": false, + "total_count": 0, + "type": "list", + "url": "/contacts/6642abc67d11ea8432bd6029/tags" + }, + "type": "contact", + "unsubscribed_from_emails": false, + "unsubscribed_from_sms": false, + "updated_at": 1715645383, + "utm_campaign": null, + "utm_content": null, + "utm_medium": null, + "utm_source": null, + "utm_term": null, + "workspace_id": "xyz" + } + }, + { + "fields": { + "id": "664d2e644e1281ed9090338b", + "location": { + "city": null, + "continent_code": null, + "country": null, + "country_code": null, + "region": null, + "type": "location" + }, + "name": "Example Lead", + "notes": { + "data": [], + "has_more": false, + "total_count": 0, + "type": "list", + "url": "/contacts/664d2e644e1281ed9090338b/notes" + }, + "workspace_id": "xyz" + }, + "raw": { + "android_app_name": null, + "android_app_version": null, + "android_device": null, + "android_last_seen_at": null, + "android_os_version": null, + "android_sdk_version": null, + "avatar": null, + "browser": null, + "browser_language": null, + "browser_version": null, + "companies": { + "data": [], + "has_more": false, + "total_count": 0, + "type": "list", + "url": "/contacts/664d2e644e1281ed9090338b/companies" + }, + "created_at": 1716334180, + "custom_attributes": {}, + "email": "lead@example.com", + "external_id": "3d71a921-9276-407d-b3c1-3b506791a48d", + "has_hard_bounced": false, + "id": "664d2e644e1281ed9090338b", + "ios_app_name": null, + "ios_app_version": null, + "ios_device": null, + "ios_last_seen_at": null, + "ios_os_version": null, + "ios_sdk_version": null, + "language_override": null, + "last_contacted_at": null, + "last_email_clicked_at": null, + "last_email_opened_at": null, + "last_replied_at": null, + "last_seen_at": null, + "location": { + "city": null, + "continent_code": null, + "country": null, + "country_code": null, + "region": null, + "type": "location" + }, + "marked_email_as_spam": false, + "name": "Example Lead", + "notes": { + "data": [], + "has_more": false, + "total_count": 0, + "type": "list", + "url": "/contacts/664d2e644e1281ed9090338b/notes" + }, + "opted_in_subscription_types": { + "data": [], + "has_more": false, + "total_count": 0, + "type": "list", + "url": "/contacts/664d2e644e1281ed9090338b/subscriptions" + }, + "opted_out_subscription_types": { + "data": [], + "has_more": false, + "total_count": 0, + "type": "list", + "url": "/contacts/664d2e644e1281ed9090338b/subscriptions" + }, + "os": null, + "owner_id": null, + "phone": null, + "referrer": null, + "role": "lead", + "signed_up_at": null, + "sms_consent": false, + "social_profiles": { + "data": [], + "type": "list" + }, + "tags": { + "data": [], + "has_more": false, + "total_count": 0, + "type": "list", + "url": "/contacts/664d2e644e1281ed9090338b/tags" + }, + "type": "contact", + "unsubscribed_from_emails": false, + "unsubscribed_from_sms": false, + "updated_at": 1716334180, + "utm_campaign": null, + "utm_content": null, + "utm_medium": null, + "utm_source": null, + "utm_term": null, + "workspace_id": "xyz" + } + } + ] + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:ampersand/v1", + "snapshotDate": "2026-07" + } + }, + { + "provider": "ampersand", + "providerLabel": "Ampersand", + "eventType": "salesforce", + "name": "salesforce", + "body": { + "projectId": "ampersand-project-id", + "provider": "salesforce", + "workspace": "salesforce-subdomain", + "groupRef": "xyz-company", + "groupName": "XYZ Company", + "installationId": "installation-id", + "objectName": "contact", + "result": [ + { + "fields": { + "id": "001Dp00000P8QurIAF", + "firstname": "Sally", + "lastname": "Jones", + "email": "sally@jones.net" + }, + "mappedFields": { + "pronoun": "she" + }, + "raw": { + "id": "001Dp00000P8QurIAF", + "firstname": "Sally", + "lastname": "Jones", + "email": "sally@jones.net", + "pronoun_custom_field": "she" + } + }, + { + "fields": { + "id": "001Dp00000P9BusEIS", + "firstname": "Taylor", + "lastname": "Lao", + "email": "taylor.lao@company.com" + }, + "mappedFields": { + "pronoun": "they" + }, + "raw": { + "id": "001Dp00000P9BusEIS", + "firstname": "Taylor", + "lastname": "Lao", + "email": "taylor.lao@company.com", + "pronoun_custom_field": "they" + } + } + ] + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:ampersand/v1", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/brand/metafield/created", + "name": "store/brand/metafield/created", + "body": { + "scope": "store/brand/metafield/created", + "store_id": "1025646", + "data": { + "metafield_id": 16, + "resource_id": "38" + }, + "hash": "352e4afc6dd3fc85ea26bfdf3f91852604d57528", + "created_at": 1561482670, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/cart/converted", + "name": "store/cart/converted", + "body": { + "scope": "store/cart/converted", + "store_id": "1025646", + "data": { + "type": "cart", + "id": "d30016e2-23c0-4a90-884f-2e92ac135476", + "orderId": 252 + }, + "hash": "b86db7c77d7ef8f90d6a8aefa56de32ccd776923", + "created_at": 1561486893, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/cart/couponApplied", + "name": "store/cart/couponApplied", + "body": { + "scope": "store/cart/couponApplied", + "store_id": "1025646", + "data": { + "type": "cart", + "id": "09346904-4175-44fd-be53-f7e598531b6c", + "couponId": 1 + }, + "hash": "4b7297d295141b660e8db5a0d99dfcdf459fe825", + "created_at": 1561482761, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/cart/created", + "name": "store/cart/created", + "body": { + "scope": "store/cart/created", + "store_id": "1025646", + "data": { + "type": "cart", + "id": "09346904-4175-44fd-be53-f7e598531b6c" + }, + "hash": "352e4afc6dd3fc85ea26bfdf3f91852604d57528", + "created_at": 1561482670, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/cart/lineItem/created", + "name": "store/cart/lineItem/created", + "body": { + "scope": "store/cart/lineItem/created", + "store_id": "1025646", + "data": { + "type": "cart_line_item", + "id": "743bfd94-d5dd-47c5-9c19-6eec32ca6119", + "cartId": "b0386708-fef3-45de-9d8b-fbe3031450a4" + }, + "hash": "399321a1bf1ac1331e12826fb89f264b4c8d21a6", + "created_at": 1561481786, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/cart/metafield/created", + "name": "store/cart/metafield/created", + "body": { + "scope": "store/cart/metafield/created", + "store_id": "1025646", + "data": { + "metafield_id": 20, + "resource_id": "29520ea6-2d39-4b39-836e-06ea14bf9f69" + }, + "hash": "352e4afc6dd3fc85ea26bfdf3f91852604d57528", + "created_at": 1561482670, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/category/created", + "name": "store/category/created", + "body": { + "scope": "store/category/created", + "store_id": "1025646", + "data": { + "type": "category", + "id": 42 + }, + "hash": "dc3a47c15425d2c895dba674f86fe71a8f3b6459", + "created_at": 1561480214, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/category/metafield/created", + "name": "store/category/metafield/created", + "body": { + "scope": "store/category/metafield/created", + "store_id": "1025646", + "data": { + "metafield_id": 17, + "resource_id": "24" + }, + "hash": "dc3a47c15425d2c895dba674f86fe71a8f3b6459", + "created_at": 1561480214, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/channel/*/inventory/product/stock_changed", + "name": "store/channel/*/inventory/product/stock_changed", + "body": { + "producer": "stores/{store_hash}", + "hash": "996ea203c939d05ca667a515ef2f029976d6df80", + "created_at": 1682624640, + "store_id": "1234567890", + "scope": "store/channel/*/inventory/product/stock_changed", + "data": { + "variant_id": 127, + "product_id": 113, + "location_id": 2 + } + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/channel/1/cart/created", + "name": "store/channel/1/cart/created", + "body": { + "store_id": "11111", + "producer": "stores/abcde", + "created_at": 1641641646, + "scope": "store/channel/1/cart/created", + "data": { + "cart_id": "41696c19-486f-40a8-ae2a-389d5d24e0c9" + }, + "hash": "3f9ea420af83450d7ef9f78b08c8af25b2213637" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/channel/1/cart/lineItems/created", + "name": "store/channel/1/cart/lineItems/created", + "body": { + "store_id": "11111", + "producer": "stores/abcde", + "created_at": 1641641646, + "scope": "store/channel/1/cart/lineItems/created", + "data": { + "cart_id": "41696c19-486f-40a8-ae2a-389d5d24e0c9", + "cart_item_id": "af133539-0d83-464d-870d-776e2672e8f4" + }, + "hash": "3f9ea420af83450d7ef9f78b08c8af25b2213637" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/channel/1/category/created", + "name": "store/channel/1/category/created", + "body": { + "store_id": "11111", + "producer": "stores/abcde", + "created_at": 1641641646, + "scope": "store/channel/1/category/created", + "data": { + "category_id": 35, + "tree_id": 1 + }, + "hash": "3f9ea420af83450d7ef9f78b08c8af25b2213637" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/channel/1/categoryTree/updated", + "name": "store/channel/1/categoryTree/updated", + "body": { + "store_id": "11111", + "producer": "stores/abcde", + "created_at": 1641641646, + "scope": "store/channel/1/categoryTree/updated", + "data": { + "tree_id": 1 + }, + "hash": "3f9ea420af83450d7ef9f78b08c8af25b2213637" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/channel/1/notifications/abandonedCart/updated", + "name": "store/channel/1/notifications/abandonedCart/updated", + "body": { + "store_id": "11111", + "producer": "stores/abcde", + "created_at": 1641641646, + "scope": "store/channel/1/notifications/abandonedCart/updated", + "data": {}, + "hash": "3f9ea420af83450d7ef9f78b08c8af25b2213637" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/channel/1/order/updated", + "name": "store/channel/1/order/updated", + "body": { + "store_id": "11111", + "producer": "stores/abcde", + "created_at": 1641641646, + "scope": "store/channel/1/order/updated", + "data": { + "order_id": 127 + }, + "hash": "3f9ea420af83450d7ef9f78b08c8af25b2213637" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/channel/1/page/created", + "name": "store/channel/1/page/created", + "body": { + "store_id": "11111", + "producer": "stores/abcde", + "created_at": 1641641646, + "scope": "store/channel/1/page/created", + "data": { + "page_id": 11 + }, + "hash": "3f9ea420af83450d7ef9f78b08c8af25b2213637" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/channel/1/product/assigned", + "name": "store/channel/1/product/assigned", + "body": { + "store_id": "11111", + "producer": "stores/abcde", + "created_at": 1641641646, + "scope": "store/channel/1/product/assigned", + "data": { + "product_id": 127 + }, + "hash": "3f9ea420af83450d7ef9f78b08c8af25b2213637" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/channel/1/script/created", + "name": "store/channel/1/script/created", + "body": { + "store_id": "11111", + "producer": "stores/abcde", + "created_at": 1641641646, + "scope": "store/channel/1/script/created", + "data": { + "uuid": "0187cc6c-cebf-45f9-93b8-7dd0a2e09774" + }, + "hash": "3f9ea420af83450d7ef9f78b08c8af25b2213637" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/channel/1/settings/emailStatus/updated", + "name": "store/channel/1/settings/emailStatus/updated", + "body": { + "store_id": "11111", + "producer": "stores/abcde", + "created_at": 1641641646, + "scope": "store/channel/1/settings/emailStatus/updated", + "data": { + "template_kind": "product_review_email" + }, + "hash": "3f9ea420af83450d7ef9f78b08c8af25b2213637" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/channel/1/settings/route/updated", + "name": "store/channel/1/settings/route/updated", + "body": { + "store_id": "11111", + "producer": "stores/abcde", + "created_at": 1641641646, + "scope": "store/channel/1/settings/route/updated", + "data": { + "site_id": 1000 + }, + "hash": "3f9ea420af83450d7ef9f78b08c8af25b2213637" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/channel/1/settings/searchContextFilters/updated", + "name": "store/channel/1/settings/searchContextFilters/updated", + "body": { + "store_id": "11111", + "producer": "stores/abcde", + "created_at": 1641641646, + "scope": "store/channel/1/settings/searchContextFilters/updated", + "data": { + "category_id": 29 + }, + "hash": "3f9ea420af83450d7ef9f78b08c8af25b2213637" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/channel/1/settings/site/updated", + "name": "store/channel/1/settings/site/updated", + "body": { + "store_id": "11111", + "producer": "stores/abcde", + "created_at": 1641641646, + "scope": "store/channel/1/settings/site/updated", + "data": { + "site_id": 1001 + }, + "hash": "3f9ea420af83450d7ef9f78b08c8af25b2213637" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/channel/1/socialMediaLinks/updated", + "name": "store/channel/1/socialMediaLinks/updated", + "body": { + "store_id": "11111", + "producer": "stores/abcde", + "created_at": 1641641646, + "scope": "store/channel/1/socialMediaLinks/updated", + "data": {}, + "hash": "3f9ea420af83450d7ef9f78b08c8af25b2213637" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/channel/1/theme/configuration/created", + "name": "store/channel/1/theme/configuration/created", + "body": { + "store_id": "11111", + "producer": "stores/abcde", + "created_at": 1641641646, + "scope": "store/channel/1/theme/configuration/created", + "data": { + "theme_id": "e3d82ce0-9bae-0133-0de7-525400970412", + "variation_id": "f49489c0-8678-013a-2933-5227bc3d7181", + "version_id": "f4337c30-8678-013a-2933-5227bc3d7181", + "configuration_id": "2dc1c3f0-b2cf-013a-a341-2aac1278f99c" + }, + "hash": "3f9ea420af83450d7ef9f78b08c8af25b2213637" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/channel/created", + "name": "store/channel/created", + "body": { + "store_id": "11111", + "producer": "stores/abcde", + "created_at": 1335335335, + "scope": "store/channel/created", + "data": { + "type": "channel", + "id": 2 + }, + "hash": "3f9ea420af83450d7ef9f78b08c8af25b2213637" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/channel/metafield/created", + "name": "store/channel/metafield/created", + "body": { + "store_id": "11111", + "producer": "stores/abcde", + "created_at": 1641641646, + "scope": "store/channel/metafield/created", + "data": { + "metafield_id": 21, + "resource_id": "1" + }, + "hash": "3f9ea420af83450d7ef9f78b08c8af25b2213637" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/customer/address/created", + "name": "store/customer/address/created", + "body": { + "scope": "store/customer/address/created", + "store_id": "1025646", + "data": { + "type": "customer", + "id": 60, + "address": { + "customer_id": 32 + } + }, + "hash": "416ca9c01779515de91824aa1cac9012ee691e7a", + "created_at": 1561481620, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/customer/channel/login/access/updated", + "name": "store/customer/channel/login/access/updated", + "body": { + "store_id": "11111", + "producer": "stores/abcde", + "created_at": 1641641646, + "scope": "store/customer/channel/login/access/updated", + "data": { + "customer_id": 22, + "channel_ids": [1] + }, + "hash": "3f9ea420af83450d7ef9f78b08c8af25b2213637" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/customer/created", + "name": "store/customer/created", + "body": { + "scope": "store/customer/created", + "store_id": "1025646", + "data": { + "type": "customer", + "id": 32 + }, + "hash": "8768ab15aa86c6d73c7e4c3efbaee072110ad1d2", + "created_at": 1561481571, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/information/updated", + "name": "store/information/updated", + "body": { + "scope": "store/information/updated", + "store_id": "1025646", + "data": { + "type": "store" + }, + "hash": "c553845e0a5e28dc8b0ea494458692a25586a294", + "created_at": 1535489273, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/inventory/location/created", + "name": "store/inventory/location/created", + "body": { + "producer": "stores/{store_hash}", + "hash": "f23133c2b4caa5a8763b678fed2e3b643348599d", + "created_at": 1682627509, + "store_id": "1234567890", + "scope": "store/inventory/location/created", + "data": { + "location_id": 2 + } + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/inventory/location/metafield/created", + "name": "store/inventory/location/metafield/created", + "body": { + "scope": "store/inventory/location/metafield/created", + "store_id": "1001197568", + "data": { + "metafield_id": 10, + "resource_id": "1" + }, + "hash": "e0c298b8097a6a2f39d17e593a9b360f5b2fef7d", + "created_at": 1683303055, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/metafield/created", + "name": "store/metafield/created", + "body": { + "scope": "store/metafield/created", + "store_id": "1001197568", + "data": { + "metafield_id": 12, + "resource_id": "118", + "resource_type": "product" + }, + "hash": "60f42bd247b52f0647cbd16b1205c84260287141", + "created_at": 1683305167, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/metafield/updated", + "name": "store/metafield/updated", + "body": { + "scope": "store/metafield/updated", + "store_id": "1001197568", + "data": { + "metafield_id": 10, + "resource_id": "1", + "resource_type": "location" + }, + "hash": "e0c298b8097a6a2f39d17e593a9b360f5b2fef7d", + "created_at": 1683303055, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/modifier/updated", + "name": "store/modifier/updated", + "body": { + "scope": "store/modifier/updated", + "store_id": "1025646", + "data": { + "type": "shared_modifier", + "id": 205, + "affected_product_ids": [1, 2], + "context": { + "channel_id": 2, + "locale": "fr" + } + }, + "hash": "a833a57fadd56a32dc752fb6ca0841dc9602a495", + "created_at": 1561479233, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/option/updated", + "name": "store/option/updated", + "body": { + "scope": "store/option/updated", + "store_id": "1025646", + "data": { + "type": "local_option", + "id": 205, + "affected_product_ids": [1, 2], + "context": { + "channel_id": 2, + "locale": "fr" + } + }, + "hash": "a833a57fadd56a32dc752fb6ca0841dc9602a495", + "created_at": 1561479233, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/order/created", + "name": "store/order/created", + "body": { + "scope": "store/order/created", + "store_id": "1025646", + "data": { + "type": "order", + "id": 250 + }, + "hash": "dd70c0976e06b67aaf671e73f49dcb79230ebf9d", + "created_at": 1561479335, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/order/message/created", + "name": "store/order/message/created", + "body": { + "scope": "store/order/message/created", + "store_id": "1025646", + "data": { + "type": "order", + "id": 250, + "message": { + "order_message_id": 3 + } + }, + "hash": "cb07cdbdda8b1965e812693d5988154807eeed02", + "created_at": 1561479923, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/order/metafield/created", + "name": "store/order/metafield/created", + "body": { + "scope": "store/order/metafield/created", + "store_id": "1001197568", + "data": { + "metafield_id": 14, + "resource_id": "270" + }, + "hash": "8fd18449531d7e531587782eb200950cbad8cffe", + "created_at": 1683315012, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/order/refund/created", + "name": "store/order/refund/created", + "body": { + "scope": "store/order/refund/created", + "store_id": "1025646", + "data": { + "type": "order", + "id": 250, + "refund": { + "refund_id": 3 + } + }, + "hash": "cb07cdbdda8b1965e812693d5988154807eeed02", + "created_at": 1561479923, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/order/statusUpdated", + "name": "store/order/statusUpdated", + "body": { + "scope": "store/order/statusUpdated", + "store_id": "1025646", + "data": { + "type": "order", + "id": 250, + "status": { + "previous_status_id": 0, + "new_status_id": 11 + } + }, + "hash": "7ee67cd1cf2ca60bc1aa9e5fe957d2de373be4ca", + "created_at": 1561479335, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/order/transaction/created", + "name": "store/order/transaction/created", + "body": { + "scope": "store/order/transaction/created", + "store_id": "1025646", + "data": { + "type": "transaction", + "order_id": 250, + "transaction_id": "176342342", + "transaction_status": "complete", + "transaction_type": "capture", + "result": { + "code": "captured", + "message": "Captured", + "type": "success" + }, + "platform_transaction_id": "964899682", + "provider_transaction_id": "CK65FBPKR472P4V5" + }, + "hash": "dd70c0976e06b67aaf671e73f49dcb79230ebf9d", + "created_at": 1561479335, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/priceList/assignment/updated", + "name": "store/priceList/assignment/updated", + "body": { + "store_id": "11111", + "producer": "stores/abcde", + "created_at": 1641641646, + "scope": "store/priceList/assignment/updated", + "data": { + "price_list_id": 2, + "channel_id": 1, + "customer_group_id": 3 + }, + "hash": "3f9ea420af83450d7ef9f78b08c8af25b2213637" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/priceList/created", + "name": "store/priceList/created", + "body": { + "scope": "store/priceList/created", + "store_id": "1001197568", + "data": { + "price_list_id": 5 + }, + "hash": "e0c298b8097a6a2f39d17e593a9b360f5b2fef7d", + "created_at": 1709327895, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/priceList/record/created", + "name": "store/priceList/record/created", + "body": { + "scope": "store/priceList/record/created", + "store_id": "1001197568", + "data": { + "price_list_id": 1, + "variant_id": 179, + "currency": "USD" + }, + "hash": "0424a15f158bbfe7277c5f84f9c55a1d4a762e60", + "created_at": 1709327895, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/priceLists/deleted", + "name": "store/priceLists/deleted", + "body": { + "scope": "store/priceLists/deleted", + "store_id": "1001197568", + "data": { + "price_list_ids": [5] + }, + "hash": "e0c298b8097a6a2f39d17e593a9b360f5b2fef7d", + "created_at": 1709327895, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/product/inventory/updated", + "name": "store/product/inventory/updated", + "body": { + "scope": "store/product/inventory/updated", + "store_id": "1025646", + "data": { + "type": "product", + "id": 167, + "inventory": { + "product_id": 167, + "method": "absolute", + "value": 100000000 + } + }, + "hash": "cba9eef399fbd6d384489bca6cacad24794b1086", + "created_at": 1561478843, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/product/updated", + "name": "store/product/updated", + "body": { + "scope": "store/product/updated", + "store_id": "1025646", + "data": { + "type": "product", + "id": 205, + "properties": ["warranty", "is_featured", "custom_fields"], + "context": { + "channel_id": 2, + "locale": "fr" + } + }, + "hash": "a833a57fadd56a32dc752fb6ca0841dc9602a495", + "created_at": 1561479233, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/shipment/created", + "name": "store/shipment/created", + "body": { + "scope": "store/shipment/created", + "store_id": "1025646", + "data": { + "type": "shipment", + "id": 12, + "orderId": 251 + }, + "hash": "8b98021cb0faa7e3a58a0e4182d3696a4bdd24ab", + "created_at": 1561482857, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/sku/created", + "name": "store/sku/created", + "body": { + "scope": "store/sku/created", + "store_id": "1025646", + "data": { + "type": "sku", + "id": 461, + "sku": { + "product_id": 206, + "variant_id": 509 + } + }, + "hash": "7a0866943b1f46cfda31c3218931f5aab83a4c73", + "created_at": 1561480465, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/sku/inventory/updated", + "name": "store/sku/inventory/updated", + "body": { + "scope": "store/sku/inventory/updated", + "store_id": "1025646", + "data": { + "type": "sku", + "id": 461, + "inventory": { + "product_id": 206, + "method": "absolute", + "value": 5, + "variant_id": 509 + } + }, + "hash": "116ddb29d7bc1b2322cc1a4dc295221ee3637d4b", + "created_at": 1561480673, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bigcommerce", + "providerLabel": "BigCommerce", + "eventType": "store/subscriber/created", + "name": "store/subscriber/created", + "body": { + "scope": "store/subscriber/created", + "store_id": "1025646", + "data": { + "type": "subscriber", + "id": 5 + }, + "hash": "bdb6c9c2d17ca7036538e483db0bdd7debc4beb4", + "created_at": 1561482953, + "producer": "stores/{store_hash}" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bigcommerce/2025-04", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bitbucket", + "providerLabel": "Bitbucket", + "eventType": "pullrequest:approved", + "name": "pullrequest:approved", + "body": { + "repository": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "scm": "git", + "website": null, + "owner": { + "display_name": "Hookdeck-write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/%7Ba217ee3e-d18c-4f19-8ebd-82576d644050%7D" + }, + "avatar": { + "href": "https://bitbucket.org/account/hookdeck-write/avatar/" + }, + "html": { + "href": "https://bitbucket.org/%7Ba217ee3e-d18c-4f19-8ebd-82576d644050%7D/" + } + }, + "type": "team", + "uuid": "{a217ee3e-d18c-4f19-8ebd-82576d644050}", + "username": "hookdeck-write" + }, + "workspace": { + "type": "workspace", + "uuid": "{a217ee3e-d18c-4f19-8ebd-82576d644050}", + "name": "Hookdeck-write", + "slug": "hookdeck-write", + "links": { + "avatar": { + "href": "https://bitbucket.org/workspaces/hookdeck-write/avatar/?ts=1675403623" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/hookdeck-write" + } + } + }, + "is_private": true, + "project": { + "type": "project", + "key": "HOOK", + "uuid": "{77c3c8c2-af98-4ce9-9e53-7d1d3dbfacb5}", + "name": "hookdeck-write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/hookdeck-write/projects/HOOK" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/workspace/projects/HOOK" + }, + "avatar": { + "href": "https://bitbucket.org/account/user/hookdeck-write/projects/HOOK/avatar/32?ts=1675403694" + } + } + }, + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + }, + "actor": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "pullrequest": { + "comment_count": 1, + "task_count": 0, + "type": "pullrequest", + "id": 1, + "title": "New changes-multiple", + "description": "* testfile.md created online with Bitbucket\r\n* anotherfile.md created online with Bitbucket\r\n* thirdfile created online with Bitbucket\r\n\r\n‌", + "rendered": { + "title": { + "type": "rendered", + "raw": "New changes-multiple", + "markup": "markdown", + "html": "

New changes-multiple

" + }, + "description": { + "type": "rendered", + "raw": "* testfile.md created online with Bitbucket\r\n* anotherfile.md created online with Bitbucket\r\n* thirdfile created online with Bitbucket\r\n\r\n‌", + "markup": "markdown", + "html": "
    \n
  • testfile.md created online with Bitbucket
  • \n
  • anotherfile.md created online with Bitbucket
  • \n
  • thirdfile created online with Bitbucket
  • \n
\n

" + } + }, + "state": "OPEN", + "merge_commit": null, + "close_source_branch": false, + "closed_by": null, + "author": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "reason": "", + "created_on": "2023-03-08T06:10:12.744328+00:00", + "updated_on": "2023-03-08T06:12:10.654730+00:00", + "destination": { + "branch": { + "name": "master" + }, + "commit": { + "type": "commit", + "hash": "0ccb80619bd1", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0ccb80619bd1" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/0ccb80619bd1" + } + } + }, + "repository": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + } + }, + "source": { + "branch": { + "name": "new-branch" + }, + "commit": { + "type": "commit", + "hash": "0845504f4acb", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0845504f4acb" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/0845504f4acb" + } + } + }, + "repository": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + } + }, + "reviewers": [], + "participants": [ + { + "type": "participant", + "user": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "role": "PARTICIPANT", + "approved": true, + "state": "approved", + "participated_on": "2023-03-08T06:12:10.654730+00:00" + } + ], + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/pull-requests/1" + }, + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/commits" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/approve" + }, + "request-changes": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/request-changes" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/diff/hookdeck-write/hookdeckwrite:0845504f4acb%0D0ccb80619bd1?from_pullrequest_id=1&topic=true" + }, + "diffstat": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/diffstat/hookdeck-write/hookdeckwrite:0845504f4acb%0D0ccb80619bd1?from_pullrequest_id=1&topic=true" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/comments" + }, + "activity": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/activity" + }, + "merge": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/merge" + }, + "decline": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/decline" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/statuses" + } + }, + "summary": { + "type": "rendered", + "raw": "* testfile.md created online with Bitbucket\r\n* anotherfile.md created online with Bitbucket\r\n* thirdfile created online with Bitbucket\r\n\r\n‌", + "markup": "markdown", + "html": "
    \n
  • testfile.md created online with Bitbucket
  • \n
  • anotherfile.md created online with Bitbucket
  • \n
  • thirdfile created online with Bitbucket
  • \n
\n

" + } + }, + "approval": { + "date": "2023-03-08T06:12:10.654730+00:00", + "user": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + } + } + }, + "extraHeaders": { + "x-attempt-number": "1", + "x-b3-sampled": "1", + "x-b3-spanid": "f8b56f7a244cfb56", + "x-b3-traceid": "f8b56f7a244cfb56", + "x-event-key": "pullrequest:approved", + "x-event-time": "Wed, 08 Mar 2023 06:12:10 GMT", + "x-hook-uuid": "91e2a5e1-d608-4a66-bda6-db4e1590b209", + "x-request-uuid": "b2f93568-9ff7-4532-adc0-43212479959d" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bitbucket/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bitbucket", + "providerLabel": "Bitbucket", + "eventType": "pullrequest:comment_created", + "name": "pullrequest:comment_created", + "body": { + "repository": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "scm": "git", + "website": null, + "owner": { + "display_name": "Hookdeck-write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/%7Ba217ee3e-d18c-4f19-8ebd-82576d644050%7D" + }, + "avatar": { + "href": "https://bitbucket.org/account/hookdeck-write/avatar/" + }, + "html": { + "href": "https://bitbucket.org/%7Ba217ee3e-d18c-4f19-8ebd-82576d644050%7D/" + } + }, + "type": "team", + "uuid": "{a217ee3e-d18c-4f19-8ebd-82576d644050}", + "username": "hookdeck-write" + }, + "workspace": { + "type": "workspace", + "uuid": "{a217ee3e-d18c-4f19-8ebd-82576d644050}", + "name": "Hookdeck-write", + "slug": "hookdeck-write", + "links": { + "avatar": { + "href": "https://bitbucket.org/workspaces/hookdeck-write/avatar/?ts=1675403623" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/hookdeck-write" + } + } + }, + "is_private": true, + "project": { + "type": "project", + "key": "HOOK", + "uuid": "{77c3c8c2-af98-4ce9-9e53-7d1d3dbfacb5}", + "name": "hookdeck-write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/hookdeck-write/projects/HOOK" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/workspace/projects/HOOK" + }, + "avatar": { + "href": "https://bitbucket.org/account/user/hookdeck-write/projects/HOOK/avatar/32?ts=1675403694" + } + } + }, + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + }, + "actor": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "pullrequest": { + "comment_count": 1, + "task_count": 0, + "type": "pullrequest", + "id": 1, + "title": "New branch", + "description": "* testfile.md created online with Bitbucket\n* anotherfile.md created online with Bitbucket\n* thirdfile created online with Bitbucket\n\n‌", + "rendered": { + "title": { + "type": "rendered", + "raw": "New branch", + "markup": "markdown", + "html": "

New branch

" + }, + "description": { + "type": "rendered", + "raw": "* testfile.md created online with Bitbucket\n* anotherfile.md created online with Bitbucket\n* thirdfile created online with Bitbucket\n\n‌", + "markup": "markdown", + "html": "
    \n
  • testfile.md created online with Bitbucket
  • \n
  • anotherfile.md created online with Bitbucket
  • \n
  • thirdfile created online with Bitbucket
  • \n
\n

" + } + }, + "state": "OPEN", + "merge_commit": null, + "close_source_branch": false, + "closed_by": null, + "author": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "reason": "", + "created_on": "2023-03-08T06:10:12.744328+00:00", + "updated_on": "2023-03-08T06:11:02.138668+00:00", + "destination": { + "branch": { + "name": "master" + }, + "commit": { + "type": "commit", + "hash": "0ccb80619bd1", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0ccb80619bd1" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/0ccb80619bd1" + } + } + }, + "repository": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + } + }, + "source": { + "branch": { + "name": "new-branch" + }, + "commit": { + "type": "commit", + "hash": "0845504f4acb", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0845504f4acb" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/0845504f4acb" + } + } + }, + "repository": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + } + }, + "reviewers": [], + "participants": [ + { + "type": "participant", + "user": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "role": "PARTICIPANT", + "approved": false, + "state": null, + "participated_on": "2023-03-08T06:11:02.138668+00:00" + } + ], + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/pull-requests/1" + }, + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/commits" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/approve" + }, + "request-changes": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/request-changes" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/diff/hookdeck-write/hookdeckwrite:0845504f4acb%0D0ccb80619bd1?from_pullrequest_id=1&topic=true" + }, + "diffstat": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/diffstat/hookdeck-write/hookdeckwrite:0845504f4acb%0D0ccb80619bd1?from_pullrequest_id=1&topic=true" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/comments" + }, + "activity": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/activity" + }, + "merge": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/merge" + }, + "decline": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/decline" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/statuses" + } + }, + "summary": { + "type": "rendered", + "raw": "* testfile.md created online with Bitbucket\n* anotherfile.md created online with Bitbucket\n* thirdfile created online with Bitbucket\n\n‌", + "markup": "markdown", + "html": "
    \n
  • testfile.md created online with Bitbucket
  • \n
  • anotherfile.md created online with Bitbucket
  • \n
  • thirdfile created online with Bitbucket
  • \n
\n

" + } + }, + "comment": { + "id": 375719071, + "created_on": "2023-03-08T06:11:02.138623+00:00", + "updated_on": "2023-03-08T06:11:02.138668+00:00", + "content": { + "type": "rendered", + "raw": "What change?", + "markup": "markdown", + "html": "

What change?

" + }, + "user": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "deleted": false, + "type": "pullrequest_comment", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/comments/375719071" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/pull-requests/1/_/diff#comment-375719071" + } + }, + "pullrequest": { + "type": "pullrequest", + "id": 1, + "title": "New branch", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/pull-requests/1" + } + } + } + } + }, + "extraHeaders": { + "x-attempt-number": "1", + "x-b3-sampled": "1", + "x-b3-spanid": "337c5f20ea645e9b", + "x-b3-traceid": "337c5f20ea645e9b", + "x-event-key": "pullrequest:comment_created", + "x-event-time": "Wed, 08 Mar 2023 06:11:02 GMT", + "x-hook-uuid": "91e2a5e1-d608-4a66-bda6-db4e1590b209", + "x-request-uuid": "42148ed7-5e52-4ffc-be60-16fadfd9d840" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bitbucket/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bitbucket", + "providerLabel": "Bitbucket", + "eventType": "pullrequest:created", + "name": "pullrequest:created", + "body": { + "repository": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "scm": "git", + "website": null, + "owner": { + "display_name": "Hookdeck-write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/%7Ba217ee3e-d18c-4f19-8ebd-82576d644050%7D" + }, + "avatar": { + "href": "https://bitbucket.org/account/hookdeck-write/avatar/" + }, + "html": { + "href": "https://bitbucket.org/%7Ba217ee3e-d18c-4f19-8ebd-82576d644050%7D/" + } + }, + "type": "team", + "uuid": "{a217ee3e-d18c-4f19-8ebd-82576d644050}", + "username": "hookdeck-write" + }, + "workspace": { + "type": "workspace", + "uuid": "{a217ee3e-d18c-4f19-8ebd-82576d644050}", + "name": "Hookdeck-write", + "slug": "hookdeck-write", + "links": { + "avatar": { + "href": "https://bitbucket.org/workspaces/hookdeck-write/avatar/?ts=1675403623" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/hookdeck-write" + } + } + }, + "is_private": true, + "project": { + "type": "project", + "key": "HOOK", + "uuid": "{77c3c8c2-af98-4ce9-9e53-7d1d3dbfacb5}", + "name": "hookdeck-write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/hookdeck-write/projects/HOOK" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/workspace/projects/HOOK" + }, + "avatar": { + "href": "https://bitbucket.org/account/user/hookdeck-write/projects/HOOK/avatar/32?ts=1675403694" + } + } + }, + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + }, + "actor": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "pullrequest": { + "comment_count": 0, + "task_count": 0, + "type": "pullrequest", + "id": 1, + "title": "New branch", + "description": "* testfile.md created online with Bitbucket\n* anotherfile.md created online with Bitbucket\n* thirdfile created online with Bitbucket\n\n‌", + "rendered": { + "title": { + "type": "rendered", + "raw": "New branch", + "markup": "markdown", + "html": "

New branch

" + }, + "description": { + "type": "rendered", + "raw": "* testfile.md created online with Bitbucket\n* anotherfile.md created online with Bitbucket\n* thirdfile created online with Bitbucket\n\n‌", + "markup": "markdown", + "html": "
    \n
  • testfile.md created online with Bitbucket
  • \n
  • anotherfile.md created online with Bitbucket
  • \n
  • thirdfile created online with Bitbucket
  • \n
\n

" + } + }, + "state": "OPEN", + "merge_commit": null, + "close_source_branch": false, + "closed_by": null, + "author": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "reason": "", + "created_on": "2023-03-08T06:10:12.744328+00:00", + "updated_on": "2023-03-08T06:10:13.341510+00:00", + "destination": { + "branch": { + "name": "master" + }, + "commit": { + "type": "commit", + "hash": "0ccb80619bd1", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0ccb80619bd1" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/0ccb80619bd1" + } + } + }, + "repository": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + } + }, + "source": { + "branch": { + "name": "new-branch" + }, + "commit": { + "type": "commit", + "hash": "0845504f4acb", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0845504f4acb" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/0845504f4acb" + } + } + }, + "repository": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + } + }, + "reviewers": [], + "participants": [], + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/pull-requests/1" + }, + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/commits" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/approve" + }, + "request-changes": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/request-changes" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/diff/hookdeck-write/hookdeckwrite:0845504f4acb%0D0ccb80619bd1?from_pullrequest_id=1&topic=true" + }, + "diffstat": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/diffstat/hookdeck-write/hookdeckwrite:0845504f4acb%0D0ccb80619bd1?from_pullrequest_id=1&topic=true" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/comments" + }, + "activity": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/activity" + }, + "merge": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/merge" + }, + "decline": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/decline" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/statuses" + } + }, + "summary": { + "type": "rendered", + "raw": "* testfile.md created online with Bitbucket\n* anotherfile.md created online with Bitbucket\n* thirdfile created online with Bitbucket\n\n‌", + "markup": "markdown", + "html": "
    \n
  • testfile.md created online with Bitbucket
  • \n
  • anotherfile.md created online with Bitbucket
  • \n
  • thirdfile created online with Bitbucket
  • \n
\n

" + } + } + }, + "extraHeaders": { + "x-attempt-number": "1", + "x-b3-sampled": "1", + "x-b3-spanid": "3d9c67a2d1fee9ed", + "x-b3-traceid": "3d9c67a2d1fee9ed", + "x-event-key": "pullrequest:created", + "x-event-time": "Wed, 08 Mar 2023 06:10:13 GMT", + "x-hook-uuid": "91e2a5e1-d608-4a66-bda6-db4e1590b209", + "x-request-uuid": "3868d64c-e550-4e36-b44d-6ee67f6d4d52" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bitbucket/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bitbucket", + "providerLabel": "Bitbucket", + "eventType": "pullrequest:fulfilled", + "name": "pullrequest:fulfilled", + "body": { + "repository": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "scm": "git", + "website": null, + "owner": { + "display_name": "Hookdeck-write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/%7Ba217ee3e-d18c-4f19-8ebd-82576d644050%7D" + }, + "avatar": { + "href": "https://bitbucket.org/account/hookdeck-write/avatar/" + }, + "html": { + "href": "https://bitbucket.org/%7Ba217ee3e-d18c-4f19-8ebd-82576d644050%7D/" + } + }, + "type": "team", + "uuid": "{a217ee3e-d18c-4f19-8ebd-82576d644050}", + "username": "hookdeck-write" + }, + "workspace": { + "type": "workspace", + "uuid": "{a217ee3e-d18c-4f19-8ebd-82576d644050}", + "name": "Hookdeck-write", + "slug": "hookdeck-write", + "links": { + "avatar": { + "href": "https://bitbucket.org/workspaces/hookdeck-write/avatar/?ts=1675403623" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/hookdeck-write" + } + } + }, + "is_private": true, + "project": { + "type": "project", + "key": "HOOK", + "uuid": "{77c3c8c2-af98-4ce9-9e53-7d1d3dbfacb5}", + "name": "hookdeck-write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/hookdeck-write/projects/HOOK" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/workspace/projects/HOOK" + }, + "avatar": { + "href": "https://bitbucket.org/account/user/hookdeck-write/projects/HOOK/avatar/32?ts=1675403694" + } + } + }, + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + }, + "actor": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "pullrequest": { + "comment_count": 1, + "task_count": 0, + "type": "pullrequest", + "id": 1, + "title": "New changes-multiple", + "description": "* testfile.md created online with Bitbucket\r\n* anotherfile.md created online with Bitbucket\r\n* thirdfile created online with Bitbucket\r\n\r\n‌", + "rendered": { + "title": { + "type": "rendered", + "raw": "New changes-multiple", + "markup": "markdown", + "html": "

New changes-multiple

" + }, + "description": { + "type": "rendered", + "raw": "* testfile.md created online with Bitbucket\r\n* anotherfile.md created online with Bitbucket\r\n* thirdfile created online with Bitbucket\r\n\r\n‌", + "markup": "markdown", + "html": "
    \n
  • testfile.md created online with Bitbucket
  • \n
  • anotherfile.md created online with Bitbucket
  • \n
  • thirdfile created online with Bitbucket
  • \n
\n

" + } + }, + "state": "MERGED", + "merge_commit": { + "type": "commit", + "hash": "ed2903eec378", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/ed2903eec378" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/ed2903eec378" + } + } + }, + "close_source_branch": false, + "closed_by": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "author": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "reason": "", + "created_on": "2023-03-08T06:10:12.744328+00:00", + "updated_on": "2023-03-08T06:12:22.485416+00:00", + "destination": { + "branch": { + "name": "master" + }, + "commit": { + "type": "commit", + "hash": "0ccb80619bd1", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0ccb80619bd1" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/0ccb80619bd1" + } + } + }, + "repository": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + } + }, + "source": { + "branch": { + "name": "new-branch" + }, + "commit": { + "type": "commit", + "hash": "0845504f4acb", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0845504f4acb" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/0845504f4acb" + } + } + }, + "repository": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + } + }, + "reviewers": [], + "participants": [ + { + "type": "participant", + "user": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "role": "PARTICIPANT", + "approved": true, + "state": "approved", + "participated_on": "2023-03-08T06:12:10.654730+00:00" + } + ], + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/pull-requests/1" + }, + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/commits" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/approve" + }, + "request-changes": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/request-changes" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/diff/hookdeck-write/hookdeckwrite:0845504f4acb%0D0ccb80619bd1?from_pullrequest_id=1&topic=true" + }, + "diffstat": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/diffstat/hookdeck-write/hookdeckwrite:0845504f4acb%0D0ccb80619bd1?from_pullrequest_id=1&topic=true" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/comments" + }, + "activity": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/activity" + }, + "merge": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/merge" + }, + "decline": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/decline" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/statuses" + } + }, + "summary": { + "type": "rendered", + "raw": "* testfile.md created online with Bitbucket\r\n* anotherfile.md created online with Bitbucket\r\n* thirdfile created online with Bitbucket\r\n\r\n‌", + "markup": "markdown", + "html": "
    \n
  • testfile.md created online with Bitbucket
  • \n
  • anotherfile.md created online with Bitbucket
  • \n
  • thirdfile created online with Bitbucket
  • \n
\n

" + } + } + }, + "extraHeaders": { + "x-attempt-number": "1", + "x-b3-sampled": "1", + "x-b3-spanid": "5490b6d67b4379e7", + "x-b3-traceid": "5490b6d67b4379e7", + "x-event-key": "pullrequest:fulfilled", + "x-event-time": "Wed, 08 Mar 2023 06:12:22 GMT", + "x-hook-uuid": "91e2a5e1-d608-4a66-bda6-db4e1590b209", + "x-request-uuid": "9236c48d-b51d-458e-8a75-42696866045a" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bitbucket/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bitbucket", + "providerLabel": "Bitbucket", + "eventType": "pullrequest:unapproved", + "name": "pullrequest:unapproved", + "body": { + "repository": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "scm": "git", + "website": null, + "owner": { + "display_name": "Hookdeck-write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/%7Ba217ee3e-d18c-4f19-8ebd-82576d644050%7D" + }, + "avatar": { + "href": "https://bitbucket.org/account/hookdeck-write/avatar/" + }, + "html": { + "href": "https://bitbucket.org/%7Ba217ee3e-d18c-4f19-8ebd-82576d644050%7D/" + } + }, + "type": "team", + "uuid": "{a217ee3e-d18c-4f19-8ebd-82576d644050}", + "username": "hookdeck-write" + }, + "workspace": { + "type": "workspace", + "uuid": "{a217ee3e-d18c-4f19-8ebd-82576d644050}", + "name": "Hookdeck-write", + "slug": "hookdeck-write", + "links": { + "avatar": { + "href": "https://bitbucket.org/workspaces/hookdeck-write/avatar/?ts=1675403623" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/hookdeck-write" + } + } + }, + "is_private": true, + "project": { + "type": "project", + "key": "HOOK", + "uuid": "{77c3c8c2-af98-4ce9-9e53-7d1d3dbfacb5}", + "name": "hookdeck-write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/hookdeck-write/projects/HOOK" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/workspace/projects/HOOK" + }, + "avatar": { + "href": "https://bitbucket.org/account/user/hookdeck-write/projects/HOOK/avatar/32?ts=1675403694" + } + } + }, + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + }, + "actor": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "pullrequest": { + "comment_count": 1, + "task_count": 0, + "type": "pullrequest", + "id": 1, + "title": "New changes-multiple", + "description": "* testfile.md created online with Bitbucket\r\n* anotherfile.md created online with Bitbucket\r\n* thirdfile created online with Bitbucket\r\n\r\n‌", + "rendered": { + "title": { + "type": "rendered", + "raw": "New changes-multiple", + "markup": "markdown", + "html": "

New changes-multiple

" + }, + "description": { + "type": "rendered", + "raw": "* testfile.md created online with Bitbucket\r\n* anotherfile.md created online with Bitbucket\r\n* thirdfile created online with Bitbucket\r\n\r\n‌", + "markup": "markdown", + "html": "
    \n
  • testfile.md created online with Bitbucket
  • \n
  • anotherfile.md created online with Bitbucket
  • \n
  • thirdfile created online with Bitbucket
  • \n
\n

" + } + }, + "state": "OPEN", + "merge_commit": null, + "close_source_branch": false, + "closed_by": null, + "author": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "reason": "", + "created_on": "2023-03-08T06:10:12.744328+00:00", + "updated_on": "2023-03-08T06:12:02.826901+00:00", + "destination": { + "branch": { + "name": "master" + }, + "commit": { + "type": "commit", + "hash": "0ccb80619bd1", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0ccb80619bd1" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/0ccb80619bd1" + } + } + }, + "repository": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + } + }, + "source": { + "branch": { + "name": "new-branch" + }, + "commit": { + "type": "commit", + "hash": "0845504f4acb", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0845504f4acb" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/0845504f4acb" + } + } + }, + "repository": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + } + }, + "reviewers": [], + "participants": [ + { + "type": "participant", + "user": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "role": "PARTICIPANT", + "approved": false, + "state": null, + "participated_on": "2023-03-08T06:11:02.138668+00:00" + } + ], + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/pull-requests/1" + }, + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/commits" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/approve" + }, + "request-changes": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/request-changes" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/diff/hookdeck-write/hookdeckwrite:0845504f4acb%0D0ccb80619bd1?from_pullrequest_id=1&topic=true" + }, + "diffstat": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/diffstat/hookdeck-write/hookdeckwrite:0845504f4acb%0D0ccb80619bd1?from_pullrequest_id=1&topic=true" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/comments" + }, + "activity": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/activity" + }, + "merge": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/merge" + }, + "decline": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/decline" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/statuses" + } + }, + "summary": { + "type": "rendered", + "raw": "* testfile.md created online with Bitbucket\r\n* anotherfile.md created online with Bitbucket\r\n* thirdfile created online with Bitbucket\r\n\r\n‌", + "markup": "markdown", + "html": "
    \n
  • testfile.md created online with Bitbucket
  • \n
  • anotherfile.md created online with Bitbucket
  • \n
  • thirdfile created online with Bitbucket
  • \n
\n

" + } + }, + "approval": { + "date": "2023-03-08T06:11:26.895005+00:00", + "user": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + } + } + }, + "extraHeaders": { + "x-attempt-number": "1", + "x-b3-sampled": "1", + "x-b3-spanid": "9d87ce69bfc31c90", + "x-b3-traceid": "9d87ce69bfc31c90", + "x-event-key": "pullrequest:unapproved", + "x-event-time": "Wed, 08 Mar 2023 06:12:02 GMT", + "x-hook-uuid": "91e2a5e1-d608-4a66-bda6-db4e1590b209", + "x-request-uuid": "27ec303b-67f6-488e-af5a-6d0d9ee3ee34" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bitbucket/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bitbucket", + "providerLabel": "Bitbucket", + "eventType": "pullrequest:updated", + "name": "pullrequest:updated", + "body": { + "repository": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "scm": "git", + "website": null, + "owner": { + "display_name": "Hookdeck-write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/%7Ba217ee3e-d18c-4f19-8ebd-82576d644050%7D" + }, + "avatar": { + "href": "https://bitbucket.org/account/hookdeck-write/avatar/" + }, + "html": { + "href": "https://bitbucket.org/%7Ba217ee3e-d18c-4f19-8ebd-82576d644050%7D/" + } + }, + "type": "team", + "uuid": "{a217ee3e-d18c-4f19-8ebd-82576d644050}", + "username": "hookdeck-write" + }, + "workspace": { + "type": "workspace", + "uuid": "{a217ee3e-d18c-4f19-8ebd-82576d644050}", + "name": "Hookdeck-write", + "slug": "hookdeck-write", + "links": { + "avatar": { + "href": "https://bitbucket.org/workspaces/hookdeck-write/avatar/?ts=1675403623" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/hookdeck-write" + } + } + }, + "is_private": true, + "project": { + "type": "project", + "key": "HOOK", + "uuid": "{77c3c8c2-af98-4ce9-9e53-7d1d3dbfacb5}", + "name": "hookdeck-write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/hookdeck-write/projects/HOOK" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/workspace/projects/HOOK" + }, + "avatar": { + "href": "https://bitbucket.org/account/user/hookdeck-write/projects/HOOK/avatar/32?ts=1675403694" + } + } + }, + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + }, + "actor": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "pullrequest": { + "comment_count": 1, + "task_count": 0, + "type": "pullrequest", + "id": 1, + "title": "New changes-multiple", + "description": "* testfile.md created online with Bitbucket\r\n* anotherfile.md created online with Bitbucket\r\n* thirdfile created online with Bitbucket\r\n\r\n‌", + "rendered": { + "title": { + "type": "rendered", + "raw": "New changes-multiple", + "markup": "markdown", + "html": "

New changes-multiple

" + }, + "description": { + "type": "rendered", + "raw": "* testfile.md created online with Bitbucket\r\n* anotherfile.md created online with Bitbucket\r\n* thirdfile created online with Bitbucket\r\n\r\n‌", + "markup": "markdown", + "html": "
    \n
  • testfile.md created online with Bitbucket
  • \n
  • anotherfile.md created online with Bitbucket
  • \n
  • thirdfile created online with Bitbucket
  • \n
\n

" + } + }, + "state": "OPEN", + "merge_commit": null, + "close_source_branch": false, + "closed_by": null, + "author": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "reason": "", + "created_on": "2023-03-08T06:10:12.744328+00:00", + "updated_on": "2023-03-08T06:11:47.499252+00:00", + "destination": { + "branch": { + "name": "master" + }, + "commit": { + "type": "commit", + "hash": "0ccb80619bd1", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0ccb80619bd1" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/0ccb80619bd1" + } + } + }, + "repository": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + } + }, + "source": { + "branch": { + "name": "new-branch" + }, + "commit": { + "type": "commit", + "hash": "0845504f4acb", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0845504f4acb" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/0845504f4acb" + } + } + }, + "repository": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + } + }, + "reviewers": [], + "participants": [ + { + "type": "participant", + "user": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "role": "PARTICIPANT", + "approved": true, + "state": "approved", + "participated_on": "2023-03-08T06:11:26.895005+00:00" + } + ], + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/pull-requests/1" + }, + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/commits" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/approve" + }, + "request-changes": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/request-changes" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/diff/hookdeck-write/hookdeckwrite:0845504f4acb%0D0ccb80619bd1?from_pullrequest_id=1&topic=true" + }, + "diffstat": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/diffstat/hookdeck-write/hookdeckwrite:0845504f4acb%0D0ccb80619bd1?from_pullrequest_id=1&topic=true" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/comments" + }, + "activity": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/activity" + }, + "merge": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/merge" + }, + "decline": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/decline" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/pullrequests/1/statuses" + } + }, + "summary": { + "type": "rendered", + "raw": "* testfile.md created online with Bitbucket\r\n* anotherfile.md created online with Bitbucket\r\n* thirdfile created online with Bitbucket\r\n\r\n‌", + "markup": "markdown", + "html": "
    \n
  • testfile.md created online with Bitbucket
  • \n
  • anotherfile.md created online with Bitbucket
  • \n
  • thirdfile created online with Bitbucket
  • \n
\n

" + } + } + }, + "extraHeaders": { + "x-attempt-number": "1", + "x-b3-sampled": "1", + "x-b3-spanid": "004d072a2d7b9f7f", + "x-b3-traceid": "004d072a2d7b9f7f", + "x-event-key": "pullrequest:updated", + "x-event-time": "Wed, 08 Mar 2023 06:11:47 GMT", + "x-hook-uuid": "91e2a5e1-d608-4a66-bda6-db4e1590b209", + "x-request-uuid": "122c6e55-234e-41c9-aa50-964f75e3ec4b" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bitbucket/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bitbucket", + "providerLabel": "Bitbucket", + "eventType": "repo:commit_comment_created", + "name": "repo:commit_comment_created", + "body": { + "repository": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "scm": "git", + "website": null, + "owner": { + "display_name": "Hookdeck-write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/%7Ba217ee3e-d18c-4f19-8ebd-82576d644050%7D" + }, + "avatar": { + "href": "https://bitbucket.org/account/hookdeck-write/avatar/" + }, + "html": { + "href": "https://bitbucket.org/%7Ba217ee3e-d18c-4f19-8ebd-82576d644050%7D/" + } + }, + "type": "team", + "uuid": "{a217ee3e-d18c-4f19-8ebd-82576d644050}", + "username": "hookdeck-write" + }, + "workspace": { + "type": "workspace", + "uuid": "{a217ee3e-d18c-4f19-8ebd-82576d644050}", + "name": "Hookdeck-write", + "slug": "hookdeck-write", + "links": { + "avatar": { + "href": "https://bitbucket.org/workspaces/hookdeck-write/avatar/?ts=1675403623" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/hookdeck-write" + } + } + }, + "is_private": true, + "project": { + "type": "project", + "key": "HOOK", + "uuid": "{77c3c8c2-af98-4ce9-9e53-7d1d3dbfacb5}", + "name": "hookdeck-write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/hookdeck-write/projects/HOOK" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/workspace/projects/HOOK" + }, + "avatar": { + "href": "https://bitbucket.org/account/user/hookdeck-write/projects/HOOK/avatar/32?ts=1675403694" + } + } + }, + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + }, + "actor": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "comment": { + "id": 13086145, + "created_on": "2023-03-08T06:09:37.857332+00:00", + "updated_on": "2023-03-08T06:09:37.858505+00:00", + "content": { + "type": "rendered", + "raw": "Marked", + "markup": "markdown", + "html": "

Marked

" + }, + "user": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "deleted": false, + "inline": { + "from": null, + "to": null, + "path": "thirdfile" + }, + "type": "commit_comment", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0845504f4acbfb5f953bda80bf37426adfbe6a04/comments/13086145" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/0845504f4acbfb5f953bda80bf37426adfbe6a04#comment-13086145" + }, + "code": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/diff/0845504f4acbfb5f953bda80bf37426adfbe6a04?path=thirdfile" + } + }, + "commit": { + "type": "commit", + "hash": "0845504f4acbfb5f953bda80bf37426adfbe6a04", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0845504f4acbfb5f953bda80bf37426adfbe6a04" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/0845504f4acbfb5f953bda80bf37426adfbe6a04" + } + } + } + }, + "commit": { + "type": "base_commit", + "hash": "0845504f4acbfb5f953bda80bf37426adfbe6a04", + "message": "thirdfile created online with Bitbucket", + "links": {} + } + }, + "extraHeaders": { + "x-attempt-number": "1", + "x-b3-sampled": "1", + "x-b3-spanid": "fbca366dc8f62bf4", + "x-b3-traceid": "fbca366dc8f62bf4", + "x-event-key": "repo:commit_comment_created", + "x-event-time": "Wed, 08 Mar 2023 06:09:37 GMT", + "x-hook-uuid": "91e2a5e1-d608-4a66-bda6-db4e1590b209", + "x-request-uuid": "e36480d4-e3f4-4fc7-9ebe-bffe878ebbd8" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bitbucket/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bitbucket", + "providerLabel": "Bitbucket", + "eventType": "repo:fork", + "name": "repo:fork", + "body": { + "repository": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "scm": "git", + "website": null, + "owner": { + "display_name": "Hookdeck-write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/%7Ba217ee3e-d18c-4f19-8ebd-82576d644050%7D" + }, + "avatar": { + "href": "https://bitbucket.org/account/hookdeck-write/avatar/" + }, + "html": { + "href": "https://bitbucket.org/%7Ba217ee3e-d18c-4f19-8ebd-82576d644050%7D/" + } + }, + "type": "team", + "uuid": "{a217ee3e-d18c-4f19-8ebd-82576d644050}", + "username": "hookdeck-write" + }, + "workspace": { + "type": "workspace", + "uuid": "{a217ee3e-d18c-4f19-8ebd-82576d644050}", + "name": "Hookdeck-write", + "slug": "hookdeck-write", + "links": { + "avatar": { + "href": "https://bitbucket.org/workspaces/hookdeck-write/avatar/?ts=1675403623" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/hookdeck-write" + } + } + }, + "is_private": true, + "project": { + "type": "project", + "key": "HOOK", + "uuid": "{77c3c8c2-af98-4ce9-9e53-7d1d3dbfacb5}", + "name": "hookdeck-write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/hookdeck-write/projects/HOOK" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/workspace/projects/HOOK" + }, + "avatar": { + "href": "https://bitbucket.org/account/user/hookdeck-write/projects/HOOK/avatar/32?ts=1675403694" + } + } + }, + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + }, + "actor": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "fork": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite-forked", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite-forked" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite-forked" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B281881dc-bcc2-4407-88d4-2e4098d6a197%7D?ts=default" + }, + "pullrequests": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite-forked/pullrequests" + }, + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite-forked/commits" + }, + "forks": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite-forked/forks" + }, + "watchers": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite-forked/watchers" + }, + "branches": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite-forked/refs/branches" + }, + "tags": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite-forked/refs/tags" + }, + "downloads": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite-forked/downloads" + }, + "source": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite-forked/src" + }, + "clone": [ + { + "name": "https", + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite-forked.git" + }, + { + "name": "ssh", + "href": "git@bitbucket.org:hookdeck-write/hookdeckwrite-forked.git" + } + ] + }, + "name": "HookdeckWrite-Forked", + "slug": "hookdeckwrite-forked", + "description": "", + "scm": "git", + "website": null, + "owner": { + "display_name": "Hookdeck-write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/%7Ba217ee3e-d18c-4f19-8ebd-82576d644050%7D" + }, + "avatar": { + "href": "https://bitbucket.org/account/hookdeck-write/avatar/" + }, + "html": { + "href": "https://bitbucket.org/%7Ba217ee3e-d18c-4f19-8ebd-82576d644050%7D/" + } + }, + "type": "team", + "uuid": "{a217ee3e-d18c-4f19-8ebd-82576d644050}", + "username": "hookdeck-write" + }, + "workspace": { + "type": "workspace", + "uuid": "{a217ee3e-d18c-4f19-8ebd-82576d644050}", + "name": "Hookdeck-write", + "slug": "hookdeck-write", + "links": { + "avatar": { + "href": "https://bitbucket.org/workspaces/hookdeck-write/avatar/?ts=1675403623" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/hookdeck-write" + } + } + }, + "is_private": true, + "project": { + "type": "project", + "key": "FOR", + "uuid": "{f1d57457-480d-42ac-9a42-c8ea71141bbb}", + "name": "forked-repo", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/hookdeck-write/projects/FOR" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/workspace/projects/FOR" + }, + "avatar": { + "href": "https://bitbucket.org/account/user/hookdeck-write/projects/FOR/avatar/32?ts=1678220494" + } + } + }, + "fork_policy": "allow_forks", + "created_on": "2023-03-07T20:21:34.640720+00:00", + "updated_on": "2023-03-07T20:21:34.724628+00:00", + "size": 0, + "language": "", + "has_issues": false, + "has_wiki": false, + "uuid": "{281881dc-bcc2-4407-88d4-2e4098d6a197}", + "mainbranch": {}, + "override_settings": {}, + "properties": {}, + "state": null, + "parent": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + } + } + }, + "extraHeaders": { + "x-attempt-number": "1", + "x-b3-sampled": "1", + "x-b3-spanid": "cc57bfb9b2820509", + "x-b3-traceid": "cc57bfb9b2820509", + "x-event-key": "repo:fork", + "x-event-time": "Tue, 07 Mar 2023 20:21:34 GMT", + "x-hook-uuid": "91e2a5e1-d608-4a66-bda6-db4e1590b209", + "x-request-uuid": "452bcde8-0f27-4af6-b1ad-34747cc723f6" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bitbucket/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bitbucket", + "providerLabel": "Bitbucket", + "eventType": "repo:push", + "name": "repo:push", + "body": { + "push": { + "changes": [ + { + "old": { + "name": "master", + "target": { + "type": "commit", + "hash": "0ccb80619bd1a8d7d040e84fd1c745ad10c0e8fc", + "date": "2023-02-05T18:43:11+00:00", + "author": { + "type": "author", + "raw": "Hookdeck Write ", + "user": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + } + }, + "message": "README.md edited online with Bitbucket", + "summary": { + "type": "rendered", + "raw": "README.md edited online with Bitbucket", + "markup": "markdown", + "html": "

README.md edited online with Bitbucket

" + }, + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0ccb80619bd1a8d7d040e84fd1c745ad10c0e8fc" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/0ccb80619bd1a8d7d040e84fd1c745ad10c0e8fc" + } + }, + "parents": [ + { + "type": "commit", + "hash": "f4c075df4f62630dd0e65ae41f0fb4d221018fd1", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/f4c075df4f62630dd0e65ae41f0fb4d221018fd1" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/f4c075df4f62630dd0e65ae41f0fb4d221018fd1" + } + } + } + ], + "rendered": {}, + "properties": {} + }, + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/refs/branches/master" + }, + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commits/master" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/branch/master" + } + }, + "type": "branch", + "merge_strategies": ["merge_commit", "squash", "fast_forward"], + "default_merge_strategy": "merge_commit" + }, + "new": { + "name": "master", + "target": { + "type": "commit", + "hash": "ed2903eec378fe5d4aa389b656420ccd9d5e723d", + "date": "2023-03-08T06:12:18+00:00", + "author": { + "type": "author", + "raw": "Hookdeck Write ", + "user": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + } + }, + "message": "Merged in new-branch (pull request #1)\n\nNew changes-multiple", + "summary": { + "type": "rendered", + "raw": "Merged in new-branch (pull request #1)\n\nNew changes-multiple", + "markup": "markdown", + "html": "

Merged in new-branch (pull request #1)

\n

New changes-multiple

" + }, + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/ed2903eec378fe5d4aa389b656420ccd9d5e723d" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/ed2903eec378fe5d4aa389b656420ccd9d5e723d" + } + }, + "parents": [ + { + "type": "commit", + "hash": "0ccb80619bd1a8d7d040e84fd1c745ad10c0e8fc", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0ccb80619bd1a8d7d040e84fd1c745ad10c0e8fc" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/0ccb80619bd1a8d7d040e84fd1c745ad10c0e8fc" + } + } + }, + { + "type": "commit", + "hash": "0845504f4acbfb5f953bda80bf37426adfbe6a04", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0845504f4acbfb5f953bda80bf37426adfbe6a04" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/0845504f4acbfb5f953bda80bf37426adfbe6a04" + } + } + } + ], + "rendered": {}, + "properties": {} + }, + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/refs/branches/master" + }, + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commits/master" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/branch/master" + } + }, + "type": "branch", + "merge_strategies": ["merge_commit", "squash", "fast_forward"], + "default_merge_strategy": "merge_commit" + }, + "truncated": false, + "created": false, + "forced": false, + "closed": false, + "links": { + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commits?include=ed2903eec378fe5d4aa389b656420ccd9d5e723d&exclude=0ccb80619bd1a8d7d040e84fd1c745ad10c0e8fc" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/diff/ed2903eec378fe5d4aa389b656420ccd9d5e723d..0ccb80619bd1a8d7d040e84fd1c745ad10c0e8fc" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/branches/compare/ed2903eec378fe5d4aa389b656420ccd9d5e723d..0ccb80619bd1a8d7d040e84fd1c745ad10c0e8fc" + } + }, + "commits": [ + { + "type": "commit", + "hash": "ed2903eec378fe5d4aa389b656420ccd9d5e723d", + "date": "2023-03-08T06:12:18+00:00", + "author": { + "type": "author", + "raw": "Hookdeck Write ", + "user": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + } + }, + "message": "Merged in new-branch (pull request #1)\n\nNew changes-multiple", + "summary": { + "type": "rendered", + "raw": "Merged in new-branch (pull request #1)\n\nNew changes-multiple", + "markup": "markdown", + "html": "

Merged in new-branch (pull request #1)

\n

New changes-multiple

" + }, + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/ed2903eec378fe5d4aa389b656420ccd9d5e723d" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/ed2903eec378fe5d4aa389b656420ccd9d5e723d" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/diff/ed2903eec378fe5d4aa389b656420ccd9d5e723d" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/ed2903eec378fe5d4aa389b656420ccd9d5e723d/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/ed2903eec378fe5d4aa389b656420ccd9d5e723d/comments" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/ed2903eec378fe5d4aa389b656420ccd9d5e723d/statuses" + } + }, + "parents": [ + { + "type": "commit", + "hash": "0ccb80619bd1a8d7d040e84fd1c745ad10c0e8fc", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0ccb80619bd1a8d7d040e84fd1c745ad10c0e8fc" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/0ccb80619bd1a8d7d040e84fd1c745ad10c0e8fc" + } + } + }, + { + "type": "commit", + "hash": "0845504f4acbfb5f953bda80bf37426adfbe6a04", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0845504f4acbfb5f953bda80bf37426adfbe6a04" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/0845504f4acbfb5f953bda80bf37426adfbe6a04" + } + } + } + ], + "rendered": {}, + "properties": {} + }, + { + "type": "commit", + "hash": "0845504f4acbfb5f953bda80bf37426adfbe6a04", + "date": "2023-03-08T06:08:37+00:00", + "author": { + "type": "author", + "raw": "Hookdeck Write ", + "user": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + } + }, + "message": "thirdfile created online with Bitbucket", + "summary": { + "type": "rendered", + "raw": "thirdfile created online with Bitbucket", + "markup": "markdown", + "html": "

thirdfile created online with Bitbucket

" + }, + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0845504f4acbfb5f953bda80bf37426adfbe6a04" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/0845504f4acbfb5f953bda80bf37426adfbe6a04" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/diff/0845504f4acbfb5f953bda80bf37426adfbe6a04" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0845504f4acbfb5f953bda80bf37426adfbe6a04/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0845504f4acbfb5f953bda80bf37426adfbe6a04/comments" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0845504f4acbfb5f953bda80bf37426adfbe6a04/statuses" + }, + "patch": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/patch/0845504f4acbfb5f953bda80bf37426adfbe6a04" + } + }, + "parents": [ + { + "type": "commit", + "hash": "a219c19cdff4dbd357732e29f3010ae143e86575", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/a219c19cdff4dbd357732e29f3010ae143e86575" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/a219c19cdff4dbd357732e29f3010ae143e86575" + } + } + } + ], + "rendered": {}, + "properties": {} + }, + { + "type": "commit", + "hash": "a219c19cdff4dbd357732e29f3010ae143e86575", + "date": "2023-03-07T20:57:17+00:00", + "author": { + "type": "author", + "raw": "Hookdeck Write ", + "user": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + } + }, + "message": "anotherfile.md created online with Bitbucket", + "summary": { + "type": "rendered", + "raw": "anotherfile.md created online with Bitbucket", + "markup": "markdown", + "html": "

anotherfile.md created online with Bitbucket

" + }, + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/a219c19cdff4dbd357732e29f3010ae143e86575" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/a219c19cdff4dbd357732e29f3010ae143e86575" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/diff/a219c19cdff4dbd357732e29f3010ae143e86575" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/a219c19cdff4dbd357732e29f3010ae143e86575/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/a219c19cdff4dbd357732e29f3010ae143e86575/comments" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/a219c19cdff4dbd357732e29f3010ae143e86575/statuses" + }, + "patch": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/patch/a219c19cdff4dbd357732e29f3010ae143e86575" + } + }, + "parents": [ + { + "type": "commit", + "hash": "81ba7a30508ff62ffb7e4b3c3101d4bf29212331", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/81ba7a30508ff62ffb7e4b3c3101d4bf29212331" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/81ba7a30508ff62ffb7e4b3c3101d4bf29212331" + } + } + } + ], + "rendered": {}, + "properties": {} + }, + { + "type": "commit", + "hash": "81ba7a30508ff62ffb7e4b3c3101d4bf29212331", + "date": "2023-03-07T20:38:47+00:00", + "author": { + "type": "author", + "raw": "Hookdeck Write ", + "user": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + } + }, + "message": "testfile.md created online with Bitbucket", + "summary": { + "type": "rendered", + "raw": "testfile.md created online with Bitbucket", + "markup": "markdown", + "html": "

testfile.md created online with Bitbucket

" + }, + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/81ba7a30508ff62ffb7e4b3c3101d4bf29212331" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/81ba7a30508ff62ffb7e4b3c3101d4bf29212331" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/diff/81ba7a30508ff62ffb7e4b3c3101d4bf29212331" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/81ba7a30508ff62ffb7e4b3c3101d4bf29212331/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/81ba7a30508ff62ffb7e4b3c3101d4bf29212331/comments" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/81ba7a30508ff62ffb7e4b3c3101d4bf29212331/statuses" + }, + "patch": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/patch/81ba7a30508ff62ffb7e4b3c3101d4bf29212331" + } + }, + "parents": [ + { + "type": "commit", + "hash": "0ccb80619bd1a8d7d040e84fd1c745ad10c0e8fc", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite/commit/0ccb80619bd1a8d7d040e84fd1c745ad10c0e8fc" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite/commits/0ccb80619bd1a8d7d040e84fd1c745ad10c0e8fc" + } + } + } + ], + "rendered": {}, + "properties": {} + } + ] + } + ] + }, + "repository": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "scm": "git", + "website": null, + "owner": { + "display_name": "Hookdeck-write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/%7Ba217ee3e-d18c-4f19-8ebd-82576d644050%7D" + }, + "avatar": { + "href": "https://bitbucket.org/account/hookdeck-write/avatar/" + }, + "html": { + "href": "https://bitbucket.org/%7Ba217ee3e-d18c-4f19-8ebd-82576d644050%7D/" + } + }, + "type": "team", + "uuid": "{a217ee3e-d18c-4f19-8ebd-82576d644050}", + "username": "hookdeck-write" + }, + "workspace": { + "type": "workspace", + "uuid": "{a217ee3e-d18c-4f19-8ebd-82576d644050}", + "name": "Hookdeck-write", + "slug": "hookdeck-write", + "links": { + "avatar": { + "href": "https://bitbucket.org/workspaces/hookdeck-write/avatar/?ts=1675403623" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/hookdeck-write" + } + } + }, + "is_private": true, + "project": { + "type": "project", + "key": "HOOK", + "uuid": "{77c3c8c2-af98-4ce9-9e53-7d1d3dbfacb5}", + "name": "hookdeck-write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/hookdeck-write/projects/HOOK" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/workspace/projects/HOOK" + }, + "avatar": { + "href": "https://bitbucket.org/account/user/hookdeck-write/projects/HOOK/avatar/32?ts=1675403694" + } + } + }, + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + }, + "actor": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + } + }, + "extraHeaders": { + "x-attempt-number": "1", + "x-b3-sampled": "1", + "x-b3-spanid": "d10ac0c40964b71e", + "x-b3-traceid": "d10ac0c40964b71e", + "x-event-key": "repo:push", + "x-event-time": "Wed, 08 Mar 2023 06:12:20 GMT", + "x-hook-uuid": "91e2a5e1-d608-4a66-bda6-db4e1590b209", + "x-request-uuid": "831ba044-6864-4085-bf4c-ae829079e224" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bitbucket/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "bitbucket", + "providerLabel": "Bitbucket", + "eventType": "repo:updated", + "name": "repo:updated", + "body": { + "repository": { + "type": "repository", + "full_name": "hookdeck-write/hookdeckwrite", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/hookdeck-write/hookdeckwrite" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/hookdeckwrite" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B7c9a2905-962d-4fb1-83a1-d316012f033a%7D?ts=default" + } + }, + "name": "HookdeckWrite", + "scm": "git", + "website": null, + "owner": { + "display_name": "Hookdeck-write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/%7Ba217ee3e-d18c-4f19-8ebd-82576d644050%7D" + }, + "avatar": { + "href": "https://bitbucket.org/account/hookdeck-write/avatar/" + }, + "html": { + "href": "https://bitbucket.org/%7Ba217ee3e-d18c-4f19-8ebd-82576d644050%7D/" + } + }, + "type": "team", + "uuid": "{a217ee3e-d18c-4f19-8ebd-82576d644050}", + "username": "hookdeck-write" + }, + "workspace": { + "type": "workspace", + "uuid": "{a217ee3e-d18c-4f19-8ebd-82576d644050}", + "name": "Hookdeck-write", + "slug": "hookdeck-write", + "links": { + "avatar": { + "href": "https://bitbucket.org/workspaces/hookdeck-write/avatar/?ts=1675403623" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/hookdeck-write" + } + } + }, + "is_private": true, + "project": { + "type": "project", + "key": "HOOK", + "uuid": "{77c3c8c2-af98-4ce9-9e53-7d1d3dbfacb5}", + "name": "hookdeck-write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/workspaces/hookdeck-write/projects/HOOK" + }, + "html": { + "href": "https://bitbucket.org/hookdeck-write/workspace/projects/HOOK" + }, + "avatar": { + "href": "https://bitbucket.org/account/user/hookdeck-write/projects/HOOK/avatar/32?ts=1675403694" + } + } + }, + "uuid": "{7c9a2905-962d-4fb1-83a1-d316012f033a}" + }, + "actor": { + "display_name": "Hookdeck Write", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FHW-5.png" + }, + "html": { + "href": "https://bitbucket.org/%7B72df0fc8-d124-495a-aa95-c8e9d5cb2001%7D/" + } + }, + "type": "user", + "uuid": "{72df0fc8-d124-495a-aa95-c8e9d5cb2001}", + "account_id": "63dc9fbb614cb4ba53000891", + "nickname": "Hookdeck Write" + }, + "changes": { + "description": { + "old": "New description", + "new": "Simple Webhook description" + } + } + }, + "extraHeaders": { + "x-attempt-number": "1", + "x-b3-sampled": "1", + "x-b3-spanid": "0f80e24b3f265467", + "x-b3-traceid": "0f80e24b3f265467", + "x-event-key": "repo:updated", + "x-event-time": "Tue, 07 Mar 2023 20:35:15 GMT", + "x-hook-uuid": "91e2a5e1-d608-4a66-bda6-db4e1590b209", + "x-request-uuid": "4bc23bf8-70ac-44da-b2b4-4f5009b1e83b" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:bitbucket/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "checkout", + "providerLabel": "Checkout.com", + "eventType": "authentication_approved", + "name": "authentication_approved", + "body": { + "id": "evt_ewzytrdo7n6udniblrzute5an4", + "type": "authentication_approved", + "version": "1.0.1", + "created_on": "2023-03-03T08:31:04.55Z", + "data": { + "eci": "05", + "cavv": "AAABAVIREQAAAAAAAAAAAAAAAAA=", + "xid": "4b4f21d8-6bd5-4359-9edd-ae7d120b381b", + "challenged": false, + "acs_challenged_mandated": false, + "exemption_applied": "none", + "session_id": "fe4c0bc2-d7ad-4163-bde3-8b39ead240f9", + "amount": "88000", + "currency": "EUR", + "type": "regular", + "challenge_indicator": "no_preference", + "protocol_version": "2.1.0", + "scheme": "visa" + }, + "_links": { + "self": { + "href": "https://api.sandbox.checkout.com/workflows/events/evt_ewzytrdo7n6udniblrzute5an4" + } + } + }, + "extraHeaders": { + "cko-correlation-id": "661c3b59-5dde-4e6b-92fa-c9eabbd80086", + "cko-invocation-id": "ivc_v7x7d2hgcizezdzkjywufw6ueq" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:checkout/1.0.25", + "snapshotDate": "2026-07" + } + }, + { + "provider": "checkout", + "providerLabel": "Checkout.com", + "eventType": "authentication_started", + "name": "authentication_started", + "body": { + "id": "evt_junw6auqvppe5efrknbdfpba6i", + "type": "authentication_started", + "version": "1.0.1", + "created_on": "2023-03-03T08:30:49.37Z", + "data": { + "session_id": "fe4c0bc2-d7ad-4163-bde3-8b39ead240f9", + "amount": "88000", + "currency": "EUR", + "type": "regular", + "challenge_indicator": "no_preference", + "protocol_version": "2.1.0", + "scheme": "visa" + }, + "_links": { + "self": { + "href": "https://api.sandbox.checkout.com/workflows/events/evt_junw6auqvppe5efrknbdfpba6i" + } + } + }, + "extraHeaders": { + "cko-correlation-id": "661c3b59-5dde-4e6b-92fa-c9eabbd80086", + "cko-invocation-id": "ivc_mdfh4hz4k3qudajh52muhtctqu" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:checkout/1.0.25", + "snapshotDate": "2026-07" + } + }, + { + "provider": "checkout", + "providerLabel": "Checkout.com", + "eventType": "payment_approved", + "name": "payment_approved", + "body": { + "id": "evt_htpp6niiutrejjz5dov7pm46hy", + "type": "payment_approved", + "version": "1.0.26", + "created_on": "2023-03-03T08:31:14.4942053Z", + "data": { + "id": "pay_oogtk3sugdxu7bilm4dbvzlmcy", + "action_id": "act_gforaglshmeu3nwqtl43henpny", + "amount": 88000, + "auth_code": "651587", + "currency": "EUR", + "metadata": { + "correlation_id": "c6a4f3f8-bf9b-419c-82d8-d5716877196e" + }, + "payment_type": "Regular", + "processed_on": "2023-03-03T08:31:05.4892639Z", + "processing": { + "acquirer_transaction_id": "280056362716772951030", + "retrieval_reference_number": "578261409697" + }, + "response_code": "10000", + "response_summary": "Approved", + "risk": { + "flagged": false + }, + "3ds": { + "version": "2.1.0", + "challenged": false, + "challenge_indicator": "no_preference", + "exemption": "none", + "eci": "05", + "cavv": "AAABAVIREQAAAAAAAAAAAAAAAAA=", + "xid": "fe4c0bc2-d7ad-4163-bde3-8b39ead240f9", + "downgraded": false, + "enrolled": "Y", + "authentication_response": "Y", + "flow_type": "frictionless" + }, + "scheme_id": "777051524135103", + "source": { + "id": "src_lkpmbqvcdbzutd23xm3hwp7cdi", + "type": "card", + "billing_address": { + "country": "GB" + }, + "expiry_month": 12, + "expiry_year": 2023, + "scheme": "VISA", + "last_4": "0685", + "fingerprint": "9629E3BB7D3888D1C6F7D2517B44332FF09B778B825684D5A4E76972A8A13961", + "bin": "465995", + "card_type": "CREDIT", + "card_category": "CONSUMER", + "issuer": "TELLER, A.S.", + "issuer_country": "NO", + "product_type": "PLATINUM", + "avs_check": "G", + "cvv_check": "Y" + }, + "balances": { + "total_authorized": 88000, + "total_voided": 0, + "available_to_void": 88000, + "total_captured": 0, + "available_to_capture": 88000, + "total_refunded": 0, + "available_to_refund": 0 + }, + "event_links": { + "payment": "https://api.sandbox.checkout.com/payments/pay_oogtk3sugdxu7bilm4dbvzlmcy", + "payment_actions": "https://api.sandbox.checkout.com/payments/pay_oogtk3sugdxu7bilm4dbvzlmcy/actions", + "capture": "https://api.sandbox.checkout.com/payments/pay_oogtk3sugdxu7bilm4dbvzlmcy/captures", + "void": "https://api.sandbox.checkout.com/payments/pay_oogtk3sugdxu7bilm4dbvzlmcy/voids" + } + }, + "_links": { + "self": { + "href": "https://api.sandbox.checkout.com/workflows/events/evt_htpp6niiutrejjz5dov7pm46hy" + }, + "subject": { + "href": "https://api.sandbox.checkout.com/workflows/events/subject/pay_oogtk3sugdxu7bilm4dbvzlmcy" + }, + "payment": { + "href": "https://api.sandbox.checkout.com/payments/pay_oogtk3sugdxu7bilm4dbvzlmcy" + }, + "payment_actions": { + "href": "https://api.sandbox.checkout.com/payments/pay_oogtk3sugdxu7bilm4dbvzlmcy/actions" + }, + "capture": { + "href": "https://api.sandbox.checkout.com/payments/pay_oogtk3sugdxu7bilm4dbvzlmcy/captures" + }, + "void": { + "href": "https://api.sandbox.checkout.com/payments/pay_oogtk3sugdxu7bilm4dbvzlmcy/voids" + } + } + }, + "extraHeaders": { + "cko-correlation-id": "661c3b59-5dde-4e6b-92fa-c9eabbd80086", + "cko-invocation-id": "ivc_2hqbbp6bg2lejjaciqq67sddiy" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:checkout/1.0.25", + "snapshotDate": "2026-07" + } + }, + { + "provider": "checkout", + "providerLabel": "Checkout.com", + "eventType": "payment_captured", + "name": "payment_captured", + "body": { + "id": "evt_uiewxipcyhvefbvc3skk7fpqfq", + "type": "payment_captured", + "version": "1.0.26", + "created_on": "2023-03-03T08:18:57.8376703Z", + "data": { + "id": "pay_ht4n7ohvpc7epirpfco6wdou24", + "action_id": "act_24iebe4b2atuhj4iemx7a4dx6q", + "amount": 12000, + "processed_on": "2023-03-03T08:18:52.1378682Z", + "response_code": "10000", + "response_summary": "Approved", + "balances": { + "total_authorized": 12000, + "total_voided": 0, + "available_to_void": 0, + "total_captured": 12000, + "available_to_capture": 0, + "total_refunded": 0, + "available_to_refund": 12000 + }, + "metadata": { + "correlation_id": "bb17cc43-3827-4190-ab70-d374244afac0" + }, + "currency": "EUR", + "processing": { + "acquirer_transaction_id": "141625565560457107263", + "acquirer_reference_number": "33255926420936831486570" + }, + "event_links": { + "payment": "https://api.sandbox.checkout.com/payments/pay_ht4n7ohvpc7epirpfco6wdou24", + "payment_actions": "https://api.sandbox.checkout.com/payments/pay_ht4n7ohvpc7epirpfco6wdou24/actions", + "refund": "https://api.sandbox.checkout.com/payments/pay_ht4n7ohvpc7epirpfco6wdou24/refunds" + } + }, + "_links": { + "self": { + "href": "https://api.sandbox.checkout.com/workflows/events/evt_uiewxipcyhvefbvc3skk7fpqfq" + }, + "subject": { + "href": "https://api.sandbox.checkout.com/workflows/events/subject/pay_ht4n7ohvpc7epirpfco6wdou24" + }, + "payment": { + "href": "https://api.sandbox.checkout.com/payments/pay_ht4n7ohvpc7epirpfco6wdou24" + }, + "payment_actions": { + "href": "https://api.sandbox.checkout.com/payments/pay_ht4n7ohvpc7epirpfco6wdou24/actions" + }, + "refund": { + "href": "https://api.sandbox.checkout.com/payments/pay_ht4n7ohvpc7epirpfco6wdou24/refunds" + } + } + }, + "extraHeaders": { + "cko-correlation-id": "74432c84-df9d-43d7-9a04-869c315d2d9e", + "cko-invocation-id": "ivc_5uo3ctib3fbeja3ye6yjquezfy" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:checkout/1.0.25", + "snapshotDate": "2026-07" + } + }, + { + "provider": "checkout", + "providerLabel": "Checkout.com", + "eventType": "payment_voided", + "name": "payment_voided", + "body": { + "id": "evt_c2oy4t4w7s7u5ebyx2ujy2fbg4", + "type": "payment_voided", + "version": "1.0.26", + "created_on": "2023-03-03T08:17:12.3289437Z", + "data": { + "id": "pay_rvaqaesek3eejfly4gt7d7eopa", + "action_id": "act_vsbujgvslqhupiaph24hpjfhwq", + "amount": 6000, + "processed_on": "2023-03-03T08:17:06.0517435Z", + "response_code": "10000", + "response_summary": "Approved", + "balances": { + "total_authorized": 6000, + "total_voided": 6000, + "available_to_void": 0, + "total_captured": 0, + "available_to_capture": 0, + "total_refunded": 0, + "available_to_refund": 0 + }, + "metadata": { + "correlation_id": "20c627d6-e909-43db-8f29-b443379b3b60" + }, + "currency": "EUR", + "event_links": { + "payment": "https://api.sandbox.checkout.com/payments/pay_rvaqaesek3eejfly4gt7d7eopa", + "payment_actions": "https://api.sandbox.checkout.com/payments/pay_rvaqaesek3eejfly4gt7d7eopa/actions" + } + }, + "_links": { + "self": { + "href": "https://api.sandbox.checkout.com/workflows/events/evt_c2oy4t4w7s7u5ebyx2ujy2fbg4" + }, + "subject": { + "href": "https://api.sandbox.checkout.com/workflows/events/subject/pay_rvaqaesek3eejfly4gt7d7eopa" + }, + "payment": { + "href": "https://api.sandbox.checkout.com/payments/pay_rvaqaesek3eejfly4gt7d7eopa" + }, + "payment_actions": { + "href": "https://api.sandbox.checkout.com/payments/pay_rvaqaesek3eejfly4gt7d7eopa/actions" + } + } + }, + "extraHeaders": { + "cko-correlation-id": "a820d422-879d-4542-b316-a78bc3920bd3", + "cko-invocation-id": "ivc_hgwdghg4ajdehpsjmmeazeb2de" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:checkout/1.0.25", + "snapshotDate": "2026-07" + } + }, + { + "provider": "commercelayer", + "providerLabel": "Commerce Layer", + "eventType": "customers.create", + "name": "customers.create", + "body": { + "data": { + "id": "njmyhybwre", + "type": "customers", + "links": { + "self": "/api/customers/njmyhybwre" + }, + "attributes": { + "email": "creted.cus@mail.com", + "status": "prospect", + "has_password": true, + "created_at": "2023-03-17T10:17:18.679Z", + "updated_at": "2023-03-17T10:17:18.679Z", + "reference": "", + "reference_origin": "", + "metadata": {} + }, + "relationships": { + "customer_group": { + "links": { + "self": "/api/customers/njmyhybwre/relationships/customer_group", + "related": "/api/customers/njmyhybwre/customer_group" + } + }, + "customer_addresses": { + "links": { + "self": "/api/customers/njmyhybwre/relationships/customer_addresses", + "related": "/api/customers/njmyhybwre/customer_addresses" + } + }, + "customer_payment_sources": { + "links": { + "self": "/api/customers/njmyhybwre/relationships/customer_payment_sources", + "related": "/api/customers/njmyhybwre/customer_payment_sources" + } + }, + "customer_subscriptions": { + "links": { + "self": "/api/customers/njmyhybwre/relationships/customer_subscriptions", + "related": "/api/customers/njmyhybwre/customer_subscriptions" + } + }, + "orders": { + "links": { + "self": "/api/customers/njmyhybwre/relationships/orders", + "related": "/api/customers/njmyhybwre/orders" + } + }, + "order_subscriptions": { + "links": { + "self": "/api/customers/njmyhybwre/relationships/order_subscriptions", + "related": "/api/customers/njmyhybwre/order_subscriptions" + } + }, + "returns": { + "links": { + "self": "/api/customers/njmyhybwre/relationships/returns", + "related": "/api/customers/njmyhybwre/returns" + } + }, + "sku_lists": { + "links": { + "self": "/api/customers/njmyhybwre/relationships/sku_lists", + "related": "/api/customers/njmyhybwre/sku_lists" + } + }, + "attachments": { + "links": { + "self": "/api/customers/njmyhybwre/relationships/attachments", + "related": "/api/customers/njmyhybwre/attachments" + } + }, + "events": { + "links": { + "self": "/api/customers/njmyhybwre/relationships/events", + "related": "/api/customers/njmyhybwre/events" + } + } + }, + "meta": { + "mode": "test", + "organization_id": "jXdWbFWAYy" + } + } + }, + "extraHeaders": { + "newrelic": "eyJ2IjpbMCwxXSwiZCI6eyJ0eSI6IkFwcCIsImFjIjoiMjA5NzI3NiIsImFwIjoiODM4ODI2MDUiLCJ0eCI6IjQyZTgzMGFkYTQ5NGMwNDUiLCJ0ciI6ImU2Y2UwMGZjZjVkNDczOWNlOWZlMzUxMWI2OTcyZGVkIiwic2EiOmZhbHNlLCJwciI6MC45OTIwNzksInRpIjoxNjc5MDQ4MjM4NzA0LCJpZCI6IjE2MzI4MTZjYjVhZmU3ZWYifX0=", + "tracestate": "2097276@nr=0-0-2097276-83882605-1632816cb5afe7ef-42e830ada494c045-0-0.992079-1679048238704", + "x-commercelayer-topic": "customers.create" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:commercelayer/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "commercelayer", + "providerLabel": "Commerce Layer", + "eventType": "gift_cards.activate", + "name": "gift_cards.activate", + "body": { + "data": { + "id": "lEnKUPNBQD", + "type": "gift_cards", + "links": { + "self": "/api/gift_cards/lEnKUPNBQD" + }, + "attributes": { + "status": "active", + "code": "1584b1f2-f31e-4154-b6a7-1aac408d918e", + "currency_code": "USD", + "initial_balance_cents": 995, + "initial_balance_float": 9.95, + "formatted_initial_balance": "$9.95", + "balance_cents": 995, + "balance_float": 9.95, + "formatted_balance": "$9.95", + "balance_max_cents": 2000, + "balance_max_float": 20, + "formatted_balance_max": "$20.00", + "balance_log": [ + { + "datetime": "2023-03-17T10:34:46.786Z", + "balance_change_cents": 995 + } + ], + "single_use": false, + "rechargeable": true, + "image_url": "", + "expires_at": null, + "recipient_email": "pokaka7480@asoflex.com", + "created_at": "2023-03-17T10:34:46.786Z", + "updated_at": "2023-03-17T10:37:35.257Z", + "reference": "gifted", + "reference_origin": "bonus", + "metadata": {} + }, + "relationships": { + "market": { + "links": { + "self": "/api/gift_cards/lEnKUPNBQD/relationships/market", + "related": "/api/gift_cards/lEnKUPNBQD/market" + } + }, + "gift_card_recipient": { + "links": { + "self": "/api/gift_cards/lEnKUPNBQD/relationships/gift_card_recipient", + "related": "/api/gift_cards/lEnKUPNBQD/gift_card_recipient" + } + }, + "attachments": { + "links": { + "self": "/api/gift_cards/lEnKUPNBQD/relationships/attachments", + "related": "/api/gift_cards/lEnKUPNBQD/attachments" + } + }, + "events": { + "links": { + "self": "/api/gift_cards/lEnKUPNBQD/relationships/events", + "related": "/api/gift_cards/lEnKUPNBQD/events" + } + } + }, + "meta": { + "mode": "test", + "organization_id": "jXdWbFWAYy" + } + } + }, + "extraHeaders": { + "newrelic": "eyJ2IjpbMCwxXSwiZCI6eyJ0eSI6IkFwcCIsImFjIjoiMjA5NzI3NiIsImFwIjoiODM4ODI2MDUiLCJ0eCI6IjBlYWQxOGU0NDk0YTYyOWIiLCJ0ciI6IjJhNzQ1OTI2MDE3NDMwNzkxZmVhM2JmNTU1NjhmMDIwIiwic2EiOmZhbHNlLCJwciI6MC4xMDM0NzksInRpIjoxNjc5MDQ5NDU1MjcyLCJpZCI6ImRhZTdmOWVkMjMyZWMyNTEifX0=", + "tracestate": "2097276@nr=0-0-2097276-83882605-dae7f9ed232ec251-0ead18e4494a629b-0-0.103479-1679049455272", + "x-commercelayer-topic": "gift_cards.activate" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:commercelayer/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "commercelayer", + "providerLabel": "Commerce Layer", + "eventType": "gift_cards.create", + "name": "gift_cards.create", + "body": { + "data": { + "id": "lEnKUPNBQD", + "type": "gift_cards", + "links": { + "self": "/api/gift_cards/lEnKUPNBQD" + }, + "attributes": { + "status": "draft", + "code": "1584b1f2-f31e-4154-b6a7-1aac408d918e", + "currency_code": "USD", + "initial_balance_cents": 995, + "initial_balance_float": 9.95, + "formatted_initial_balance": "$9.95", + "balance_cents": 995, + "balance_float": 9.95, + "formatted_balance": "$9.95", + "balance_max_cents": 2000, + "balance_max_float": 20, + "formatted_balance_max": "$20.00", + "balance_log": [ + { + "datetime": "2023-03-17T10:34:46.786Z", + "balance_change_cents": 995 + } + ], + "single_use": false, + "rechargeable": true, + "image_url": "", + "expires_at": null, + "recipient_email": "pokaka7480@asoflex.com", + "created_at": "2023-03-17T10:34:46.786Z", + "updated_at": "2023-03-17T10:34:46.786Z", + "reference": "gifted", + "reference_origin": "bonus", + "metadata": {} + }, + "relationships": { + "market": { + "links": { + "self": "/api/gift_cards/lEnKUPNBQD/relationships/market", + "related": "/api/gift_cards/lEnKUPNBQD/market" + } + }, + "gift_card_recipient": { + "links": { + "self": "/api/gift_cards/lEnKUPNBQD/relationships/gift_card_recipient", + "related": "/api/gift_cards/lEnKUPNBQD/gift_card_recipient" + } + }, + "attachments": { + "links": { + "self": "/api/gift_cards/lEnKUPNBQD/relationships/attachments", + "related": "/api/gift_cards/lEnKUPNBQD/attachments" + } + }, + "events": { + "links": { + "self": "/api/gift_cards/lEnKUPNBQD/relationships/events", + "related": "/api/gift_cards/lEnKUPNBQD/events" + } + } + }, + "meta": { + "mode": "test", + "organization_id": "jXdWbFWAYy" + } + } + }, + "extraHeaders": { + "newrelic": "eyJ2IjpbMCwxXSwiZCI6eyJ0eSI6IkFwcCIsImFjIjoiMjA5NzI3NiIsImFwIjoiODM4ODI2MDUiLCJ0eCI6IjMzZDA5ZTMyN2EzMGNhNTIiLCJ0ciI6IjQ2ZDk1ZDJmZmU4ZDRhMjA2YWZjZjY5YjQ3ODYyMjFlIiwic2EiOmZhbHNlLCJwciI6MC42MDg5NjUsInRpIjoxNjc5MDQ5Mjg2ODE1LCJpZCI6ImJhMjBiNGUwNTExY2MwY2UifX0=", + "tracestate": "2097276@nr=0-0-2097276-83882605-ba20b4e0511cc0ce-33d09e327a30ca52-0-0.608965-1679049286815", + "x-commercelayer-topic": "gift_cards.create" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:commercelayer/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "commercelayer", + "providerLabel": "Commerce Layer", + "eventType": "gift_cards.deactivate", + "name": "gift_cards.deactivate", + "body": { + "data": { + "id": "lEnKUPNBQD", + "type": "gift_cards", + "links": { + "self": "/api/gift_cards/lEnKUPNBQD" + }, + "attributes": { + "status": "inactive", + "code": "1584b1f2-f31e-4154-b6a7-1aac408d918e", + "currency_code": "USD", + "initial_balance_cents": 995, + "initial_balance_float": 9.95, + "formatted_initial_balance": "$9.95", + "balance_cents": 995, + "balance_float": 9.95, + "formatted_balance": "$9.95", + "balance_max_cents": 2000, + "balance_max_float": 20, + "formatted_balance_max": "$20.00", + "balance_log": [ + { + "datetime": "2023-03-17T10:34:46.786Z", + "balance_change_cents": 995 + } + ], + "single_use": false, + "rechargeable": true, + "image_url": "", + "expires_at": null, + "recipient_email": "pokaka7480@asoflex.com", + "created_at": "2023-03-17T10:34:46.786Z", + "updated_at": "2023-03-17T10:38:48.325Z", + "reference": "gifted", + "reference_origin": "bonus", + "metadata": {} + }, + "relationships": { + "market": { + "links": { + "self": "/api/gift_cards/lEnKUPNBQD/relationships/market", + "related": "/api/gift_cards/lEnKUPNBQD/market" + } + }, + "gift_card_recipient": { + "links": { + "self": "/api/gift_cards/lEnKUPNBQD/relationships/gift_card_recipient", + "related": "/api/gift_cards/lEnKUPNBQD/gift_card_recipient" + } + }, + "attachments": { + "links": { + "self": "/api/gift_cards/lEnKUPNBQD/relationships/attachments", + "related": "/api/gift_cards/lEnKUPNBQD/attachments" + } + }, + "events": { + "links": { + "self": "/api/gift_cards/lEnKUPNBQD/relationships/events", + "related": "/api/gift_cards/lEnKUPNBQD/events" + } + } + }, + "meta": { + "mode": "test", + "organization_id": "jXdWbFWAYy" + } + } + }, + "extraHeaders": { + "newrelic": "eyJ2IjpbMCwxXSwiZCI6eyJ0eSI6IkFwcCIsImFjIjoiMjA5NzI3NiIsImFwIjoiODM4ODI2MDUiLCJ0eCI6ImM2NDVjOGU5MjNhZWEzYzkiLCJ0ciI6IjJjMjQxN2Y2NjE1ZjJhZTg3Y2QzNzkzYmE3OGQxYTg3Iiwic2EiOmZhbHNlLCJwciI6MC4xNzMwNDEsInRpIjoxNjc5MDQ5NTI4MzM5LCJpZCI6IjEzMzk1M2JhNmI3M2VjZTUifX0=", + "tracestate": "2097276@nr=0-0-2097276-83882605-133953ba6b73ece5-c645c8e923aea3c9-0-0.173041-1679049528339", + "x-commercelayer-topic": "gift_cards.deactivate" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:commercelayer/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "commercelayer", + "providerLabel": "Commerce Layer", + "eventType": "gift_cards.purchase", + "name": "gift_cards.purchase", + "body": { + "data": { + "id": "lEnKUPNBQD", + "type": "gift_cards", + "links": { + "self": "/api/gift_cards/lEnKUPNBQD" + }, + "attributes": { + "status": "inactive", + "code": "1584b1f2-f31e-4154-b6a7-1aac408d918e", + "currency_code": "USD", + "initial_balance_cents": 995, + "initial_balance_float": 9.95, + "formatted_initial_balance": "$9.95", + "balance_cents": 995, + "balance_float": 9.95, + "formatted_balance": "$9.95", + "balance_max_cents": 2000, + "balance_max_float": 20, + "formatted_balance_max": "$20.00", + "balance_log": [ + { + "datetime": "2023-03-17T10:34:46.786Z", + "balance_change_cents": 995 + } + ], + "single_use": false, + "rechargeable": true, + "image_url": "", + "expires_at": null, + "recipient_email": "pokaka7480@asoflex.com", + "created_at": "2023-03-17T10:34:46.786Z", + "updated_at": "2023-03-17T10:36:40.682Z", + "reference": "gifted", + "reference_origin": "bonus", + "metadata": {} + }, + "relationships": { + "market": { + "links": { + "self": "/api/gift_cards/lEnKUPNBQD/relationships/market", + "related": "/api/gift_cards/lEnKUPNBQD/market" + } + }, + "gift_card_recipient": { + "links": { + "self": "/api/gift_cards/lEnKUPNBQD/relationships/gift_card_recipient", + "related": "/api/gift_cards/lEnKUPNBQD/gift_card_recipient" + } + }, + "attachments": { + "links": { + "self": "/api/gift_cards/lEnKUPNBQD/relationships/attachments", + "related": "/api/gift_cards/lEnKUPNBQD/attachments" + } + }, + "events": { + "links": { + "self": "/api/gift_cards/lEnKUPNBQD/relationships/events", + "related": "/api/gift_cards/lEnKUPNBQD/events" + } + } + }, + "meta": { + "mode": "test", + "organization_id": "jXdWbFWAYy" + } + } + }, + "extraHeaders": { + "newrelic": "eyJ2IjpbMCwxXSwiZCI6eyJ0eSI6IkFwcCIsImFjIjoiMjA5NzI3NiIsImFwIjoiODM4ODI2MDUiLCJ0eCI6IjYwMDY5ZDliOGZiOWI1ODIiLCJ0ciI6Ijk3NzYwMmQ0MGUzMzljNjQwZjQyMGVkMzk0Y2FkMTFkIiwic2EiOmZhbHNlLCJwciI6MC44Mzc2NiwidGkiOjE2NzkwNDk0MDA2OTUsImlkIjoiN2MyOTBmMWVhN2Q2YzIwNyJ9fQ==", + "tracestate": "2097276@nr=0-0-2097276-83882605-7c290f1ea7d6c207-60069d9b8fb9b582-0-0.837660-1679049400695", + "x-commercelayer-topic": "gift_cards.purchase" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:commercelayer/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "commercelayer", + "providerLabel": "Commerce Layer", + "eventType": "orders.create", + "name": "orders.create", + "body": { + "data": { + "id": "PlalhWGrAb", + "type": "orders", + "links": { + "self": "/api/orders/PlalhWGrAb" + }, + "attributes": { + "number": 35310566, + "autorefresh": true, + "status": "draft", + "payment_status": "unpaid", + "fulfillment_status": "unfulfilled", + "guest": false, + "editable": true, + "customer_email": "mark@mail.com", + "language_code": "en", + "currency_code": "USD", + "tax_included": false, + "tax_rate": null, + "freight_taxable": null, + "requires_billing_info": false, + "country_code": null, + "shipping_country_code_lock": null, + "coupon_code": null, + "gift_card_code": null, + "gift_card_or_coupon_code": null, + "subtotal_amount_cents": 0, + "subtotal_amount_float": 0, + "formatted_subtotal_amount": "$0.00", + "shipping_amount_cents": 0, + "shipping_amount_float": 0, + "formatted_shipping_amount": "$0.00", + "payment_method_amount_cents": 0, + "payment_method_amount_float": 0, + "formatted_payment_method_amount": "$0.00", + "discount_amount_cents": 0, + "discount_amount_float": 0, + "formatted_discount_amount": "$0.00", + "adjustment_amount_cents": 0, + "adjustment_amount_float": 0, + "formatted_adjustment_amount": "$0.00", + "gift_card_amount_cents": 0, + "gift_card_amount_float": 0, + "formatted_gift_card_amount": "$0.00", + "total_tax_amount_cents": 0, + "total_tax_amount_float": 0, + "formatted_total_tax_amount": "$0.00", + "subtotal_tax_amount_cents": 0, + "subtotal_tax_amount_float": 0, + "formatted_subtotal_tax_amount": "$0.00", + "shipping_tax_amount_cents": 0, + "shipping_tax_amount_float": 0, + "formatted_shipping_tax_amount": "$0.00", + "payment_method_tax_amount_cents": 0, + "payment_method_tax_amount_float": 0, + "formatted_payment_method_tax_amount": "$0.00", + "adjustment_tax_amount_cents": 0, + "adjustment_tax_amount_float": 0, + "formatted_adjustment_tax_amount": "$0.00", + "total_amount_cents": 0, + "total_amount_float": 0, + "formatted_total_amount": "$0.00", + "total_taxable_amount_cents": 0, + "total_taxable_amount_float": 0, + "formatted_total_taxable_amount": "$0.00", + "subtotal_taxable_amount_cents": 0, + "subtotal_taxable_amount_float": 0, + "formatted_subtotal_taxable_amount": "$0.00", + "shipping_taxable_amount_cents": 0, + "shipping_taxable_amount_float": 0, + "formatted_shipping_taxable_amount": "$0.00", + "payment_method_taxable_amount_cents": 0, + "payment_method_taxable_amount_float": 0, + "formatted_payment_method_taxable_amount": "$0.00", + "adjustment_taxable_amount_cents": 0, + "adjustment_taxable_amount_float": 0, + "formatted_adjustment_taxable_amount": "$0.00", + "total_amount_with_taxes_cents": 0, + "total_amount_with_taxes_float": 0, + "formatted_total_amount_with_taxes": "$0.00", + "fees_amount_cents": 0, + "fees_amount_float": 0, + "formatted_fees_amount": "$0.00", + "duty_amount_cents": null, + "duty_amount_float": null, + "formatted_duty_amount": null, + "skus_count": 0, + "line_item_options_count": 0, + "shipments_count": 0, + "tax_calculations_count": 0, + "payment_source_details": null, + "token": "9b207dcee16313d78115c1ee48df4097", + "cart_url": null, + "return_url": null, + "terms_url": null, + "privacy_url": null, + "checkout_url": null, + "placed_at": null, + "approved_at": null, + "cancelled_at": null, + "payment_updated_at": null, + "fulfillment_updated_at": null, + "refreshed_at": null, + "archived_at": null, + "expires_at": "2023-05-17T10:27:09.826Z", + "subscription_created_at": null, + "created_at": "2023-03-17T10:27:09.819Z", + "updated_at": "2023-03-17T10:27:09.819Z", + "reference": "", + "reference_origin": "", + "metadata": {} + }, + "relationships": { + "market": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/market", + "related": "/api/orders/PlalhWGrAb/market" + } + }, + "customer": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/customer", + "related": "/api/orders/PlalhWGrAb/customer" + } + }, + "shipping_address": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/shipping_address", + "related": "/api/orders/PlalhWGrAb/shipping_address" + } + }, + "billing_address": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/billing_address", + "related": "/api/orders/PlalhWGrAb/billing_address" + } + }, + "available_payment_methods": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/available_payment_methods", + "related": "/api/orders/PlalhWGrAb/available_payment_methods" + } + }, + "available_customer_payment_sources": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/available_customer_payment_sources", + "related": "/api/orders/PlalhWGrAb/available_customer_payment_sources" + } + }, + "available_free_skus": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/available_free_skus", + "related": "/api/orders/PlalhWGrAb/available_free_skus" + } + }, + "available_free_bundles": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/available_free_bundles", + "related": "/api/orders/PlalhWGrAb/available_free_bundles" + } + }, + "payment_method": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/payment_method", + "related": "/api/orders/PlalhWGrAb/payment_method" + } + }, + "payment_source": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/payment_source", + "related": "/api/orders/PlalhWGrAb/payment_source" + } + }, + "line_items": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/line_items", + "related": "/api/orders/PlalhWGrAb/line_items" + } + }, + "shipments": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/shipments", + "related": "/api/orders/PlalhWGrAb/shipments" + } + }, + "transactions": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/transactions", + "related": "/api/orders/PlalhWGrAb/transactions" + } + }, + "authorizations": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/authorizations", + "related": "/api/orders/PlalhWGrAb/authorizations" + } + }, + "captures": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/captures", + "related": "/api/orders/PlalhWGrAb/captures" + } + }, + "voids": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/voids", + "related": "/api/orders/PlalhWGrAb/voids" + } + }, + "refunds": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/refunds", + "related": "/api/orders/PlalhWGrAb/refunds" + } + }, + "returns": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/returns", + "related": "/api/orders/PlalhWGrAb/returns" + } + }, + "order_subscriptions": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/order_subscriptions", + "related": "/api/orders/PlalhWGrAb/order_subscriptions" + } + }, + "order_factories": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/order_factories", + "related": "/api/orders/PlalhWGrAb/order_factories" + } + }, + "order_copies": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/order_copies", + "related": "/api/orders/PlalhWGrAb/order_copies" + } + }, + "recurring_order_copies": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/recurring_order_copies", + "related": "/api/orders/PlalhWGrAb/recurring_order_copies" + } + }, + "attachments": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/attachments", + "related": "/api/orders/PlalhWGrAb/attachments" + } + }, + "events": { + "links": { + "self": "/api/orders/PlalhWGrAb/relationships/events", + "related": "/api/orders/PlalhWGrAb/events" + } + } + }, + "meta": { + "mode": "test", + "organization_id": "jXdWbFWAYy" + } + } + }, + "extraHeaders": { + "newrelic": "eyJ2IjpbMCwxXSwiZCI6eyJ0eSI6IkFwcCIsImFjIjoiMjA5NzI3NiIsImFwIjoiODM4ODI2MDUiLCJ0eCI6IjI2Y2Y0NjM0MmZmYzNhZGQiLCJ0ciI6IjEzYTQyZmQ2ZjQzMjI4N2YzNWM5YjRhYjIyMmE1YTM4Iiwic2EiOmZhbHNlLCJwciI6MC4wOTgwNDYsInRpIjoxNjc5MDQ4ODI5ODUxLCJpZCI6ImJlY2UyY2VmNmUwNWE4NTIifX0=", + "tracestate": "2097276@nr=0-0-2097276-83882605-bece2cef6e05a852-26cf46342ffc3add-0-0.098046-1679048829851", + "x-commercelayer-topic": "orders.create" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:commercelayer/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "commercelayer", + "providerLabel": "Commerce Layer", + "eventType": "percentage_discount_promotions.create", + "name": "percentage_discount_promotions.create", + "body": { + "data": { + "id": "lAvdbfPPvO", + "type": "percentage_discount_promotions", + "links": { + "self": "/api/percentage_discount_promotions/lAvdbfPPvO" + }, + "attributes": { + "name": "Discounted", + "currency_code": "USD", + "starts_at": "2023-03-17T10:42:32.000Z", + "expires_at": "2023-03-21T00:00:00.000Z", + "total_usage_limit": 3, + "total_usage_count": 0, + "active": true, + "created_at": "2023-03-17T10:43:23.551Z", + "updated_at": "2023-03-17T10:43:23.551Z", + "reference": "Merry Christmas", + "reference_origin": "gift", + "metadata": {}, + "percentage": "4" + }, + "relationships": { + "market": { + "links": { + "self": "/api/percentage_discount_promotions/lAvdbfPPvO/relationships/market", + "related": "/api/percentage_discount_promotions/lAvdbfPPvO/market" + } + }, + "promotion_rules": { + "links": { + "self": "/api/percentage_discount_promotions/lAvdbfPPvO/relationships/promotion_rules", + "related": "/api/percentage_discount_promotions/lAvdbfPPvO/promotion_rules" + } + }, + "order_amount_promotion_rule": { + "links": { + "self": "/api/percentage_discount_promotions/lAvdbfPPvO/relationships/order_amount_promotion_rule", + "related": "/api/percentage_discount_promotions/lAvdbfPPvO/order_amount_promotion_rule" + } + }, + "sku_list_promotion_rule": { + "links": { + "self": "/api/percentage_discount_promotions/lAvdbfPPvO/relationships/sku_list_promotion_rule", + "related": "/api/percentage_discount_promotions/lAvdbfPPvO/sku_list_promotion_rule" + } + }, + "coupon_codes_promotion_rule": { + "links": { + "self": "/api/percentage_discount_promotions/lAvdbfPPvO/relationships/coupon_codes_promotion_rule", + "related": "/api/percentage_discount_promotions/lAvdbfPPvO/coupon_codes_promotion_rule" + } + }, + "attachments": { + "links": { + "self": "/api/percentage_discount_promotions/lAvdbfPPvO/relationships/attachments", + "related": "/api/percentage_discount_promotions/lAvdbfPPvO/attachments" + } + }, + "sku_list": { + "links": { + "self": "/api/percentage_discount_promotions/lAvdbfPPvO/relationships/sku_list", + "related": "/api/percentage_discount_promotions/lAvdbfPPvO/sku_list" + } + }, + "skus": { + "links": { + "self": "/api/percentage_discount_promotions/lAvdbfPPvO/relationships/skus", + "related": "/api/percentage_discount_promotions/lAvdbfPPvO/skus" + } + }, + "events": { + "links": { + "self": "/api/percentage_discount_promotions/lAvdbfPPvO/relationships/events", + "related": "/api/percentage_discount_promotions/lAvdbfPPvO/events" + } + } + }, + "meta": { + "mode": "test", + "organization_id": "jXdWbFWAYy" + } + } + }, + "extraHeaders": { + "newrelic": "eyJ2IjpbMCwxXSwiZCI6eyJ0eSI6IkFwcCIsImFjIjoiMjA5NzI3NiIsImFwIjoiODM4ODI2MDUiLCJ0eCI6IjVmMDMxNGE0OTYwMTRkOWUiLCJ0ciI6ImJkZGZhOWQ5NGUxYmVjZWY4OTc3NTk2OWIyNTcxN2EwIiwic2EiOmZhbHNlLCJwciI6MC4xNTQ5NjMsInRpIjoxNjc5MDQ5ODAzNTc5LCJpZCI6Ijk0NTJkMmM2YWM1NzdkYTAifX0=", + "tracestate": "2097276@nr=0-0-2097276-83882605-9452d2c6ac577da0-5f0314a496014d9e-0-0.154963-1679049803579", + "x-commercelayer-topic": "percentage_discount_promotions.create" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:commercelayer/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "github", + "providerLabel": "GitHub", + "presetId": "github", + "eventType": "create", + "name": "create", + "body": { + "ref": "demo", + "ref_type": "tag", + "master_branch": "main", + "description": null, + "pusher_type": "user", + "repository": { + "id": 592181315, + "node_id": "R_kgDOI0v4Qw", + "name": "simple-app", + "full_name": "hookdeckwrite/simple-app", + "private": false, + "owner": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/hookdeckwrite/simple-app", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/hookdeckwrite/simple-app", + "forks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/forks", + "keys_url": "https://api.github.com/repos/hookdeckwrite/simple-app/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/hookdeckwrite/simple-app/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/hookdeckwrite/simple-app/teams", + "hooks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/hooks", + "issue_events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/events{/number}", + "events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/events", + "assignees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/assignees{/user}", + "branches_url": "https://api.github.com/repos/hookdeckwrite/simple-app/branches{/branch}", + "tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/tags", + "blobs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/hookdeckwrite/simple-app/statuses/{sha}", + "languages_url": "https://api.github.com/repos/hookdeckwrite/simple-app/languages", + "stargazers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/stargazers", + "contributors_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contributors", + "subscribers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscribers", + "subscription_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscription", + "commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contents/{+path}", + "compare_url": "https://api.github.com/repos/hookdeckwrite/simple-app/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/hookdeckwrite/simple-app/merges", + "archive_url": "https://api.github.com/repos/hookdeckwrite/simple-app/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/hookdeckwrite/simple-app/downloads", + "issues_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues{/number}", + "pulls_url": "https://api.github.com/repos/hookdeckwrite/simple-app/pulls{/number}", + "milestones_url": "https://api.github.com/repos/hookdeckwrite/simple-app/milestones{/number}", + "notifications_url": "https://api.github.com/repos/hookdeckwrite/simple-app/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/hookdeckwrite/simple-app/labels{/name}", + "releases_url": "https://api.github.com/repos/hookdeckwrite/simple-app/releases{/id}", + "deployments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/deployments", + "created_at": "2023-01-23T05:59:11Z", + "updated_at": "2023-01-23T05:59:11Z", + "pushed_at": "2023-03-05T23:04:11Z", + "git_url": "git://github.com/hookdeckwrite/simple-app.git", + "ssh_url": "git@github.com:hookdeckwrite/simple-app.git", + "clone_url": "https://github.com/hookdeckwrite/simple-app.git", + "svn_url": "https://github.com/hookdeckwrite/simple-app", + "homepage": null, + "size": 1, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 0, + "license": null, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [], + "visibility": "public", + "forks": 0, + "open_issues": 0, + "watchers": 0, + "default_branch": "main" + }, + "sender": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + } + }, + "extraHeaders": { + "x-github-event": "create" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:github/2022-11-28", + "snapshotDate": "2026-07" + } + }, + { + "provider": "github", + "providerLabel": "GitHub", + "presetId": "github", + "eventType": "delete", + "name": "delete", + "body": { + "ref": "hookdeckwrite-patch-1", + "ref_type": "branch", + "pusher_type": "user", + "repository": { + "id": 592181315, + "node_id": "R_kgDOI0v4Qw", + "name": "simple-app", + "full_name": "hookdeckwrite/simple-app", + "private": false, + "owner": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/hookdeckwrite/simple-app", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/hookdeckwrite/simple-app", + "forks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/forks", + "keys_url": "https://api.github.com/repos/hookdeckwrite/simple-app/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/hookdeckwrite/simple-app/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/hookdeckwrite/simple-app/teams", + "hooks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/hooks", + "issue_events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/events{/number}", + "events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/events", + "assignees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/assignees{/user}", + "branches_url": "https://api.github.com/repos/hookdeckwrite/simple-app/branches{/branch}", + "tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/tags", + "blobs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/hookdeckwrite/simple-app/statuses/{sha}", + "languages_url": "https://api.github.com/repos/hookdeckwrite/simple-app/languages", + "stargazers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/stargazers", + "contributors_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contributors", + "subscribers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscribers", + "subscription_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscription", + "commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contents/{+path}", + "compare_url": "https://api.github.com/repos/hookdeckwrite/simple-app/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/hookdeckwrite/simple-app/merges", + "archive_url": "https://api.github.com/repos/hookdeckwrite/simple-app/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/hookdeckwrite/simple-app/downloads", + "issues_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues{/number}", + "pulls_url": "https://api.github.com/repos/hookdeckwrite/simple-app/pulls{/number}", + "milestones_url": "https://api.github.com/repos/hookdeckwrite/simple-app/milestones{/number}", + "notifications_url": "https://api.github.com/repos/hookdeckwrite/simple-app/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/hookdeckwrite/simple-app/labels{/name}", + "releases_url": "https://api.github.com/repos/hookdeckwrite/simple-app/releases{/id}", + "deployments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/deployments", + "created_at": "2023-01-23T05:59:11Z", + "updated_at": "2023-01-23T05:59:11Z", + "pushed_at": "2023-03-05T22:59:39Z", + "git_url": "git://github.com/hookdeckwrite/simple-app.git", + "ssh_url": "git@github.com:hookdeckwrite/simple-app.git", + "clone_url": "https://github.com/hookdeckwrite/simple-app.git", + "svn_url": "https://github.com/hookdeckwrite/simple-app", + "homepage": null, + "size": 1, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 0, + "license": null, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [], + "visibility": "public", + "forks": 0, + "open_issues": 0, + "watchers": 0, + "default_branch": "main" + }, + "sender": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + } + }, + "extraHeaders": { + "x-github-event": "delete" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:github/2022-11-28", + "snapshotDate": "2026-07" + } + }, + { + "provider": "github", + "providerLabel": "GitHub", + "presetId": "github", + "eventType": "issue_comment", + "name": "issue_comment", + "body": { + "action": "created", + "issue": { + "url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/1", + "repository_url": "https://api.github.com/repos/hookdeckwrite/simple-app", + "labels_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/1/labels{/name}", + "comments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/1/comments", + "events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/1/events", + "html_url": "https://github.com/hookdeckwrite/simple-app/pull/1", + "id": 1610402038, + "node_id": "PR_kwDOI0v4Q85LUJ3o", + "number": 1, + "title": "Update README.md", + "user": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + }, + "labels": [], + "state": "closed", + "locked": false, + "assignee": null, + "assignees": [], + "milestone": null, + "comments": 2, + "created_at": "2023-03-05T22:57:12Z", + "updated_at": "2023-03-05T22:59:02Z", + "closed_at": "2023-03-05T22:58:17Z", + "author_association": "OWNER", + "active_lock_reason": null, + "draft": false, + "pull_request": { + "url": "https://api.github.com/repos/hookdeckwrite/simple-app/pulls/1", + "html_url": "https://github.com/hookdeckwrite/simple-app/pull/1", + "diff_url": "https://github.com/hookdeckwrite/simple-app/pull/1.diff", + "patch_url": "https://github.com/hookdeckwrite/simple-app/pull/1.patch", + "merged_at": "2023-03-05T22:58:17Z" + }, + "body": "Edited", + "reactions": { + "url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/1/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "timeline_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/1/timeline", + "performed_via_github_app": null, + "state_reason": null + }, + "comment": { + "url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/comments/1455234988", + "html_url": "https://github.com/hookdeckwrite/simple-app/pull/1#issuecomment-1455234988", + "issue_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/1", + "id": 1455234988, + "node_id": "IC_kwDOI0v4Q85WvR-s", + "user": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + }, + "created_at": "2023-03-05T22:59:02Z", + "updated_at": "2023-03-05T22:59:02Z", + "author_association": "OWNER", + "body": "> Merged?\r\n\r\nYes", + "reactions": { + "url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/comments/1455234988/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "performed_via_github_app": null + }, + "repository": { + "id": 592181315, + "node_id": "R_kgDOI0v4Qw", + "name": "simple-app", + "full_name": "hookdeckwrite/simple-app", + "private": false, + "owner": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/hookdeckwrite/simple-app", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/hookdeckwrite/simple-app", + "forks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/forks", + "keys_url": "https://api.github.com/repos/hookdeckwrite/simple-app/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/hookdeckwrite/simple-app/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/hookdeckwrite/simple-app/teams", + "hooks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/hooks", + "issue_events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/events{/number}", + "events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/events", + "assignees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/assignees{/user}", + "branches_url": "https://api.github.com/repos/hookdeckwrite/simple-app/branches{/branch}", + "tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/tags", + "blobs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/hookdeckwrite/simple-app/statuses/{sha}", + "languages_url": "https://api.github.com/repos/hookdeckwrite/simple-app/languages", + "stargazers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/stargazers", + "contributors_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contributors", + "subscribers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscribers", + "subscription_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscription", + "commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contents/{+path}", + "compare_url": "https://api.github.com/repos/hookdeckwrite/simple-app/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/hookdeckwrite/simple-app/merges", + "archive_url": "https://api.github.com/repos/hookdeckwrite/simple-app/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/hookdeckwrite/simple-app/downloads", + "issues_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues{/number}", + "pulls_url": "https://api.github.com/repos/hookdeckwrite/simple-app/pulls{/number}", + "milestones_url": "https://api.github.com/repos/hookdeckwrite/simple-app/milestones{/number}", + "notifications_url": "https://api.github.com/repos/hookdeckwrite/simple-app/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/hookdeckwrite/simple-app/labels{/name}", + "releases_url": "https://api.github.com/repos/hookdeckwrite/simple-app/releases{/id}", + "deployments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/deployments", + "created_at": "2023-01-23T05:59:11Z", + "updated_at": "2023-01-23T05:59:11Z", + "pushed_at": "2023-03-05T22:58:17Z", + "git_url": "git://github.com/hookdeckwrite/simple-app.git", + "ssh_url": "git@github.com:hookdeckwrite/simple-app.git", + "clone_url": "https://github.com/hookdeckwrite/simple-app.git", + "svn_url": "https://github.com/hookdeckwrite/simple-app", + "homepage": null, + "size": 1, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 0, + "license": null, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [], + "visibility": "public", + "forks": 0, + "open_issues": 0, + "watchers": 0, + "default_branch": "main" + }, + "sender": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + } + }, + "extraHeaders": { + "x-github-event": "issue_comment" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:github/2022-11-28", + "snapshotDate": "2026-07" + } + }, + { + "provider": "github", + "providerLabel": "GitHub", + "presetId": "github", + "eventType": "label", + "name": "label", + "body": { + "action": "deleted", + "label": { + "id": 5068260474, + "node_id": "LA_kwDOI0v4Q88AAAABLheEeg", + "url": "https://api.github.com/repos/hookdeckwrite/simple-app/labels/enhancement", + "name": "enhancement", + "color": "a2eeef", + "default": true, + "description": "New feature or request" + }, + "repository": { + "id": 592181315, + "node_id": "R_kgDOI0v4Qw", + "name": "simple-app", + "full_name": "hookdeckwrite/simple-app", + "private": false, + "owner": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/hookdeckwrite/simple-app", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/hookdeckwrite/simple-app", + "forks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/forks", + "keys_url": "https://api.github.com/repos/hookdeckwrite/simple-app/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/hookdeckwrite/simple-app/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/hookdeckwrite/simple-app/teams", + "hooks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/hooks", + "issue_events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/events{/number}", + "events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/events", + "assignees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/assignees{/user}", + "branches_url": "https://api.github.com/repos/hookdeckwrite/simple-app/branches{/branch}", + "tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/tags", + "blobs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/hookdeckwrite/simple-app/statuses/{sha}", + "languages_url": "https://api.github.com/repos/hookdeckwrite/simple-app/languages", + "stargazers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/stargazers", + "contributors_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contributors", + "subscribers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscribers", + "subscription_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscription", + "commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contents/{+path}", + "compare_url": "https://api.github.com/repos/hookdeckwrite/simple-app/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/hookdeckwrite/simple-app/merges", + "archive_url": "https://api.github.com/repos/hookdeckwrite/simple-app/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/hookdeckwrite/simple-app/downloads", + "issues_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues{/number}", + "pulls_url": "https://api.github.com/repos/hookdeckwrite/simple-app/pulls{/number}", + "milestones_url": "https://api.github.com/repos/hookdeckwrite/simple-app/milestones{/number}", + "notifications_url": "https://api.github.com/repos/hookdeckwrite/simple-app/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/hookdeckwrite/simple-app/labels{/name}", + "releases_url": "https://api.github.com/repos/hookdeckwrite/simple-app/releases{/id}", + "deployments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/deployments", + "created_at": "2023-01-23T05:59:11Z", + "updated_at": "2023-01-23T05:59:11Z", + "pushed_at": "2023-03-05T22:59:49Z", + "git_url": "git://github.com/hookdeckwrite/simple-app.git", + "ssh_url": "git@github.com:hookdeckwrite/simple-app.git", + "clone_url": "https://github.com/hookdeckwrite/simple-app.git", + "svn_url": "https://github.com/hookdeckwrite/simple-app", + "homepage": null, + "size": 1, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 0, + "license": null, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [], + "visibility": "public", + "forks": 0, + "open_issues": 0, + "watchers": 0, + "default_branch": "main" + }, + "sender": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + } + }, + "extraHeaders": { + "x-github-event": "label" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:github/2022-11-28", + "snapshotDate": "2026-07" + } + }, + { + "provider": "github", + "providerLabel": "GitHub", + "presetId": "github", + "eventType": "ping", + "name": "ping", + "body": { + "zen": "Speak like a human.", + "hook_id": 403738602, + "hook": { + "type": "Repository", + "id": 403738602, + "name": "web", + "active": true, + "events": ["*"], + "config": { + "content_type": "json", + "insecure_ssl": "0", + "secret": "********", + "url": "https://events.hookdeck.com/e/src_oRc6ItAA6fEu/github/2022-11-28" + }, + "updated_at": "2023-03-05T22:54:35Z", + "created_at": "2023-03-05T22:54:35Z", + "url": "https://api.github.com/repos/hookdeckwrite/simple-app/hooks/403738602", + "test_url": "https://api.github.com/repos/hookdeckwrite/simple-app/hooks/403738602/test", + "ping_url": "https://api.github.com/repos/hookdeckwrite/simple-app/hooks/403738602/pings", + "deliveries_url": "https://api.github.com/repos/hookdeckwrite/simple-app/hooks/403738602/deliveries", + "last_response": { + "code": null, + "status": "unused", + "message": null + } + }, + "repository": { + "id": 592181315, + "node_id": "R_kgDOI0v4Qw", + "name": "simple-app", + "full_name": "hookdeckwrite/simple-app", + "private": false, + "owner": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/hookdeckwrite/simple-app", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/hookdeckwrite/simple-app", + "forks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/forks", + "keys_url": "https://api.github.com/repos/hookdeckwrite/simple-app/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/hookdeckwrite/simple-app/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/hookdeckwrite/simple-app/teams", + "hooks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/hooks", + "issue_events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/events{/number}", + "events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/events", + "assignees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/assignees{/user}", + "branches_url": "https://api.github.com/repos/hookdeckwrite/simple-app/branches{/branch}", + "tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/tags", + "blobs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/hookdeckwrite/simple-app/statuses/{sha}", + "languages_url": "https://api.github.com/repos/hookdeckwrite/simple-app/languages", + "stargazers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/stargazers", + "contributors_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contributors", + "subscribers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscribers", + "subscription_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscription", + "commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contents/{+path}", + "compare_url": "https://api.github.com/repos/hookdeckwrite/simple-app/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/hookdeckwrite/simple-app/merges", + "archive_url": "https://api.github.com/repos/hookdeckwrite/simple-app/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/hookdeckwrite/simple-app/downloads", + "issues_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues{/number}", + "pulls_url": "https://api.github.com/repos/hookdeckwrite/simple-app/pulls{/number}", + "milestones_url": "https://api.github.com/repos/hookdeckwrite/simple-app/milestones{/number}", + "notifications_url": "https://api.github.com/repos/hookdeckwrite/simple-app/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/hookdeckwrite/simple-app/labels{/name}", + "releases_url": "https://api.github.com/repos/hookdeckwrite/simple-app/releases{/id}", + "deployments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/deployments", + "created_at": "2023-01-23T05:59:11Z", + "updated_at": "2023-01-23T05:59:11Z", + "pushed_at": "2023-01-23T06:50:15Z", + "git_url": "git://github.com/hookdeckwrite/simple-app.git", + "ssh_url": "git@github.com:hookdeckwrite/simple-app.git", + "clone_url": "https://github.com/hookdeckwrite/simple-app.git", + "svn_url": "https://github.com/hookdeckwrite/simple-app", + "homepage": null, + "size": 1, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 0, + "license": null, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [], + "visibility": "public", + "forks": 0, + "open_issues": 0, + "watchers": 0, + "default_branch": "main" + }, + "sender": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + } + }, + "extraHeaders": { + "x-github-event": "ping" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:github/2022-11-28", + "snapshotDate": "2026-07" + } + }, + { + "provider": "github", + "providerLabel": "GitHub", + "presetId": "github", + "eventType": "pull_request", + "name": "pull_request", + "body": { + "action": "labeled", + "number": 1, + "pull_request": { + "url": "https://api.github.com/repos/hookdeckwrite/simple-app/pulls/1", + "id": 1263574504, + "node_id": "PR_kwDOI0v4Q85LUJ3o", + "html_url": "https://github.com/hookdeckwrite/simple-app/pull/1", + "diff_url": "https://github.com/hookdeckwrite/simple-app/pull/1.diff", + "patch_url": "https://github.com/hookdeckwrite/simple-app/pull/1.patch", + "issue_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/1", + "number": 1, + "state": "closed", + "locked": false, + "title": "Update README.md", + "user": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + }, + "body": "Edited", + "created_at": "2023-03-05T22:57:12Z", + "updated_at": "2023-03-05T23:01:24Z", + "closed_at": "2023-03-05T22:58:17Z", + "merged_at": "2023-03-05T22:58:17Z", + "merge_commit_sha": "b03b17320c22d5e46c5e302a150fdbe3abe39735", + "assignee": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + }, + "assignees": [ + { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + } + ], + "requested_reviewers": [], + "requested_teams": [], + "labels": [ + { + "id": 5068260465, + "node_id": "LA_kwDOI0v4Q88AAAABLheEcQ", + "url": "https://api.github.com/repos/hookdeckwrite/simple-app/labels/documentation", + "name": "documentation", + "color": "0075ca", + "default": true, + "description": "Improvements or additions to documentation" + } + ], + "milestone": null, + "draft": false, + "commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/pulls/1/commits", + "review_comments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/pulls/1/comments", + "review_comment_url": "https://api.github.com/repos/hookdeckwrite/simple-app/pulls/comments{/number}", + "comments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/1/comments", + "statuses_url": "https://api.github.com/repos/hookdeckwrite/simple-app/statuses/bc6a8bcfac73fae64f541c8ce1d405adbe04d920", + "head": { + "label": "hookdeckwrite:hookdeckwrite-patch-1", + "ref": "hookdeckwrite-patch-1", + "sha": "bc6a8bcfac73fae64f541c8ce1d405adbe04d920", + "user": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + }, + "repo": { + "id": 592181315, + "node_id": "R_kgDOI0v4Qw", + "name": "simple-app", + "full_name": "hookdeckwrite/simple-app", + "private": false, + "owner": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/hookdeckwrite/simple-app", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/hookdeckwrite/simple-app", + "forks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/forks", + "keys_url": "https://api.github.com/repos/hookdeckwrite/simple-app/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/hookdeckwrite/simple-app/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/hookdeckwrite/simple-app/teams", + "hooks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/hooks", + "issue_events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/events{/number}", + "events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/events", + "assignees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/assignees{/user}", + "branches_url": "https://api.github.com/repos/hookdeckwrite/simple-app/branches{/branch}", + "tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/tags", + "blobs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/hookdeckwrite/simple-app/statuses/{sha}", + "languages_url": "https://api.github.com/repos/hookdeckwrite/simple-app/languages", + "stargazers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/stargazers", + "contributors_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contributors", + "subscribers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscribers", + "subscription_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscription", + "commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contents/{+path}", + "compare_url": "https://api.github.com/repos/hookdeckwrite/simple-app/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/hookdeckwrite/simple-app/merges", + "archive_url": "https://api.github.com/repos/hookdeckwrite/simple-app/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/hookdeckwrite/simple-app/downloads", + "issues_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues{/number}", + "pulls_url": "https://api.github.com/repos/hookdeckwrite/simple-app/pulls{/number}", + "milestones_url": "https://api.github.com/repos/hookdeckwrite/simple-app/milestones{/number}", + "notifications_url": "https://api.github.com/repos/hookdeckwrite/simple-app/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/hookdeckwrite/simple-app/labels{/name}", + "releases_url": "https://api.github.com/repos/hookdeckwrite/simple-app/releases{/id}", + "deployments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/deployments", + "created_at": "2023-01-23T05:59:11Z", + "updated_at": "2023-01-23T05:59:11Z", + "pushed_at": "2023-03-05T22:59:49Z", + "git_url": "git://github.com/hookdeckwrite/simple-app.git", + "ssh_url": "git@github.com:hookdeckwrite/simple-app.git", + "clone_url": "https://github.com/hookdeckwrite/simple-app.git", + "svn_url": "https://github.com/hookdeckwrite/simple-app", + "homepage": null, + "size": 1, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 0, + "license": null, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [], + "visibility": "public", + "forks": 0, + "open_issues": 0, + "watchers": 0, + "default_branch": "main", + "allow_squash_merge": true, + "allow_merge_commit": true, + "allow_rebase_merge": true, + "allow_auto_merge": false, + "delete_branch_on_merge": false, + "allow_update_branch": false, + "use_squash_pr_title_as_default": false, + "squash_merge_commit_message": "COMMIT_MESSAGES", + "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", + "merge_commit_message": "PR_TITLE", + "merge_commit_title": "MERGE_MESSAGE" + } + }, + "base": { + "label": "hookdeckwrite:main", + "ref": "main", + "sha": "ec65202d8919f48daaaa2f5276311de544a0916e", + "user": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + }, + "repo": { + "id": 592181315, + "node_id": "R_kgDOI0v4Qw", + "name": "simple-app", + "full_name": "hookdeckwrite/simple-app", + "private": false, + "owner": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/hookdeckwrite/simple-app", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/hookdeckwrite/simple-app", + "forks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/forks", + "keys_url": "https://api.github.com/repos/hookdeckwrite/simple-app/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/hookdeckwrite/simple-app/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/hookdeckwrite/simple-app/teams", + "hooks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/hooks", + "issue_events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/events{/number}", + "events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/events", + "assignees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/assignees{/user}", + "branches_url": "https://api.github.com/repos/hookdeckwrite/simple-app/branches{/branch}", + "tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/tags", + "blobs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/hookdeckwrite/simple-app/statuses/{sha}", + "languages_url": "https://api.github.com/repos/hookdeckwrite/simple-app/languages", + "stargazers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/stargazers", + "contributors_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contributors", + "subscribers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscribers", + "subscription_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscription", + "commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contents/{+path}", + "compare_url": "https://api.github.com/repos/hookdeckwrite/simple-app/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/hookdeckwrite/simple-app/merges", + "archive_url": "https://api.github.com/repos/hookdeckwrite/simple-app/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/hookdeckwrite/simple-app/downloads", + "issues_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues{/number}", + "pulls_url": "https://api.github.com/repos/hookdeckwrite/simple-app/pulls{/number}", + "milestones_url": "https://api.github.com/repos/hookdeckwrite/simple-app/milestones{/number}", + "notifications_url": "https://api.github.com/repos/hookdeckwrite/simple-app/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/hookdeckwrite/simple-app/labels{/name}", + "releases_url": "https://api.github.com/repos/hookdeckwrite/simple-app/releases{/id}", + "deployments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/deployments", + "created_at": "2023-01-23T05:59:11Z", + "updated_at": "2023-01-23T05:59:11Z", + "pushed_at": "2023-03-05T22:59:49Z", + "git_url": "git://github.com/hookdeckwrite/simple-app.git", + "ssh_url": "git@github.com:hookdeckwrite/simple-app.git", + "clone_url": "https://github.com/hookdeckwrite/simple-app.git", + "svn_url": "https://github.com/hookdeckwrite/simple-app", + "homepage": null, + "size": 1, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 0, + "license": null, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [], + "visibility": "public", + "forks": 0, + "open_issues": 0, + "watchers": 0, + "default_branch": "main", + "allow_squash_merge": true, + "allow_merge_commit": true, + "allow_rebase_merge": true, + "allow_auto_merge": false, + "delete_branch_on_merge": false, + "allow_update_branch": false, + "use_squash_pr_title_as_default": false, + "squash_merge_commit_message": "COMMIT_MESSAGES", + "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", + "merge_commit_message": "PR_TITLE", + "merge_commit_title": "MERGE_MESSAGE" + } + }, + "_links": { + "self": { + "href": "https://api.github.com/repos/hookdeckwrite/simple-app/pulls/1" + }, + "html": { + "href": "https://github.com/hookdeckwrite/simple-app/pull/1" + }, + "issue": { + "href": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/1" + }, + "comments": { + "href": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/1/comments" + }, + "review_comments": { + "href": "https://api.github.com/repos/hookdeckwrite/simple-app/pulls/1/comments" + }, + "review_comment": { + "href": "https://api.github.com/repos/hookdeckwrite/simple-app/pulls/comments{/number}" + }, + "commits": { + "href": "https://api.github.com/repos/hookdeckwrite/simple-app/pulls/1/commits" + }, + "statuses": { + "href": "https://api.github.com/repos/hookdeckwrite/simple-app/statuses/bc6a8bcfac73fae64f541c8ce1d405adbe04d920" + } + }, + "author_association": "OWNER", + "auto_merge": null, + "active_lock_reason": null, + "merged": true, + "mergeable": null, + "rebaseable": null, + "mergeable_state": "unknown", + "merged_by": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + }, + "comments": 2, + "review_comments": 0, + "maintainer_can_modify": false, + "commits": 1, + "additions": 1, + "deletions": 0, + "changed_files": 1 + }, + "label": { + "id": 5068260465, + "node_id": "LA_kwDOI0v4Q88AAAABLheEcQ", + "url": "https://api.github.com/repos/hookdeckwrite/simple-app/labels/documentation", + "name": "documentation", + "color": "0075ca", + "default": true, + "description": "Improvements or additions to documentation" + }, + "repository": { + "id": 592181315, + "node_id": "R_kgDOI0v4Qw", + "name": "simple-app", + "full_name": "hookdeckwrite/simple-app", + "private": false, + "owner": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/hookdeckwrite/simple-app", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/hookdeckwrite/simple-app", + "forks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/forks", + "keys_url": "https://api.github.com/repos/hookdeckwrite/simple-app/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/hookdeckwrite/simple-app/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/hookdeckwrite/simple-app/teams", + "hooks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/hooks", + "issue_events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/events{/number}", + "events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/events", + "assignees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/assignees{/user}", + "branches_url": "https://api.github.com/repos/hookdeckwrite/simple-app/branches{/branch}", + "tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/tags", + "blobs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/hookdeckwrite/simple-app/statuses/{sha}", + "languages_url": "https://api.github.com/repos/hookdeckwrite/simple-app/languages", + "stargazers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/stargazers", + "contributors_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contributors", + "subscribers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscribers", + "subscription_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscription", + "commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contents/{+path}", + "compare_url": "https://api.github.com/repos/hookdeckwrite/simple-app/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/hookdeckwrite/simple-app/merges", + "archive_url": "https://api.github.com/repos/hookdeckwrite/simple-app/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/hookdeckwrite/simple-app/downloads", + "issues_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues{/number}", + "pulls_url": "https://api.github.com/repos/hookdeckwrite/simple-app/pulls{/number}", + "milestones_url": "https://api.github.com/repos/hookdeckwrite/simple-app/milestones{/number}", + "notifications_url": "https://api.github.com/repos/hookdeckwrite/simple-app/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/hookdeckwrite/simple-app/labels{/name}", + "releases_url": "https://api.github.com/repos/hookdeckwrite/simple-app/releases{/id}", + "deployments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/deployments", + "created_at": "2023-01-23T05:59:11Z", + "updated_at": "2023-01-23T05:59:11Z", + "pushed_at": "2023-03-05T22:59:49Z", + "git_url": "git://github.com/hookdeckwrite/simple-app.git", + "ssh_url": "git@github.com:hookdeckwrite/simple-app.git", + "clone_url": "https://github.com/hookdeckwrite/simple-app.git", + "svn_url": "https://github.com/hookdeckwrite/simple-app", + "homepage": null, + "size": 1, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 0, + "license": null, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [], + "visibility": "public", + "forks": 0, + "open_issues": 0, + "watchers": 0, + "default_branch": "main" + }, + "sender": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + } + }, + "extraHeaders": { + "x-github-event": "pull_request" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:github/2022-11-28", + "snapshotDate": "2026-07" + } + }, + { + "provider": "github", + "providerLabel": "GitHub", + "presetId": "github", + "eventType": "push", + "name": "push", + "body": { + "ref": "refs/tags/demo", + "before": "0000000000000000000000000000000000000000", + "after": "b03b17320c22d5e46c5e302a150fdbe3abe39735", + "repository": { + "id": 592181315, + "node_id": "R_kgDOI0v4Qw", + "name": "simple-app", + "full_name": "hookdeckwrite/simple-app", + "private": false, + "owner": { + "name": "hookdeckwrite", + "email": "123277766+hookdeckwrite@users.noreply.github.com", + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/hookdeckwrite/simple-app", + "description": null, + "fork": false, + "url": "https://github.com/hookdeckwrite/simple-app", + "forks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/forks", + "keys_url": "https://api.github.com/repos/hookdeckwrite/simple-app/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/hookdeckwrite/simple-app/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/hookdeckwrite/simple-app/teams", + "hooks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/hooks", + "issue_events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/events{/number}", + "events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/events", + "assignees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/assignees{/user}", + "branches_url": "https://api.github.com/repos/hookdeckwrite/simple-app/branches{/branch}", + "tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/tags", + "blobs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/hookdeckwrite/simple-app/statuses/{sha}", + "languages_url": "https://api.github.com/repos/hookdeckwrite/simple-app/languages", + "stargazers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/stargazers", + "contributors_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contributors", + "subscribers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscribers", + "subscription_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscription", + "commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contents/{+path}", + "compare_url": "https://api.github.com/repos/hookdeckwrite/simple-app/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/hookdeckwrite/simple-app/merges", + "archive_url": "https://api.github.com/repos/hookdeckwrite/simple-app/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/hookdeckwrite/simple-app/downloads", + "issues_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues{/number}", + "pulls_url": "https://api.github.com/repos/hookdeckwrite/simple-app/pulls{/number}", + "milestones_url": "https://api.github.com/repos/hookdeckwrite/simple-app/milestones{/number}", + "notifications_url": "https://api.github.com/repos/hookdeckwrite/simple-app/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/hookdeckwrite/simple-app/labels{/name}", + "releases_url": "https://api.github.com/repos/hookdeckwrite/simple-app/releases{/id}", + "deployments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/deployments", + "created_at": 1674453551, + "updated_at": "2023-01-23T05:59:11Z", + "pushed_at": 1678057451, + "git_url": "git://github.com/hookdeckwrite/simple-app.git", + "ssh_url": "git@github.com:hookdeckwrite/simple-app.git", + "clone_url": "https://github.com/hookdeckwrite/simple-app.git", + "svn_url": "https://github.com/hookdeckwrite/simple-app", + "homepage": null, + "size": 1, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 0, + "license": null, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [], + "visibility": "public", + "forks": 0, + "open_issues": 0, + "watchers": 0, + "default_branch": "main", + "stargazers": 0, + "master_branch": "main" + }, + "pusher": { + "name": "hookdeckwrite", + "email": "123277766+hookdeckwrite@users.noreply.github.com" + }, + "sender": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + }, + "created": true, + "deleted": false, + "forced": false, + "base_ref": "refs/heads/main", + "compare": "https://github.com/hookdeckwrite/simple-app/compare/demo", + "commits": [], + "head_commit": { + "id": "b03b17320c22d5e46c5e302a150fdbe3abe39735", + "tree_id": "3b0153ccb69afba6845b1c4b76cbf8f4b465b392", + "distinct": true, + "message": "Merge pull request #1 from hookdeckwrite/hookdeckwrite-patch-1\n\nUpdate README.md", + "timestamp": "2023-03-05T23:58:17+01:00", + "url": "https://github.com/hookdeckwrite/simple-app/commit/b03b17320c22d5e46c5e302a150fdbe3abe39735", + "author": { + "name": "hookdeckwrite", + "email": "123277766+hookdeckwrite@users.noreply.github.com", + "username": "hookdeckwrite" + }, + "committer": { + "name": "GitHub", + "email": "noreply@github.com", + "username": "web-flow" + }, + "added": [], + "removed": [], + "modified": ["README.md"] + } + }, + "extraHeaders": { + "x-github-event": "push" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:github/2022-11-28", + "snapshotDate": "2026-07" + } + }, + { + "provider": "github", + "providerLabel": "GitHub", + "presetId": "github", + "eventType": "release", + "name": "release", + "body": { + "action": "released", + "release": { + "url": "https://api.github.com/repos/hookdeckwrite/simple-app/releases/94570490", + "assets_url": "https://api.github.com/repos/hookdeckwrite/simple-app/releases/94570490/assets", + "upload_url": "https://uploads.github.com/repos/hookdeckwrite/simple-app/releases/94570490/assets{?name,label}", + "html_url": "https://github.com/hookdeckwrite/simple-app/releases/tag/demo", + "id": 94570490, + "author": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + }, + "node_id": "RE_kwDOI0v4Q84Fowf6", + "tag_name": "demo", + "target_commitish": "main", + "name": "demo-release", + "draft": false, + "prerelease": false, + "created_at": "2023-03-05T22:58:17Z", + "published_at": "2023-03-05T23:04:11Z", + "assets": [], + "tarball_url": "https://api.github.com/repos/hookdeckwrite/simple-app/tarball/demo", + "zipball_url": "https://api.github.com/repos/hookdeckwrite/simple-app/zipball/demo", + "body": "See demo release" + }, + "repository": { + "id": 592181315, + "node_id": "R_kgDOI0v4Qw", + "name": "simple-app", + "full_name": "hookdeckwrite/simple-app", + "private": false, + "owner": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/hookdeckwrite/simple-app", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/hookdeckwrite/simple-app", + "forks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/forks", + "keys_url": "https://api.github.com/repos/hookdeckwrite/simple-app/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/hookdeckwrite/simple-app/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/hookdeckwrite/simple-app/teams", + "hooks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/hooks", + "issue_events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/events{/number}", + "events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/events", + "assignees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/assignees{/user}", + "branches_url": "https://api.github.com/repos/hookdeckwrite/simple-app/branches{/branch}", + "tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/tags", + "blobs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/hookdeckwrite/simple-app/statuses/{sha}", + "languages_url": "https://api.github.com/repos/hookdeckwrite/simple-app/languages", + "stargazers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/stargazers", + "contributors_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contributors", + "subscribers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscribers", + "subscription_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscription", + "commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contents/{+path}", + "compare_url": "https://api.github.com/repos/hookdeckwrite/simple-app/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/hookdeckwrite/simple-app/merges", + "archive_url": "https://api.github.com/repos/hookdeckwrite/simple-app/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/hookdeckwrite/simple-app/downloads", + "issues_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues{/number}", + "pulls_url": "https://api.github.com/repos/hookdeckwrite/simple-app/pulls{/number}", + "milestones_url": "https://api.github.com/repos/hookdeckwrite/simple-app/milestones{/number}", + "notifications_url": "https://api.github.com/repos/hookdeckwrite/simple-app/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/hookdeckwrite/simple-app/labels{/name}", + "releases_url": "https://api.github.com/repos/hookdeckwrite/simple-app/releases{/id}", + "deployments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/deployments", + "created_at": "2023-01-23T05:59:11Z", + "updated_at": "2023-01-23T05:59:11Z", + "pushed_at": "2023-03-05T23:04:11Z", + "git_url": "git://github.com/hookdeckwrite/simple-app.git", + "ssh_url": "git@github.com:hookdeckwrite/simple-app.git", + "clone_url": "https://github.com/hookdeckwrite/simple-app.git", + "svn_url": "https://github.com/hookdeckwrite/simple-app", + "homepage": null, + "size": 1, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 0, + "license": null, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [], + "visibility": "public", + "forks": 0, + "open_issues": 0, + "watchers": 0, + "default_branch": "main" + }, + "sender": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + } + }, + "extraHeaders": { + "x-github-event": "release" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:github/2022-11-28", + "snapshotDate": "2026-07" + } + }, + { + "provider": "github", + "providerLabel": "GitHub", + "presetId": "github", + "eventType": "star", + "name": "star", + "body": { + "action": "created", + "starred_at": "2023-03-06T04:19:17Z", + "repository": { + "id": 592181315, + "node_id": "R_kgDOI0v4Qw", + "name": "simple-app", + "full_name": "hookdeckwrite/simple-app", + "private": false, + "owner": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/hookdeckwrite/simple-app", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/hookdeckwrite/simple-app", + "forks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/forks", + "keys_url": "https://api.github.com/repos/hookdeckwrite/simple-app/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/hookdeckwrite/simple-app/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/hookdeckwrite/simple-app/teams", + "hooks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/hooks", + "issue_events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/events{/number}", + "events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/events", + "assignees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/assignees{/user}", + "branches_url": "https://api.github.com/repos/hookdeckwrite/simple-app/branches{/branch}", + "tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/tags", + "blobs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/hookdeckwrite/simple-app/statuses/{sha}", + "languages_url": "https://api.github.com/repos/hookdeckwrite/simple-app/languages", + "stargazers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/stargazers", + "contributors_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contributors", + "subscribers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscribers", + "subscription_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscription", + "commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contents/{+path}", + "compare_url": "https://api.github.com/repos/hookdeckwrite/simple-app/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/hookdeckwrite/simple-app/merges", + "archive_url": "https://api.github.com/repos/hookdeckwrite/simple-app/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/hookdeckwrite/simple-app/downloads", + "issues_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues{/number}", + "pulls_url": "https://api.github.com/repos/hookdeckwrite/simple-app/pulls{/number}", + "milestones_url": "https://api.github.com/repos/hookdeckwrite/simple-app/milestones{/number}", + "notifications_url": "https://api.github.com/repos/hookdeckwrite/simple-app/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/hookdeckwrite/simple-app/labels{/name}", + "releases_url": "https://api.github.com/repos/hookdeckwrite/simple-app/releases{/id}", + "deployments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/deployments", + "created_at": "2023-01-23T05:59:11Z", + "updated_at": "2023-03-06T04:19:17Z", + "pushed_at": "2023-03-05T23:04:11Z", + "git_url": "git://github.com/hookdeckwrite/simple-app.git", + "ssh_url": "git@github.com:hookdeckwrite/simple-app.git", + "clone_url": "https://github.com/hookdeckwrite/simple-app.git", + "svn_url": "https://github.com/hookdeckwrite/simple-app", + "homepage": null, + "size": 3, + "stargazers_count": 1, + "watchers_count": 1, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 0, + "license": null, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [], + "visibility": "public", + "forks": 0, + "open_issues": 0, + "watchers": 1, + "default_branch": "main" + }, + "sender": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + } + }, + "extraHeaders": { + "x-github-event": "star" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:github/2022-11-28", + "snapshotDate": "2026-07" + } + }, + { + "provider": "github", + "providerLabel": "GitHub", + "presetId": "github", + "eventType": "watch", + "name": "watch", + "body": { + "action": "started", + "repository": { + "id": 592181315, + "node_id": "R_kgDOI0v4Qw", + "name": "simple-app", + "full_name": "hookdeckwrite/simple-app", + "private": false, + "owner": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/hookdeckwrite/simple-app", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/hookdeckwrite/simple-app", + "forks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/forks", + "keys_url": "https://api.github.com/repos/hookdeckwrite/simple-app/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/hookdeckwrite/simple-app/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/hookdeckwrite/simple-app/teams", + "hooks_url": "https://api.github.com/repos/hookdeckwrite/simple-app/hooks", + "issue_events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/events{/number}", + "events_url": "https://api.github.com/repos/hookdeckwrite/simple-app/events", + "assignees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/assignees{/user}", + "branches_url": "https://api.github.com/repos/hookdeckwrite/simple-app/branches{/branch}", + "tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/tags", + "blobs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/hookdeckwrite/simple-app/statuses/{sha}", + "languages_url": "https://api.github.com/repos/hookdeckwrite/simple-app/languages", + "stargazers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/stargazers", + "contributors_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contributors", + "subscribers_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscribers", + "subscription_url": "https://api.github.com/repos/hookdeckwrite/simple-app/subscription", + "commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/hookdeckwrite/simple-app/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/hookdeckwrite/simple-app/contents/{+path}", + "compare_url": "https://api.github.com/repos/hookdeckwrite/simple-app/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/hookdeckwrite/simple-app/merges", + "archive_url": "https://api.github.com/repos/hookdeckwrite/simple-app/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/hookdeckwrite/simple-app/downloads", + "issues_url": "https://api.github.com/repos/hookdeckwrite/simple-app/issues{/number}", + "pulls_url": "https://api.github.com/repos/hookdeckwrite/simple-app/pulls{/number}", + "milestones_url": "https://api.github.com/repos/hookdeckwrite/simple-app/milestones{/number}", + "notifications_url": "https://api.github.com/repos/hookdeckwrite/simple-app/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/hookdeckwrite/simple-app/labels{/name}", + "releases_url": "https://api.github.com/repos/hookdeckwrite/simple-app/releases{/id}", + "deployments_url": "https://api.github.com/repos/hookdeckwrite/simple-app/deployments", + "created_at": "2023-01-23T05:59:11Z", + "updated_at": "2023-03-06T04:19:17Z", + "pushed_at": "2023-03-05T23:04:11Z", + "git_url": "git://github.com/hookdeckwrite/simple-app.git", + "ssh_url": "git@github.com:hookdeckwrite/simple-app.git", + "clone_url": "https://github.com/hookdeckwrite/simple-app.git", + "svn_url": "https://github.com/hookdeckwrite/simple-app", + "homepage": null, + "size": 3, + "stargazers_count": 1, + "watchers_count": 1, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 0, + "license": null, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [], + "visibility": "public", + "forks": 0, + "open_issues": 0, + "watchers": 1, + "default_branch": "main" + }, + "sender": { + "login": "hookdeckwrite", + "id": 123277766, + "node_id": "U_kgDOB1kRxg", + "avatar_url": "https://avatars.githubusercontent.com/u/123277766?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hookdeckwrite", + "html_url": "https://github.com/hookdeckwrite", + "followers_url": "https://api.github.com/users/hookdeckwrite/followers", + "following_url": "https://api.github.com/users/hookdeckwrite/following{/other_user}", + "gists_url": "https://api.github.com/users/hookdeckwrite/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hookdeckwrite/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hookdeckwrite/subscriptions", + "organizations_url": "https://api.github.com/users/hookdeckwrite/orgs", + "repos_url": "https://api.github.com/users/hookdeckwrite/repos", + "events_url": "https://api.github.com/users/hookdeckwrite/events{/privacy}", + "received_events_url": "https://api.github.com/users/hookdeckwrite/received_events", + "type": "User", + "site_admin": false + } + }, + "extraHeaders": { + "x-github-event": "watch" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:github/2022-11-28", + "snapshotDate": "2026-07" + } + }, + { + "provider": "gitlab", + "providerLabel": "GitLab", + "eventType": "Confidential Issue Hook", + "name": "Confidential Issue Hook", + "body": { + "object_kind": "issue", + "event_type": "issue", + "user": { + "id": 13576927, + "name": "Hookdeck Write", + "username": "hookdeckwrite", + "avatar_url": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?s=80&d=identicon", + "email": "[REDACTED]" + }, + "project": { + "id": 42958108, + "name": "Hookdeck-write", + "description": null, + "web_url": "https://gitlab.com/hookdeckwrite/hookdeck-write", + "avatar_url": null, + "git_ssh_url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "git_http_url": "https://gitlab.com/hookdeckwrite/hookdeck-write.git", + "namespace": "Hookdeck Write", + "visibility_level": 0, + "path_with_namespace": "hookdeckwrite/hookdeck-write", + "default_branch": "main", + "ci_config_path": "", + "homepage": "https://gitlab.com/hookdeckwrite/hookdeck-write", + "url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "ssh_url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "http_url": "https://gitlab.com/hookdeckwrite/hookdeck-write.git" + }, + "object_attributes": { + "author_id": 13576927, + "closed_at": null, + "confidential": false, + "created_at": "2023-02-20T19:43:43.306Z", + "description": "", + "discussion_locked": null, + "due_date": null, + "id": 124017598, + "iid": 1, + "last_edited_at": null, + "last_edited_by_id": null, + "milestone_id": null, + "moved_to_id": null, + "duplicated_to_id": null, + "project_id": 42958108, + "relative_position": 513, + "state_id": 1, + "time_estimate": 0, + "title": "test tissue", + "updated_at": "2023-02-20T19:43:43.306Z", + "updated_by_id": null, + "weight": null, + "url": "https://gitlab.com/hookdeckwrite/hookdeck-write/-/issues/1", + "total_time_spent": 0, + "time_change": 0, + "human_total_time_spent": null, + "human_time_change": null, + "human_time_estimate": null, + "assignee_ids": [], + "assignee_id": null, + "labels": [], + "state": "opened", + "severity": "unknown" + }, + "labels": [], + "changes": {}, + "repository": { + "name": "Hookdeck-write", + "url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "description": null, + "homepage": "https://gitlab.com/hookdeckwrite/hookdeck-write" + } + }, + "extraHeaders": { + "x-gitlab-event": "Confidential Issue Hook", + "x-gitlab-event-uuid": "f92eb1b5-1a40-4e58-b001-54d2a86fed80", + "x-gitlab-instance": "https://gitlab.com", + "x-gitlab-token": "secrettoken" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:gitlab/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "gitlab", + "providerLabel": "GitLab", + "eventType": "Issue Hook", + "name": "Issue Hook", + "body": { + "object_kind": "issue", + "event_type": "issue", + "user": { + "id": 13576927, + "name": "Hookdeck Write", + "username": "hookdeckwrite", + "avatar_url": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?s=80&d=identicon", + "email": "[REDACTED]" + }, + "project": { + "id": 42958108, + "name": "Hookdeck-write", + "description": null, + "web_url": "https://gitlab.com/hookdeckwrite/hookdeck-write", + "avatar_url": null, + "git_ssh_url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "git_http_url": "https://gitlab.com/hookdeckwrite/hookdeck-write.git", + "namespace": "Hookdeck Write", + "visibility_level": 0, + "path_with_namespace": "hookdeckwrite/hookdeck-write", + "default_branch": "main", + "ci_config_path": "", + "homepage": "https://gitlab.com/hookdeckwrite/hookdeck-write", + "url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "ssh_url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "http_url": "https://gitlab.com/hookdeckwrite/hookdeck-write.git" + }, + "object_attributes": { + "author_id": 13576927, + "closed_at": null, + "confidential": false, + "created_at": "2023-02-20T19:43:43.306Z", + "description": "", + "discussion_locked": null, + "due_date": null, + "id": 124017598, + "iid": 1, + "last_edited_at": null, + "last_edited_by_id": null, + "milestone_id": null, + "moved_to_id": null, + "duplicated_to_id": null, + "project_id": 42958108, + "relative_position": 513, + "state_id": 1, + "time_estimate": 0, + "title": "test tissue", + "updated_at": "2023-02-20T19:43:43.306Z", + "updated_by_id": null, + "weight": null, + "url": "https://gitlab.com/hookdeckwrite/hookdeck-write/-/issues/1", + "total_time_spent": 0, + "time_change": 0, + "human_total_time_spent": null, + "human_time_change": null, + "human_time_estimate": null, + "assignee_ids": [], + "assignee_id": null, + "labels": [], + "state": "opened", + "severity": "unknown" + }, + "labels": [], + "changes": {}, + "repository": { + "name": "Hookdeck-write", + "url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "description": null, + "homepage": "https://gitlab.com/hookdeckwrite/hookdeck-write" + } + }, + "extraHeaders": { + "x-gitlab-event": "Issue Hook", + "x-gitlab-event-uuid": "3e241fee-25e7-48ae-9d58-a1c88528f92f", + "x-gitlab-instance": "https://gitlab.com", + "x-gitlab-token": "secrettoken" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:gitlab/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "gitlab", + "providerLabel": "GitLab", + "eventType": "Note Hook", + "name": "Note Hook", + "body": { + "object_kind": "note", + "event_type": "note", + "user": { + "id": 13576927, + "name": "Hookdeck Write", + "username": "hookdeckwrite", + "avatar_url": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?s=80&d=identicon", + "email": "[REDACTED]" + }, + "project_id": 42958108, + "project": { + "id": 42958108, + "name": "Hookdeck-write", + "description": null, + "web_url": "https://gitlab.com/hookdeckwrite/hookdeck-write", + "avatar_url": null, + "git_ssh_url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "git_http_url": "https://gitlab.com/hookdeckwrite/hookdeck-write.git", + "namespace": "Hookdeck Write", + "visibility_level": 0, + "path_with_namespace": "hookdeckwrite/hookdeck-write", + "default_branch": "main", + "ci_config_path": "", + "homepage": "https://gitlab.com/hookdeckwrite/hookdeck-write", + "url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "ssh_url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "http_url": "https://gitlab.com/hookdeckwrite/hookdeck-write.git" + }, + "object_attributes": { + "attachment": null, + "author_id": 13576927, + "change_position": { + "base_sha": null, + "start_sha": null, + "head_sha": null, + "old_path": null, + "new_path": null, + "position_type": "text", + "old_line": null, + "new_line": null, + "line_range": null + }, + "commit_id": "6752dd8e9b70a034e36b495d26626dafef42c53c", + "created_at": "2023-03-06 04:38:16 UTC", + "discussion_id": "1f3a53063b9739107c9dc86a12aebb03d197a91b", + "id": 1301562881, + "line_code": "8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d_11_12", + "note": "commented", + "noteable_id": null, + "noteable_type": "Commit", + "original_position": { + "base_sha": "c32d8669f6b1afa6c8b4b634fad5201f6dd9d2f7", + "start_sha": "c32d8669f6b1afa6c8b4b634fad5201f6dd9d2f7", + "head_sha": "6752dd8e9b70a034e36b495d26626dafef42c53c", + "old_path": "README.md", + "new_path": "README.md", + "position_type": "text", + "old_line": null, + "new_line": 12, + "line_range": null + }, + "position": { + "base_sha": "c32d8669f6b1afa6c8b4b634fad5201f6dd9d2f7", + "start_sha": "c32d8669f6b1afa6c8b4b634fad5201f6dd9d2f7", + "head_sha": "6752dd8e9b70a034e36b495d26626dafef42c53c", + "old_path": "README.md", + "new_path": "README.md", + "position_type": "text", + "old_line": null, + "new_line": 12, + "line_range": null + }, + "project_id": 42958108, + "resolved_at": null, + "resolved_by_id": null, + "resolved_by_push": null, + "st_diff": null, + "system": false, + "type": "DiffNote", + "updated_at": "2023-03-06 04:38:16 UTC", + "updated_by_id": null, + "description": "commented", + "url": "https://gitlab.com/hookdeckwrite/hookdeck-write/-/commit/6752dd8e9b70a034e36b495d26626dafef42c53c#note_1301562881" + }, + "repository": { + "name": "Hookdeck-write", + "url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "description": null, + "homepage": "https://gitlab.com/hookdeckwrite/hookdeck-write" + }, + "commit": { + "id": "6752dd8e9b70a034e36b495d26626dafef42c53c", + "message": "Update README.md", + "title": "Update README.md", + "timestamp": "2023-01-27T15:11:04+00:00", + "url": "https://gitlab.com/hookdeckwrite/hookdeck-write/-/commit/6752dd8e9b70a034e36b495d26626dafef42c53c", + "author": { + "name": "Hookdeck Write", + "email": "hookdeckwrite@gmail.com" + } + } + }, + "extraHeaders": { + "x-gitlab-event": "Note Hook", + "x-gitlab-event-uuid": "29f888a0-b898-487c-a0bf-ce135adfa206", + "x-gitlab-instance": "https://gitlab.com", + "x-gitlab-token": "secrettoken" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:gitlab/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "gitlab", + "providerLabel": "GitLab", + "eventType": "Push Hook", + "name": "Push Hook", + "body": { + "object_kind": "push", + "event_name": "push", + "before": "0000000000000000000000000000000000000000", + "after": "6752dd8e9b70a034e36b495d26626dafef42c53c", + "ref": "refs/heads/manage", + "checkout_sha": "6752dd8e9b70a034e36b495d26626dafef42c53c", + "message": null, + "user_id": 13576927, + "user_name": "Hookdeck Write", + "user_username": "hookdeckwrite", + "user_email": null, + "user_avatar": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?s=80&d=identicon", + "project_id": 42958108, + "project": { + "id": 42958108, + "name": "Hookdeck-write", + "description": null, + "web_url": "https://gitlab.com/hookdeckwrite/hookdeck-write", + "avatar_url": null, + "git_ssh_url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "git_http_url": "https://gitlab.com/hookdeckwrite/hookdeck-write.git", + "namespace": "Hookdeck Write", + "visibility_level": 0, + "path_with_namespace": "hookdeckwrite/hookdeck-write", + "default_branch": "main", + "ci_config_path": "", + "homepage": "https://gitlab.com/hookdeckwrite/hookdeck-write", + "url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "ssh_url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "http_url": "https://gitlab.com/hookdeckwrite/hookdeck-write.git" + }, + "commits": [], + "total_commits_count": 0, + "push_options": {}, + "repository": { + "name": "Hookdeck-write", + "url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "description": null, + "homepage": "https://gitlab.com/hookdeckwrite/hookdeck-write", + "git_http_url": "https://gitlab.com/hookdeckwrite/hookdeck-write.git", + "git_ssh_url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "visibility_level": 0 + } + }, + "extraHeaders": { + "x-gitlab-event": "Push Hook", + "x-gitlab-event-uuid": "5866755a-6a7d-430f-b7c3-bd29ec77e3f3", + "x-gitlab-instance": "https://gitlab.com", + "x-gitlab-token": "secrettoken" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:gitlab/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "gitlab", + "providerLabel": "GitLab", + "eventType": "Release Hook", + "name": "Release Hook", + "body": { + "id": 7054492, + "created_at": "2023-03-06 04:44:05 UTC", + "description": "Leta see", + "name": "os", + "released_at": "2023-03-06 04:44:05 UTC", + "tag": "observe", + "object_kind": "release", + "project": { + "id": 42958108, + "name": "Hookdeck-write", + "description": null, + "web_url": "https://gitlab.com/hookdeckwrite/hookdeck-write", + "avatar_url": null, + "git_ssh_url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "git_http_url": "https://gitlab.com/hookdeckwrite/hookdeck-write.git", + "namespace": "Hookdeck Write", + "visibility_level": 0, + "path_with_namespace": "hookdeckwrite/hookdeck-write", + "default_branch": "main", + "ci_config_path": "", + "homepage": "https://gitlab.com/hookdeckwrite/hookdeck-write", + "url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "ssh_url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "http_url": "https://gitlab.com/hookdeckwrite/hookdeck-write.git" + }, + "url": "https://gitlab.com/hookdeckwrite/hookdeck-write/-/releases/observe", + "action": "create", + "assets": { + "count": 4, + "links": [], + "sources": [ + { + "format": "zip", + "url": "https://gitlab.com/hookdeckwrite/hookdeck-write/-/archive/observe/hookdeck-write-observe.zip" + }, + { + "format": "tar.gz", + "url": "https://gitlab.com/hookdeckwrite/hookdeck-write/-/archive/observe/hookdeck-write-observe.tar.gz" + }, + { + "format": "tar.bz2", + "url": "https://gitlab.com/hookdeckwrite/hookdeck-write/-/archive/observe/hookdeck-write-observe.tar.bz2" + }, + { + "format": "tar", + "url": "https://gitlab.com/hookdeckwrite/hookdeck-write/-/archive/observe/hookdeck-write-observe.tar" + } + ] + }, + "commit": { + "id": "6752dd8e9b70a034e36b495d26626dafef42c53c", + "message": "Update README.md", + "title": "Update README.md", + "timestamp": "2023-01-27T15:11:04+00:00", + "url": "https://gitlab.com/hookdeckwrite/hookdeck-write/-/commit/6752dd8e9b70a034e36b495d26626dafef42c53c", + "author": { + "name": "Hookdeck Write", + "email": "hookdeckwrite@gmail.com" + } + } + }, + "extraHeaders": { + "x-gitlab-event": "Release Hook", + "x-gitlab-event-uuid": "5977e99f-9dd2-41b3-b169-1e66461f3995", + "x-gitlab-instance": "https://gitlab.com", + "x-gitlab-token": "secrettoken" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:gitlab/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "gitlab", + "providerLabel": "GitLab", + "eventType": "Tag Push Hook", + "name": "Tag Push Hook", + "body": { + "object_kind": "tag_push", + "event_name": "tag_push", + "before": "0000000000000000000000000000000000000000", + "after": "844cdc62966d4bf4b660285912fa530bf9c63257", + "ref": "refs/tags/observe", + "checkout_sha": "6752dd8e9b70a034e36b495d26626dafef42c53c", + "message": "observing release", + "user_id": 13576927, + "user_name": "Hookdeck Write", + "user_username": "hookdeckwrite", + "user_email": null, + "user_avatar": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?s=80&d=identicon", + "project_id": 42958108, + "project": { + "id": 42958108, + "name": "Hookdeck-write", + "description": null, + "web_url": "https://gitlab.com/hookdeckwrite/hookdeck-write", + "avatar_url": null, + "git_ssh_url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "git_http_url": "https://gitlab.com/hookdeckwrite/hookdeck-write.git", + "namespace": "Hookdeck Write", + "visibility_level": 0, + "path_with_namespace": "hookdeckwrite/hookdeck-write", + "default_branch": "main", + "ci_config_path": "", + "homepage": "https://gitlab.com/hookdeckwrite/hookdeck-write", + "url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "ssh_url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "http_url": "https://gitlab.com/hookdeckwrite/hookdeck-write.git" + }, + "commits": [ + { + "id": "6752dd8e9b70a034e36b495d26626dafef42c53c", + "message": "Update README.md", + "title": "Update README.md", + "timestamp": "2023-01-27T15:11:04+00:00", + "url": "https://gitlab.com/hookdeckwrite/hookdeck-write/-/commit/6752dd8e9b70a034e36b495d26626dafef42c53c", + "author": { + "name": "Hookdeck Write", + "email": "hookdeckwrite@gmail.com" + }, + "added": [], + "modified": ["README.md"], + "removed": [] + } + ], + "total_commits_count": 1, + "push_options": {}, + "repository": { + "name": "Hookdeck-write", + "url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "description": null, + "homepage": "https://gitlab.com/hookdeckwrite/hookdeck-write", + "git_http_url": "https://gitlab.com/hookdeckwrite/hookdeck-write.git", + "git_ssh_url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "visibility_level": 0 + } + }, + "extraHeaders": { + "x-gitlab-event": "Tag Push Hook", + "x-gitlab-event-uuid": "77d0cb42-4aba-4aed-9c66-79e65c979e71", + "x-gitlab-instance": "https://gitlab.com", + "x-gitlab-token": "secrettoken" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:gitlab/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "gitlab", + "providerLabel": "GitLab", + "eventType": "Wiki Page Hook", + "name": "Wiki Page Hook", + "body": { + "object_kind": "wiki_page", + "user": { + "id": 13576927, + "name": "Hookdeck Write", + "username": "hookdeckwrite", + "avatar_url": "https://secure.gravatar.com/avatar/8d9e5d44f7eb9d70f015e80e50d1fa98?s=80&d=identicon", + "email": "[REDACTED]" + }, + "project": { + "id": 42958108, + "name": "Hookdeck-write", + "description": null, + "web_url": "https://gitlab.com/hookdeckwrite/hookdeck-write", + "avatar_url": null, + "git_ssh_url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "git_http_url": "https://gitlab.com/hookdeckwrite/hookdeck-write.git", + "namespace": "Hookdeck Write", + "visibility_level": 0, + "path_with_namespace": "hookdeckwrite/hookdeck-write", + "default_branch": "main", + "ci_config_path": "", + "homepage": "https://gitlab.com/hookdeckwrite/hookdeck-write", + "url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "ssh_url": "git@gitlab.com:hookdeckwrite/hookdeck-write.git", + "http_url": "https://gitlab.com/hookdeckwrite/hookdeck-write.git" + }, + "wiki": { + "web_url": "https://gitlab.com/hookdeckwrite/hookdeck-write/-/wikis/home", + "git_ssh_url": "git@gitlab.com:hookdeckwrite/hookdeck-write.wiki.git", + "git_http_url": "https://gitlab.com/hookdeckwrite/hookdeck-write.wiki.git", + "path_with_namespace": "hookdeckwrite/hookdeck-write.wiki", + "default_branch": "main" + }, + "object_attributes": { + "content": "Hello new wiki page", + "format": "markdown", + "message": "Create home", + "title": "home", + "slug": "home", + "url": "https://gitlab.com/hookdeckwrite/hookdeck-write/-/wikis/home", + "action": "create", + "diff_url": "https://gitlab.com/hookdeckwrite/hookdeck-write/-/wikis/home/diff?version_id=fa0715e03fc718299e66127d92d2d0eebeacc69d" + } + }, + "extraHeaders": { + "x-gitlab-event": "Wiki Page Hook", + "x-gitlab-event-uuid": "f697fa74-d5e3-45dc-8be2-5418ea11362b", + "x-gitlab-instance": "https://gitlab.com", + "x-gitlab-token": "secrettoken" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:gitlab/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "hubspot", + "providerLabel": "HubSpot", + "eventType": "COMPANY", + "name": "COMPANY", + "body": { + "portalId": 27145807, + "objectType": "COMPANY", + "objectTypeId": "0-2", + "objectId": 7298894071, + "properties": { + "country": { + "versions": [ + { + "name": "country", + "value": "United States", + "timestamp": 1680448389477, + "sourceId": "CompanyInsightsPropertyMappings", + "source": "COMPANY_INSIGHTS", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "United States", + "timestamp": 1680448389477, + "persistenceTimestamp": 1680448389477, + "source": "COMPANY_INSIGHTS", + "sourceId": "CompanyInsightsPropertyMappings", + "updatedByUserId": null + }, + "city": { + "versions": [ + { + "name": "city", + "value": "Mountain View", + "timestamp": 1680448389197, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "bed919cd-4645-438c-bf3d-a998796ec860", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "Mountain View", + "timestamp": 1680448389197, + "persistenceTimestamp": 1680448389197, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "num_associated_contacts": { + "versions": [ + { + "name": "num_associated_contacts", + "value": "0", + "timestamp": 1680448390046, + "sourceId": "RollupProperties", + "source": "CALCULATED", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "0", + "timestamp": 1680448390046, + "persistenceTimestamp": 1680448390046, + "source": "CALCULATED", + "sourceId": "RollupProperties", + "updatedByUserId": null + }, + "timezone": { + "versions": [ + { + "name": "timezone", + "value": "America/Los_Angeles", + "timestamp": 1680448389197, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "bed919cd-4645-438c-bf3d-a998796ec860", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "America/Los_Angeles", + "timestamp": 1680448389197, + "persistenceTimestamp": 1680448389197, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "facebook_company_page": { + "versions": [ + { + "name": "facebook_company_page", + "value": "https://facebook.com/google", + "timestamp": 1680448389477, + "sourceId": "CompanyInsightsPropertyMappings", + "source": "COMPANY_INSIGHTS", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "https://facebook.com/google", + "timestamp": 1680448389477, + "persistenceTimestamp": 1680448389477, + "source": "COMPANY_INSIGHTS", + "sourceId": "CompanyInsightsPropertyMappings", + "updatedByUserId": null + }, + "hs_pipeline": { + "versions": [ + { + "name": "hs_pipeline", + "value": "companies-lifecycle-pipeline", + "timestamp": 1680448389197, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "bed919cd-4645-438c-bf3d-a998796ec860", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "companies-lifecycle-pipeline", + "timestamp": 1680448389197, + "persistenceTimestamp": 1680448389197, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "createdate": { + "versions": [ + { + "name": "createdate", + "value": "1680448389197", + "timestamp": 1680448389197, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "bed919cd-4645-438c-bf3d-a998796ec860", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "1680448389197", + "timestamp": 1680448389197, + "persistenceTimestamp": 1680448389197, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "description": { + "versions": [ + { + "name": "description", + "value": "Google LLC is an American multinational technology company that focuses on artificial intelligence, search engine, online advertising, cloud computing, computer software, quantum computing, e-commerce, and consumer electronics.", + "timestamp": 1680448389197, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "bed919cd-4645-438c-bf3d-a998796ec860", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "Google LLC is an American multinational technology company that focuses on artificial intelligence, search engine, online advertising, cloud computing, computer software, quantum computing, e-commerce, and consumer electronics.", + "timestamp": 1680448389197, + "persistenceTimestamp": 1680448389197, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "hs_num_blockers": { + "versions": [ + { + "name": "hs_num_blockers", + "value": "0", + "timestamp": 1680448390047, + "sourceId": "RollupProperties", + "source": "CALCULATED", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "0", + "timestamp": 1680448390047, + "persistenceTimestamp": 1680448390047, + "source": "CALCULATED", + "sourceId": "RollupProperties", + "updatedByUserId": null + }, + "industry": { + "versions": [ + { + "name": "industry", + "value": "COMPUTER_SOFTWARE", + "timestamp": 1680448389197, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "bed919cd-4645-438c-bf3d-a998796ec860", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "COMPUTER_SOFTWARE", + "timestamp": 1680448389197, + "persistenceTimestamp": 1680448389197, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "total_money_raised": { + "versions": [ + { + "name": "total_money_raised", + "value": "786.1M", + "timestamp": 1680448389477, + "sourceId": "CompanyInsightsPropertyMappings", + "source": "COMPANY_INSIGHTS", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "786.1M", + "timestamp": 1680448389477, + "persistenceTimestamp": 1680448389477, + "source": "COMPANY_INSIGHTS", + "sourceId": "CompanyInsightsPropertyMappings", + "updatedByUserId": null + }, + "web_technologies": { + "versions": [ + { + "name": "web_technologies", + "value": "amazon_s3;wordpress;google_analytics;amazon__cloudfront;double_click;google_tag_manager", + "timestamp": 1680448389477, + "sourceId": "CompanyInsightsPropertyMappings", + "source": "COMPANY_INSIGHTS", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "amazon_s3;wordpress;google_analytics;amazon__cloudfront;double_click;google_tag_manager", + "timestamp": 1680448389477, + "persistenceTimestamp": 1680448389477, + "source": "COMPANY_INSIGHTS", + "sourceId": "CompanyInsightsPropertyMappings", + "updatedByUserId": null + }, + "numberofemployees": { + "versions": [ + { + "name": "numberofemployees", + "value": "10000", + "timestamp": 1680448389197, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "bed919cd-4645-438c-bf3d-a998796ec860", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "10000", + "timestamp": 1680448389197, + "persistenceTimestamp": 1680448389197, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "linkedin_company_page": { + "versions": [ + { + "name": "linkedin_company_page", + "value": "https://www.linkedin.com/company/google", + "timestamp": 1680448389197, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "bed919cd-4645-438c-bf3d-a998796ec860", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "https://www.linkedin.com/company/google", + "timestamp": 1680448389197, + "persistenceTimestamp": 1680448389197, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "hubspot_owner_id": { + "versions": [ + { + "name": "hubspot_owner_id", + "value": "823493108", + "timestamp": 1680448389197, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "bed919cd-4645-438c-bf3d-a998796ec860", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "823493108", + "timestamp": 1680448389197, + "persistenceTimestamp": 1680448389197, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "annualrevenue": { + "versions": [ + { + "name": "annualrevenue", + "value": "1000000000", + "timestamp": 1680448389197, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "bed919cd-4645-438c-bf3d-a998796ec860", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "1000000000", + "timestamp": 1680448389197, + "persistenceTimestamp": 1680448389197, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "hs_created_by_user_id": { + "versions": [ + { + "name": "hs_created_by_user_id", + "value": "50549138", + "timestamp": 1680448389197, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "bed919cd-4645-438c-bf3d-a998796ec860", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "50549138", + "timestamp": 1680448389197, + "persistenceTimestamp": 1680448389197, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "founded_year": { + "versions": [ + { + "name": "founded_year", + "value": "1998", + "timestamp": 1680448389477, + "sourceId": "CompanyInsightsPropertyMappings", + "source": "COMPANY_INSIGHTS", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "1998", + "timestamp": 1680448389477, + "persistenceTimestamp": 1680448389477, + "source": "COMPANY_INSIGHTS", + "sourceId": "CompanyInsightsPropertyMappings", + "updatedByUserId": null + }, + "state": { + "versions": [ + { + "name": "state", + "value": "CA", + "timestamp": 1680448389197, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "bed919cd-4645-438c-bf3d-a998796ec860", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "CA", + "timestamp": 1680448389197, + "persistenceTimestamp": 1680448389197, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "hs_all_owner_ids": { + "versions": [ + { + "name": "hs_all_owner_ids", + "value": "823493108", + "timestamp": 1680448389197, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "bed919cd-4645-438c-bf3d-a998796ec860", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "823493108", + "timestamp": 1680448389197, + "persistenceTimestamp": 1680448389197, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "linkedinbio": { + "versions": [ + { + "name": "linkedinbio", + "value": "Google LLC is an American multinational technology company that focuses on artificial intelligence, search engine, online advertising, cloud computing, computer software, quantum computing, e-commerce, and consumer electronics.", + "timestamp": 1680448389477, + "sourceId": "CompanyInsightsPropertyMappings", + "source": "COMPANY_INSIGHTS", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "Google LLC is an American multinational technology company that focuses on artificial intelligence, search engine, online advertising, cloud computing, computer software, quantum computing, e-commerce, and consumer electronics.", + "timestamp": 1680448389477, + "persistenceTimestamp": 1680448389477, + "source": "COMPANY_INSIGHTS", + "sourceId": "CompanyInsightsPropertyMappings", + "updatedByUserId": null + }, + "hs_num_open_deals": { + "versions": [ + { + "name": "hs_num_open_deals", + "value": "0", + "timestamp": 1680448390046, + "sourceId": "RollupProperties", + "source": "CALCULATED", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "0", + "timestamp": 1680448390046, + "persistenceTimestamp": 1680448390046, + "source": "CALCULATED", + "sourceId": "RollupProperties", + "updatedByUserId": null + }, + "zip": { + "versions": [ + { + "name": "zip", + "value": "94043", + "timestamp": 1680448389197, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "bed919cd-4645-438c-bf3d-a998796ec860", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "94043", + "timestamp": 1680448389197, + "persistenceTimestamp": 1680448389197, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "website": { + "versions": [ + { + "name": "website", + "value": "google.com", + "timestamp": 1680448389197, + "source": "CALCULATED", + "sourceVid": [], + "requestId": "bed919cd-4645-438c-bf3d-a998796ec860", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "google.com", + "timestamp": 1680448389197, + "persistenceTimestamp": 1680448389197, + "source": "CALCULATED", + "sourceId": null, + "updatedByUserId": 50549138 + }, + "address": { + "versions": [ + { + "name": "address", + "value": "1600 Amphitheatre Parkway", + "timestamp": 1680448389477, + "sourceId": "CompanyInsightsPropertyMappings", + "source": "COMPANY_INSIGHTS", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "1600 Amphitheatre Parkway", + "timestamp": 1680448389477, + "persistenceTimestamp": 1680448389477, + "source": "COMPANY_INSIGHTS", + "sourceId": "CompanyInsightsPropertyMappings", + "updatedByUserId": null + }, + "hs_user_ids_of_all_owners": { + "versions": [ + { + "name": "hs_user_ids_of_all_owners", + "value": "50549138", + "timestamp": 1680448389197, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "bed919cd-4645-438c-bf3d-a998796ec860", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "50549138", + "timestamp": 1680448389197, + "persistenceTimestamp": 1680448389197, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "twitterhandle": { + "versions": [ + { + "name": "twitterhandle", + "value": "google", + "timestamp": 1680448389477, + "sourceId": "CompanyInsightsPropertyMappings", + "source": "COMPANY_INSIGHTS", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "google", + "timestamp": 1680448389477, + "persistenceTimestamp": 1680448389477, + "source": "COMPANY_INSIGHTS", + "sourceId": "CompanyInsightsPropertyMappings", + "updatedByUserId": null + }, + "hs_target_account_probability": { + "versions": [ + { + "name": "hs_target_account_probability", + "value": "0.49565839767456055", + "timestamp": 1680448390378, + "source": "SALES", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "0.49565839767456055", + "timestamp": 1680448390378, + "persistenceTimestamp": 1680448390378, + "source": "SALES", + "sourceId": null, + "updatedByUserId": null + }, + "hs_lastmodifieddate": { + "versions": [ + { + "name": "hs_lastmodifieddate", + "value": "1680448390378", + "timestamp": 1680448390378, + "source": "SALES", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "1680448390378", + "timestamp": 1680448390378, + "persistenceTimestamp": 1680448390378, + "source": "SALES", + "sourceId": null, + "updatedByUserId": null + }, + "hs_num_decision_makers": { + "versions": [ + { + "name": "hs_num_decision_makers", + "value": "0", + "timestamp": 1680448390046, + "sourceId": "RollupProperties", + "source": "CALCULATED", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "0", + "timestamp": 1680448390046, + "persistenceTimestamp": 1680448390046, + "source": "CALCULATED", + "sourceId": "RollupProperties", + "updatedByUserId": null + }, + "hubspot_owner_assigneddate": { + "versions": [ + { + "name": "hubspot_owner_assigneddate", + "value": "1680448389197", + "timestamp": 1680448389197, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "bed919cd-4645-438c-bf3d-a998796ec860", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "1680448389197", + "timestamp": 1680448389197, + "persistenceTimestamp": 1680448389197, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "phone": { + "versions": [ + { + "name": "phone", + "value": "+1 650-253-0000", + "timestamp": 1680448389477, + "sourceId": "CompanyInsightsPropertyMappings", + "source": "COMPANY_INSIGHTS", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "+1 650-253-0000", + "timestamp": 1680448389477, + "persistenceTimestamp": 1680448389477, + "source": "COMPANY_INSIGHTS", + "sourceId": "CompanyInsightsPropertyMappings", + "updatedByUserId": null + }, + "domain": { + "versions": [ + { + "name": "domain", + "value": "google.com", + "timestamp": 1680448389197, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "bed919cd-4645-438c-bf3d-a998796ec860", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "google.com", + "timestamp": 1680448389197, + "persistenceTimestamp": 1680448389197, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "hs_num_child_companies": { + "versions": [ + { + "name": "hs_num_child_companies", + "value": "0", + "timestamp": 1680448390047, + "sourceId": "RollupProperties", + "source": "CALCULATED", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "0", + "timestamp": 1680448390047, + "persistenceTimestamp": 1680448390047, + "source": "CALCULATED", + "sourceId": "RollupProperties", + "updatedByUserId": null + }, + "hs_num_contacts_with_buying_roles": { + "versions": [ + { + "name": "hs_num_contacts_with_buying_roles", + "value": "0", + "timestamp": 1680448390046, + "sourceId": "RollupProperties", + "source": "CALCULATED", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "0", + "timestamp": 1680448390046, + "persistenceTimestamp": 1680448390046, + "source": "CALCULATED", + "sourceId": "RollupProperties", + "updatedByUserId": null + }, + "hs_object_id": { + "versions": [ + { + "name": "hs_object_id", + "value": "7298894071", + "timestamp": 1680448389197, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "bed919cd-4645-438c-bf3d-a998796ec860", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "7298894071", + "timestamp": 1680448389197, + "persistenceTimestamp": 1680448389197, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "is_public": { + "versions": [ + { + "name": "is_public", + "value": "true", + "timestamp": 1680448389477, + "sourceId": "CompanyInsightsPropertyMappings", + "source": "COMPANY_INSIGHTS", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "true", + "timestamp": 1680448389477, + "persistenceTimestamp": 1680448389477, + "source": "COMPANY_INSIGHTS", + "sourceId": "CompanyInsightsPropertyMappings", + "updatedByUserId": null + }, + "name": { + "versions": [ + { + "name": "name", + "value": "Google", + "timestamp": 1680448389197, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "bed919cd-4645-438c-bf3d-a998796ec860", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "Google", + "timestamp": 1680448389197, + "persistenceTimestamp": 1680448389197, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "lifecyclestage": { + "versions": [ + { + "name": "lifecyclestage", + "value": "lead", + "timestamp": 1680448389197, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "bed919cd-4645-438c-bf3d-a998796ec860", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "lead", + "timestamp": 1680448389197, + "persistenceTimestamp": 1680448389197, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "hs_updated_by_user_id": { + "versions": [ + { + "name": "hs_updated_by_user_id", + "value": "50549138", + "timestamp": 1680448389197, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "bed919cd-4645-438c-bf3d-a998796ec860", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "50549138", + "timestamp": 1680448389197, + "persistenceTimestamp": 1680448389197, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + } + }, + "version": 4, + "secondaryIdentifier": "google.com", + "isDeleted": false + }, + "extraHeaders": { + "x-hubspot-client-ip": "172.21.85.188", + "x-hubspot-correlation-id": "4c1e9d73-2536-463b-b98c-ccb602123544", + "x-hubspot-request-timestamp": "1680448400841", + "x-hubspot-requesting-chain-bin": "CkwKQEF1dG9tYXRpb25QbGF0Zm9ybUV4ZWN1dGlvbi1leGVjdXRpb25TbG93QWN0aW9uVHlwZUxhbmVXYWxXb3JrZXISCFBST0NGSUxFCjEKJUF1dG9tYXRpb25BY3Rpb25zRXhlY3V0aW9uU2VydmljZS13ZWISCFBST0NGSUxFEgIIAg==", + "x-hubspot-timeout-millis": "44986", + "x-trace": "2B77CBE81E60CC0E534F6EE10E6A6744D39CC6F13A000000000000000000" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:hubspot/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "hubspot", + "providerLabel": "HubSpot", + "eventType": "CONTACT", + "name": "CONTACT", + "body": { + "vid": 251, + "canonical-vid": 251, + "merged-vids": [], + "portal-id": 27145807, + "is-contact": true, + "properties": { + "hs_latest_source_data_2": { + "value": "userId:50549138", + "versions": [ + { + "value": "userId:50549138", + "source-type": "ANALYTICS", + "source-id": "ContactAnalyticsDetailsUpdateWorker", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448110678, + "selected": false + } + ] + }, + "hs_is_unworked": { + "value": "true", + "versions": [ + { + "value": "true", + "source-type": "CALCULATED", + "source-id": "CalculatedPropertyComputer", + "source-label": null, + "updated-by-user-id": 50549138, + "timestamp": 1680448099688, + "selected": false + } + ] + }, + "hs_latest_source_data_1": { + "value": "CRM_UI", + "versions": [ + { + "value": "CRM_UI", + "source-type": "ANALYTICS", + "source-id": "ContactAnalyticsDetailsUpdateWorker", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448110678, + "selected": false + } + ] + }, + "associatedcompanyid": { + "value": "7298823368", + "versions": [ + { + "value": "7298823368", + "source-type": "CALCULATED", + "source-id": "RollupProperties", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448100462, + "selected": false + } + ] + }, + "firstname": { + "value": "Kovat", + "versions": [ + { + "value": "Kovat", + "source-type": "CRM_UI", + "source-id": "userId:50549138", + "source-label": null, + "updated-by-user-id": 50549138, + "timestamp": 1680448099688, + "selected": false + } + ] + }, + "hs_latest_source": { + "value": "OFFLINE", + "versions": [ + { + "value": "OFFLINE", + "source-type": "ANALYTICS", + "source-id": "ContactAnalyticsDetailsUpdateWorker", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448110678, + "selected": false + } + ] + }, + "num_unique_conversion_events": { + "value": "0", + "versions": [ + { + "value": "0", + "source-type": "MERGE_CONTACTS", + "source-id": null, + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448100250, + "selected": false + } + ] + }, + "hs_analytics_revenue": { + "value": "0.0", + "versions": [ + { + "value": "0.0", + "source-type": "ANALYTICS", + "source-id": "ContactAnalyticsDetailsUpdateWorker", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448110678, + "selected": false + } + ] + }, + "hs_pipeline": { + "value": "contacts-lifecycle-pipeline", + "versions": [ + { + "value": "contacts-lifecycle-pipeline", + "source-type": "CRM_UI", + "source-id": "userId:50549138", + "source-label": null, + "updated-by-user-id": 50549138, + "timestamp": 1680448099688, + "selected": false + } + ] + }, + "createdate": { + "value": "1680448099688", + "versions": [ + { + "value": "1680448099688", + "source-type": "CRM_UI", + "source-id": "userId:50549138", + "source-label": null, + "updated-by-user-id": 50549138, + "timestamp": 1680448099688, + "selected": false + } + ] + }, + "hs_social_num_broadcast_clicks": { + "value": "0", + "versions": [ + { + "value": "0", + "source-type": "ANALYTICS", + "source-id": "ContactAnalyticsDetailsUpdateWorker", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448110678, + "selected": false + } + ] + }, + "hs_analytics_num_visits": { + "value": "0", + "versions": [ + { + "value": "0", + "source-type": "ANALYTICS", + "source-id": "ContactAnalyticsDetailsUpdateWorker", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448110678, + "selected": false + } + ] + }, + "hs_sequences_actively_enrolled_count": { + "value": "0", + "versions": [ + { + "value": "0", + "source-type": "CALCULATED", + "source-id": "RollupProperties", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448100462, + "selected": false + } + ] + }, + "hs_social_linkedin_clicks": { + "value": "0", + "versions": [ + { + "value": "0", + "source-type": "ANALYTICS", + "source-id": "ContactAnalyticsDetailsUpdateWorker", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448110678, + "selected": false + } + ] + }, + "hubspot_owner_id": { + "value": "823493108", + "versions": [ + { + "value": "823493108", + "source-type": "CRM_UI", + "source-id": "userId:50549138", + "source-label": null, + "updated-by-user-id": 50549138, + "timestamp": 1680448099688, + "selected": false + } + ] + }, + "hs_analytics_source": { + "value": "OFFLINE", + "versions": [ + { + "value": "OFFLINE", + "source-type": "ANALYTICS", + "source-id": "ContactAnalyticsDetailsUpdateWorker", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448110678, + "selected": false + } + ] + }, + "hs_created_by_user_id": { + "value": "50549138", + "versions": [ + { + "value": "50549138", + "source-type": "CRM_UI", + "source-id": "userId:50549138", + "source-label": null, + "updated-by-user-id": 50549138, + "timestamp": 1680448099688, + "selected": false + } + ] + }, + "hs_analytics_num_page_views": { + "value": "0", + "versions": [ + { + "value": "0", + "source-type": "ANALYTICS", + "source-id": "ContactAnalyticsDetailsUpdateWorker", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448110678, + "selected": false + } + ] + }, + "hs_email_domain": { + "value": "cyclesat.com", + "versions": [ + { + "value": "cyclesat.com", + "source-type": "CALCULATED", + "source-id": null, + "source-label": null, + "updated-by-user-id": 50549138, + "timestamp": 1680448099688, + "selected": false + } + ] + }, + "hs_all_owner_ids": { + "value": "823493108", + "versions": [ + { + "value": "823493108", + "source-type": "CALCULATED", + "source-id": "PermissionsUpdater", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448100455, + "selected": false + } + ] + }, + "hs_count_is_worked": { + "value": "0", + "versions": [ + { + "value": "0", + "source-type": "CALCULATED", + "source-id": "CalculatedPropertyComputer", + "source-label": null, + "updated-by-user-id": 50549138, + "timestamp": 1680448099688, + "selected": false + } + ] + }, + "email": { + "value": "kovatif642@cyclesat.com", + "versions": [ + { + "value": "kovatif642@cyclesat.com", + "source-type": "CRM_UI", + "source-id": "userId:50549138", + "source-label": null, + "updated-by-user-id": 50549138, + "timestamp": 1680448099688, + "selected": false + } + ] + }, + "hs_latest_source_timestamp": { + "value": "1680448099780", + "versions": [ + { + "value": "1680448099780", + "source-type": "ANALYTICS", + "source-id": "ContactAnalyticsDetailsUpdateWorker", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448110678, + "selected": false + } + ] + }, + "jobtitle": { + "value": "Writer", + "versions": [ + { + "value": "Writer", + "source-type": "CRM_UI", + "source-id": "userId:50549138", + "source-label": null, + "updated-by-user-id": 50549138, + "timestamp": 1680448099688, + "selected": false + } + ] + }, + "hs_analytics_first_timestamp": { + "value": "1680448099688", + "versions": [ + { + "value": "1680448099688", + "source-type": "ANALYTICS", + "source-id": "ContactAnalyticsDetailsUpdateWorker", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448110678, + "selected": false + } + ] + }, + "lastmodifieddate": { + "value": "1680448122728", + "versions": [ + { + "value": "1680448122728", + "source-type": "AUTOMATION_PLATFORM", + "source-id": null, + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448122728, + "selected": false + }, + { + "value": "1680448110678", + "source-type": "API", + "source-id": null, + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448110678, + "selected": false + }, + { + "value": "1680448100475", + "source-type": "API", + "source-id": null, + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448100475, + "selected": false + }, + { + "value": "1680448100462", + "source-type": "API", + "source-id": null, + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448100462, + "selected": false + }, + { + "value": "1680448099688", + "source-type": "CRM_UI", + "source-id": "userId:50549138", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448099688, + "selected": false + } + ] + }, + "hs_social_google_plus_clicks": { + "value": "0", + "versions": [ + { + "value": "0", + "source-type": "ANALYTICS", + "source-id": "ContactAnalyticsDetailsUpdateWorker", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448110678, + "selected": false + } + ] + }, + "hs_user_ids_of_all_owners": { + "value": "50549138", + "versions": [ + { + "value": "50549138", + "source-type": "CALCULATED", + "source-id": "PermissionsUpdater", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448100455, + "selected": false + } + ] + }, + "hs_analytics_average_page_views": { + "value": "0", + "versions": [ + { + "value": "0", + "source-type": "ANALYTICS", + "source-id": "ContactAnalyticsDetailsUpdateWorker", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448110678, + "selected": false + } + ] + }, + "hs_all_contact_vids": { + "value": "251", + "versions": [ + { + "value": "251", + "source-type": "CRM_UI", + "source-id": "userId:50549138", + "source-label": null, + "updated-by-user-id": 50549138, + "timestamp": 1680448099688, + "selected": false + } + ] + }, + "lastname": { + "value": "Tif", + "versions": [ + { + "value": "Tif", + "source-type": "CRM_UI", + "source-id": "userId:50549138", + "source-label": null, + "updated-by-user-id": 50549138, + "timestamp": 1680448099688, + "selected": false + } + ] + }, + "hs_social_facebook_clicks": { + "value": "0", + "versions": [ + { + "value": "0", + "source-type": "ANALYTICS", + "source-id": "ContactAnalyticsDetailsUpdateWorker", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448110678, + "selected": false + } + ] + }, + "hs_is_contact": { + "value": "true", + "versions": [ + { + "value": "true", + "source-type": "CALCULATED", + "source-id": null, + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448099688, + "selected": false + } + ] + }, + "hubspot_owner_assigneddate": { + "value": "1680448099688", + "versions": [ + { + "value": "1680448099688", + "source-type": "CRM_UI", + "source-id": "userId:50549138", + "source-label": null, + "updated-by-user-id": 50549138, + "timestamp": 1680448099688, + "selected": false + } + ] + }, + "num_conversion_events": { + "value": "0", + "versions": [ + { + "value": "0", + "source-type": "MERGE_CONTACTS", + "source-id": null, + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448100250, + "selected": false + } + ] + }, + "hs_object_id": { + "value": "251", + "versions": [ + { + "value": "251", + "source-type": "CRM_UI", + "source-id": "userId:50549138", + "source-label": null, + "updated-by-user-id": 50549138, + "timestamp": 1680448099688, + "selected": false + } + ] + }, + "currentlyinworkflow": { + "value": "true", + "versions": [ + { + "value": "true", + "source-type": "AUTOMATION_PLATFORM", + "source-id": "785646042", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448122489, + "selected": false + } + ] + }, + "hs_analytics_num_event_completions": { + "value": "0", + "versions": [ + { + "value": "0", + "source-type": "ANALYTICS", + "source-id": "ContactAnalyticsDetailsUpdateWorker", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448110678, + "selected": false + } + ] + }, + "hs_analytics_source_data_2": { + "value": "userId:50549138", + "versions": [ + { + "value": "userId:50549138", + "source-type": "ANALYTICS", + "source-id": "ContactAnalyticsDetailsUpdateWorker", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448110678, + "selected": false + } + ] + }, + "hs_social_twitter_clicks": { + "value": "0", + "versions": [ + { + "value": "0", + "source-type": "ANALYTICS", + "source-id": "ContactAnalyticsDetailsUpdateWorker", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448110678, + "selected": false + } + ] + }, + "hs_analytics_source_data_1": { + "value": "CRM_UI", + "versions": [ + { + "value": "CRM_UI", + "source-type": "ANALYTICS", + "source-id": "ContactAnalyticsDetailsUpdateWorker", + "source-label": null, + "updated-by-user-id": null, + "timestamp": 1680448110678, + "selected": false + } + ] + }, + "hs_lifecyclestage_lead_date": { + "value": "1680448099688", + "versions": [ + { + "value": "1680448099688", + "source-type": "CRM_UI", + "source-id": "userId:50549138", + "source-label": null, + "updated-by-user-id": 50549138, + "timestamp": 1680448099688, + "selected": false + } + ] + }, + "lifecyclestage": { + "value": "lead", + "versions": [ + { + "value": "lead", + "source-type": "CRM_UI", + "source-id": "userId:50549138", + "source-label": null, + "updated-by-user-id": 50549138, + "timestamp": 1680448099688, + "selected": false + } + ] + }, + "hs_count_is_unworked": { + "value": "1", + "versions": [ + { + "value": "1", + "source-type": "CALCULATED", + "source-id": "CalculatedPropertyComputer", + "source-label": null, + "updated-by-user-id": 50549138, + "timestamp": 1680448099688, + "selected": false + } + ] + }, + "hs_updated_by_user_id": { + "value": "50549138", + "versions": [ + { + "value": "50549138", + "source-type": "CRM_UI", + "source-id": "userId:50549138", + "source-label": null, + "updated-by-user-id": 50549138, + "timestamp": 1680448099688, + "selected": false + } + ] + } + }, + "form-submissions": [], + "list-memberships": [ + { + "static-list-id": 21, + "internal-list-id": 2147483643, + "timestamp": 1680448122038, + "vid": 251, + "is-member": true + } + ], + "identity-profiles": [ + { + "vid": 251, + "is-deleted": false, + "is-contact": false, + "pointer-vid": 0, + "previous-vid": 0, + "linked-vids": [], + "saved-at-timestamp": 0, + "deleted-changed-timestamp": 0, + "identities": [ + { + "type": "EMAIL", + "value": "kovatif642@cyclesat.com", + "timestamp": 1680448099688, + "is-primary": true, + "source": "UNSPECIFIED" + }, + { + "type": "LEAD_GUID", + "value": "06c11203-d92d-42d3-a81c-d2a75030b4be", + "timestamp": 1680448099774, + "source": "UNSPECIFIED" + } + ] + } + ], + "merge-audits": [], + "associated-company": { + "company-id": 7298823368, + "portal-id": 27145807, + "properties": { + "num_associated_contacts": { + "value": "1" + }, + "hs_pipeline": { + "value": "companies-lifecycle-pipeline" + }, + "hs_analytics_latest_source_data_2": { + "value": "userId:50549138" + }, + "createdate": { + "value": "1680448100221" + }, + "hs_num_blockers": { + "value": "0" + }, + "hs_analytics_latest_source_data_1": { + "value": "CRM_UI" + }, + "hs_analytics_num_visits": { + "value": "0" + }, + "hs_analytics_latest_source_timestamp": { + "value": "1680448099780" + }, + "hs_analytics_source": { + "value": "OFFLINE" + }, + "hs_analytics_num_page_views": { + "value": "0" + }, + "hs_num_open_deals": { + "value": "0" + }, + "website": { + "value": "cyclesat.com" + }, + "hs_analytics_first_timestamp": { + "value": "1680448099688" + }, + "first_contact_createdate": { + "value": "1680448099688" + }, + "hs_target_account_probability": { + "value": "0.38625118136405945" + }, + "hs_lastmodifieddate": { + "value": "1680448113387" + }, + "hs_num_decision_makers": { + "value": "0" + }, + "num_conversion_events": { + "value": "0" + }, + "domain": { + "value": "cyclesat.com" + }, + "name": { + "value": "Cyclesat" + }, + "hs_object_id": { + "value": "7298823368" + }, + "hs_num_contacts_with_buying_roles": { + "value": "0" + }, + "hs_num_child_companies": { + "value": "0" + }, + "hs_analytics_source_data_2": { + "value": "userId:50549138" + }, + "hs_analytics_source_data_1": { + "value": "CRM_UI" + }, + "hs_analytics_latest_source": { + "value": "OFFLINE" + }, + "lifecyclestage": { + "value": "lead" + } + } + }, + "associated-owner": { + "first-name": "Hookdeck", + "last-name": "Write", + "email": "hookdeckwrite@gmail.com", + "type": "PERSON", + "hubspot-user-id": 50549138 + } + }, + "extraHeaders": { + "x-hubspot-client-ip": "172.21.85.188", + "x-hubspot-correlation-id": "a14b39fd-3844-4247-9854-4fe205102f28", + "x-hubspot-request-timestamp": "1680448123263", + "x-hubspot-requesting-chain-bin": "CkwKQEF1dG9tYXRpb25QbGF0Zm9ybUV4ZWN1dGlvbi1leGVjdXRpb25TbG93QWN0aW9uVHlwZUxhbmVXYWxXb3JrZXISCFBST0NGSUxFCjEKJUF1dG9tYXRpb25BY3Rpb25zRXhlY3V0aW9uU2VydmljZS13ZWISCFBST0NGSUxFEgIIAg==", + "x-hubspot-timeout-millis": "44958", + "x-trace": "2B9340EAC52DB4308D3685ABA9D233DCB33D984B1C000000000000000000" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:hubspot/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "hubspot", + "providerLabel": "HubSpot", + "eventType": "DEAL", + "name": "DEAL", + "body": { + "portalId": 27145807, + "objectType": "DEAL", + "objectTypeId": "0-3", + "objectId": 7036839888, + "properties": { + "hs_closed_amount_in_home_currency": { + "versions": [ + { + "name": "hs_closed_amount_in_home_currency", + "value": "0", + "timestamp": 1680442574451, + "sourceId": "CalculatedPropertyComputer", + "source": "CALCULATED", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "0", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CALCULATED", + "sourceId": "CalculatedPropertyComputer", + "updatedByUserId": 50549138 + }, + "dealname": { + "versions": [ + { + "name": "dealname", + "value": "High Deal", + "timestamp": 1680442574451, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "High Deal", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "hs_num_associated_deal_registrations": { + "versions": [ + { + "name": "hs_num_associated_deal_registrations", + "value": "0", + "timestamp": 1680442574707, + "sourceId": "RollupProperties", + "source": "CALCULATED", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "0", + "timestamp": 1680442574707, + "persistenceTimestamp": 1680442574707, + "source": "CALCULATED", + "sourceId": "RollupProperties", + "updatedByUserId": null + }, + "num_associated_contacts": { + "versions": [ + { + "name": "num_associated_contacts", + "value": "0", + "timestamp": 1680442574707, + "sourceId": "RollupProperties", + "source": "CALCULATED", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "0", + "timestamp": 1680442574707, + "persistenceTimestamp": 1680442574707, + "source": "CALCULATED", + "sourceId": "RollupProperties", + "updatedByUserId": null + }, + "hs_forecast_amount": { + "versions": [ + { + "name": "hs_forecast_amount", + "value": "50000000", + "timestamp": 1680442574451, + "sourceId": "CalculatedPropertyComputer", + "source": "CALCULATED", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "50000000", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CALCULATED", + "sourceId": "CalculatedPropertyComputer", + "updatedByUserId": 50549138 + }, + "createdate": { + "versions": [ + { + "name": "createdate", + "value": "1680442574451", + "timestamp": 1680442574451, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "1680442574451", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "amount_in_home_currency": { + "versions": [ + { + "name": "amount_in_home_currency", + "value": "50000000", + "timestamp": 1680442574451, + "sourceId": "CalculatedPropertyComputer", + "source": "CALCULATED", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "50000000", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CALCULATED", + "sourceId": "CalculatedPropertyComputer", + "updatedByUserId": 50549138 + }, + "hs_is_closed": { + "versions": [ + { + "name": "hs_is_closed", + "value": "false", + "timestamp": 1680442574451, + "sourceId": "CalculatedPropertyComputer", + "source": "CALCULATED", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "false", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CALCULATED", + "sourceId": "CalculatedPropertyComputer", + "updatedByUserId": 50549138 + }, + "days_to_close": { + "versions": [ + { + "name": "days_to_close", + "value": "27", + "timestamp": 1680442574451, + "sourceId": "CalculatedPropertyComputer", + "source": "CALCULATED", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "27", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CALCULATED", + "sourceId": "CalculatedPropertyComputer", + "updatedByUserId": 50549138 + }, + "hs_deal_stage_probability": { + "versions": [ + { + "name": "hs_deal_stage_probability", + "value": "0.200000000000000011102230246251565404236316680908203125", + "timestamp": 1680442574957, + "sourceId": "DealStageProbabilityHandler", + "source": "CALCULATED", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "0.200000000000000011102230246251565404236316680908203125", + "timestamp": 1680442574957, + "persistenceTimestamp": 1680442574957, + "source": "CALCULATED", + "sourceId": "DealStageProbabilityHandler", + "updatedByUserId": null + }, + "hs_deal_stage_probability_shadow": { + "versions": [ + { + "name": "hs_deal_stage_probability_shadow", + "value": "0.200000000000000011102230246251565404236316680908203125", + "timestamp": 1680442574451, + "sourceId": "CalculatedPropertyComputer", + "source": "CALCULATED", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "0.200000000000000011102230246251565404236316680908203125", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CALCULATED", + "sourceId": "CalculatedPropertyComputer", + "updatedByUserId": 50549138 + }, + "hs_closed_amount": { + "versions": [ + { + "name": "hs_closed_amount", + "value": "0", + "timestamp": 1680442574451, + "sourceId": "CalculatedPropertyComputer", + "source": "CALCULATED", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "0", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CALCULATED", + "sourceId": "CalculatedPropertyComputer", + "updatedByUserId": 50549138 + }, + "hubspot_owner_id": { + "versions": [ + { + "name": "hubspot_owner_id", + "value": "823493108", + "timestamp": 1680442574451, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "823493108", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "hs_created_by_user_id": { + "versions": [ + { + "name": "hs_created_by_user_id", + "value": "50549138", + "timestamp": 1680442574451, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "50549138", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "hs_createdate": { + "versions": [ + { + "name": "hs_createdate", + "value": "1680442574451", + "timestamp": 1680442574451, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "1680442574451", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "hs_is_deal_split": { + "versions": [ + { + "name": "hs_is_deal_split", + "value": "false", + "timestamp": 1680442574451, + "sourceId": "CalculatedPropertyComputer", + "source": "CALCULATED", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "false", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CALCULATED", + "sourceId": "CalculatedPropertyComputer", + "updatedByUserId": 50549138 + }, + "hs_projected_amount": { + "versions": [ + { + "name": "hs_projected_amount", + "value": "10000000.000000000555111512312578270211815834045410156250000000", + "timestamp": 1680442574957, + "sourceId": "CalculatedPropertyComputer", + "source": "CALCULATED", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "10000000.000000000555111512312578270211815834045410156250000000", + "timestamp": 1680442574957, + "persistenceTimestamp": 1680442574957, + "source": "CALCULATED", + "sourceId": "CalculatedPropertyComputer", + "updatedByUserId": null + }, + "hs_num_target_accounts": { + "versions": [ + { + "name": "hs_num_target_accounts", + "value": "0", + "timestamp": 1680442574707, + "sourceId": "RollupProperties", + "source": "CALCULATED", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "0", + "timestamp": 1680442574707, + "persistenceTimestamp": 1680442574707, + "source": "CALCULATED", + "sourceId": "RollupProperties", + "updatedByUserId": null + }, + "hs_all_owner_ids": { + "versions": [ + { + "name": "hs_all_owner_ids", + "value": "823493108", + "timestamp": 1680442574451, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "823493108", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "hs_projected_amount_in_home_currency": { + "versions": [ + { + "name": "hs_projected_amount_in_home_currency", + "value": "10000000.000000000555111512312578270211815834045410156250000000", + "timestamp": 1680442574957, + "sourceId": "CalculatedPropertyComputer", + "source": "CALCULATED", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "10000000.000000000555111512312578270211815834045410156250000000", + "timestamp": 1680442574957, + "persistenceTimestamp": 1680442574957, + "source": "CALCULATED", + "sourceId": "CalculatedPropertyComputer", + "updatedByUserId": null + }, + "amount": { + "versions": [ + { + "name": "amount", + "value": "50000000", + "timestamp": 1680442574451, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "50000000", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "hs_is_closed_won": { + "versions": [ + { + "name": "hs_is_closed_won", + "value": "false", + "timestamp": 1680442574451, + "sourceId": "CalculatedPropertyComputer", + "source": "CALCULATED", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "false", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CALCULATED", + "sourceId": "CalculatedPropertyComputer", + "updatedByUserId": 50549138 + }, + "closedate": { + "versions": [ + { + "name": "closedate", + "value": "1682861749726", + "timestamp": 1680442574451, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "1682861749726", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "hs_num_associated_active_deal_registrations": { + "versions": [ + { + "name": "hs_num_associated_active_deal_registrations", + "value": "0", + "timestamp": 1680442574707, + "sourceId": "RollupProperties", + "source": "CALCULATED", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "0", + "timestamp": 1680442574707, + "persistenceTimestamp": 1680442574707, + "source": "CALCULATED", + "sourceId": "RollupProperties", + "updatedByUserId": null + }, + "hs_user_ids_of_all_owners": { + "versions": [ + { + "name": "hs_user_ids_of_all_owners", + "value": "50549138", + "timestamp": 1680442574451, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "50549138", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "pipeline": { + "versions": [ + { + "name": "pipeline", + "value": "default", + "timestamp": 1680442574451, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "default", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "hs_lastmodifieddate": { + "versions": [ + { + "name": "hs_lastmodifieddate", + "value": "1680442574957", + "timestamp": 1680442574957, + "sourceId": "DealStageProbabilityHandler", + "source": "CALCULATED", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "1680442574957", + "timestamp": 1680442574957, + "persistenceTimestamp": 1680442574957, + "source": "CALCULATED", + "sourceId": "DealStageProbabilityHandler", + "updatedByUserId": null + }, + "hubspot_owner_assigneddate": { + "versions": [ + { + "name": "hubspot_owner_assigneddate", + "value": "1680442574451", + "timestamp": 1680442574451, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "1680442574451", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "dealstage": { + "versions": [ + { + "name": "dealstage", + "value": "appointmentscheduled", + "timestamp": 1680442574451, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "appointmentscheduled", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "hs_num_associated_deal_splits": { + "versions": [ + { + "name": "hs_num_associated_deal_splits", + "value": "0", + "timestamp": 1680442574707, + "sourceId": "RollupProperties", + "source": "CALCULATED", + "sourceVid": [], + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "0", + "timestamp": 1680442574707, + "persistenceTimestamp": 1680442574707, + "source": "CALCULATED", + "sourceId": "RollupProperties", + "updatedByUserId": null + }, + "hs_object_id": { + "versions": [ + { + "name": "hs_object_id", + "value": "7036839888", + "timestamp": 1680442574451, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "7036839888", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + }, + "hs_updated_by_user_id": { + "versions": [ + { + "name": "hs_updated_by_user_id", + "value": "50549138", + "timestamp": 1680442574451, + "sourceId": "userId:50549138", + "source": "CRM_UI", + "sourceVid": [], + "requestId": "251ce58e-09b3-4b28-bbdd-6e8165d15a5d", + "updatedByUserId": 50549138, + "useTimestampAsPersistenceTimestamp": true + } + ], + "value": "50549138", + "timestamp": 1680442574451, + "persistenceTimestamp": 1680442574451, + "source": "CRM_UI", + "sourceId": "userId:50549138", + "updatedByUserId": 50549138 + } + }, + "version": 3, + "secondaryIdentifier": null, + "isDeleted": false + }, + "extraHeaders": { + "x-hubspot-client-ip": "172.21.156.49", + "x-hubspot-correlation-id": "0baa9845-a48c-415a-94a4-c33d32eb9347", + "x-hubspot-request-timestamp": "1680447533390", + "x-hubspot-requesting-chain-bin": "CkwKQEF1dG9tYXRpb25QbGF0Zm9ybUV4ZWN1dGlvbi1leGVjdXRpb25TbG93QWN0aW9uVHlwZUxhbmVXYWxXb3JrZXISCFBST0NGSUxFCjEKJUF1dG9tYXRpb25BY3Rpb25zRXhlY3V0aW9uU2VydmljZS13ZWISCFBST0NGSUxFEgIIAg==", + "x-hubspot-timeout-millis": "44610", + "x-trace": "2B98E110B8DA0D2F8CCB8D0A57BBE117FE0FEBCB20000000000000000000" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:hubspot/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "monday", + "providerLabel": "Monday", + "eventType": "create_pulse", + "name": "create_pulse", + "body": { + "event": { + "userId": 42973518, + "originalTriggerUuid": null, + "boardId": 4429449918, + "pulseId": 4429514497, + "pulseName": "New Item", + "groupId": "topics", + "groupName": "Group 1", + "groupColor": "#579bfc", + "isTopGroup": true, + "columnValues": {}, + "app": "monday", + "type": "create_pulse", + "triggerTime": "2023-05-07T06:16:58.471Z", + "subscriptionId": 227626570, + "triggerUuid": "1f418681359b1cc8f675efe2fd0ba062" + } + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:monday/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "monday", + "providerLabel": "Monday", + "eventType": "create_update", + "name": "create_update", + "body": { + "event": { + "userId": 42973518, + "originalTriggerUuid": null, + "boardId": 4429449918, + "pulseId": 4429462688, + "body": "

I'll check

", + "textBody": "I'll check", + "updateId": 2141354286, + "replyId": null, + "app": "monday", + "type": "create_update", + "triggerTime": "2023-05-07T06:18:47.778Z", + "subscriptionId": 227626549, + "triggerUuid": "ac4c0750983e5270bdfbb4cbcb65fd45" + } + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:monday/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "monday", + "providerLabel": "Monday", + "eventType": "move_pulse_into_group", + "name": "move_pulse_into_group", + "body": { + "event": { + "userId": 42973518, + "originalTriggerUuid": null, + "boardId": 4429449918, + "pulseId": 4429514497, + "sourceGroupId": "topics", + "destGroupId": "group_title", + "destGroup": { + "id": "group_title", + "title": "Group 2", + "color": "#a25ddc", + "is_top_group": false + }, + "app": "monday", + "type": "move_pulse_into_group", + "triggerTime": "2023-05-07T06:18:25.767Z", + "subscriptionId": 227626684, + "triggerUuid": "e412b412ac2d3cdd5684b4c1b57eb2f6" + } + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:monday/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "monday", + "providerLabel": "Monday", + "eventType": "update_column_value", + "name": "update_column_value", + "body": { + "event": { + "userId": 42973518, + "originalTriggerUuid": null, + "boardId": 4429449918, + "groupId": "topics", + "pulseId": 4429449927, + "pulseName": "Item 1", + "columnId": "status", + "columnType": "color", + "columnTitle": "Status", + "value": { + "label": { + "index": 1, + "text": "Done", + "style": { + "color": "#00c875", + "border": "#00B461", + "var_name": "green-shadow" + }, + "is_done": true + }, + "post_id": null + }, + "previousValue": { + "label": { + "index": 0, + "text": "Working on it", + "style": { + "color": "#fdab3d", + "border": "#E99729", + "var_name": "orange" + }, + "is_done": false + }, + "post_id": null + }, + "changedAt": 1683440337.5137901, + "isTopGroup": true, + "app": "monday", + "type": "update_column_value", + "triggerTime": "2023-05-07T06:18:57.914Z", + "subscriptionId": 227626788, + "triggerUuid": "998d11923305a73436d016ab2787df2e" + } + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:monday/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "ordinal", + "providerLabel": "Ordinal", + "eventType": "campaign.approval.approved", + "name": "campaign.approval.approved", + "body": { + "type": "campaign.approval.approved", + "data": { + "approval": { + "id": "550e8400-e29b-41d4-a716-446655440710", + "isBlocking": true, + "message": "Legal sign-off needed for this campaign.", + "dueDate": "2025-03-05T17:00:00.000Z", + "createdAt": "2025-02-26T14:00:00.000Z", + "status": "Approved", + "approvedAt": "2025-02-26T16:15:00.000Z", + "approvedBy": { + "id": "550e8400-e29b-41d4-a716-446655440021", + "firstName": "Sam", + "lastName": "Chen", + "email": "sam@example.com" + }, + "requestedBy": { + "id": "550e8400-e29b-41d4-a716-446655440010", + "firstName": "Jane", + "lastName": "Doe", + "email": "jane@example.com" + }, + "campaign": { + "id": "550e8400-e29b-41d4-a716-446655440003", + "name": "Launch 2025", + "startDate": "2025-01-01", + "endDate": "2025-12-31" + }, + "workspace": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "slug": "acme", + "name": "Acme Inc" + } + } + }, + "createdAt": "2025-02-26T16:15:00.000Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:ordinal/latest", + "snapshotDate": "2026-07" + } + }, + { + "provider": "ordinal", + "providerLabel": "Ordinal", + "eventType": "campaign.approval.requested", + "name": "campaign.approval.requested", + "body": { + "type": "campaign.approval.requested", + "data": { + "approval": { + "campaign": { + "id": "550e8400-e29b-41d4-a716-446655440003", + "name": "Launch 2025", + "startDate": "2025-01-01", + "endDate": "2025-12-31" + }, + "workspace": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "slug": "acme", + "name": "Acme Inc" + }, + "createdApprovals": [ + { + "id": "550e8400-e29b-41d4-a716-446655440710", + "isBlocking": true, + "message": "Legal sign-off needed for this campaign.", + "dueDate": "2025-03-05T17:00:00.000Z", + "createdAt": "2025-02-26T14:00:00.000Z", + "status": "Requested", + "user": { + "id": "550e8400-e29b-41d4-a716-446655440021", + "firstName": "Sam", + "lastName": "Chen", + "email": "sam@example.com" + }, + "requestedBy": { + "id": "550e8400-e29b-41d4-a716-446655440010", + "firstName": "Jane", + "lastName": "Doe", + "email": "jane@example.com" + } + } + ], + "existingApprovals": [] + } + }, + "createdAt": "2025-02-26T14:00:00.000Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:ordinal/latest", + "snapshotDate": "2026-07" + } + }, + { + "provider": "ordinal", + "providerLabel": "Ordinal", + "eventType": "invite.accepted", + "name": "invite.accepted", + "body": { + "type": "invite.accepted", + "data": { + "invite": { + "id": "550e8400-e29b-41d4-a716-446655440600", + "email": "newmember@example.com", + "createdAt": "2025-02-26T11:00:00.000Z", + "acceptedAt": "2025-02-26T14:30:00.000Z", + "invitedBy": { + "id": "550e8400-e29b-41d4-a716-446655440010", + "firstName": "Jane", + "lastName": "Doe", + "email": "jane@example.com" + }, + "acceptedBy": { + "id": "550e8400-e29b-41d4-a716-446655440020", + "firstName": "John", + "lastName": "Smith", + "email": "newmember@example.com" + }, + "workspace": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "slug": "acme", + "name": "Acme Inc" + } + } + }, + "createdAt": "2025-02-26T14:30:00.000Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:ordinal/latest", + "snapshotDate": "2026-07" + } + }, + { + "provider": "ordinal", + "providerLabel": "Ordinal", + "eventType": "invite.created", + "name": "invite.created", + "body": { + "type": "invite.created", + "data": { + "invite": { + "id": "550e8400-e29b-41d4-a716-446655440600", + "email": "newmember@example.com", + "createdAt": "2025-02-26T11:00:00.000Z", + "invitedBy": { + "id": "550e8400-e29b-41d4-a716-446655440010", + "firstName": "Jane", + "lastName": "Doe", + "email": "jane@example.com" + }, + "workspace": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "slug": "acme", + "name": "Acme Inc" + }, + "sentEmail": true + } + }, + "createdAt": "2025-02-26T11:00:00.000Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:ordinal/latest", + "snapshotDate": "2026-07" + } + }, + { + "provider": "ordinal", + "providerLabel": "Ordinal", + "eventType": "post.approval.approved", + "name": "post.approval.approved", + "body": { + "type": "post.approval.approved", + "data": { + "approval": { + "id": "550e8400-e29b-41d4-a716-446655440700", + "isBlocking": true, + "message": "Please review before we publish.", + "dueDate": "2025-02-28T17:00:00.000Z", + "createdAt": "2025-02-26T14:00:00.000Z", + "status": "Approved", + "approvedAt": "2025-02-26T15:30:00.000Z", + "approvedBy": { + "id": "550e8400-e29b-41d4-a716-446655440020", + "firstName": "Alex", + "lastName": "Rivera", + "email": "alex@example.com" + }, + "requestedBy": { + "id": "550e8400-e29b-41d4-a716-446655440010", + "firstName": "Jane", + "lastName": "Doe", + "email": "jane@example.com" + }, + "post": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "title": "Q4 Product Launch Announcement", + "url": "https://app.tryordinal.com/acme/posts/550e8400-e29b-41d4-a716-446655440001" + }, + "campaign": { + "id": "550e8400-e29b-41d4-a716-446655440003", + "name": "Launch 2025", + "startDate": "2025-01-01", + "endDate": "2025-12-31" + }, + "workspace": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "slug": "acme", + "name": "Acme Inc" + } + } + }, + "createdAt": "2025-02-26T15:30:00.000Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:ordinal/latest", + "snapshotDate": "2026-07" + } + }, + { + "provider": "ordinal", + "providerLabel": "Ordinal", + "eventType": "post.approval.requested", + "name": "post.approval.requested", + "body": { + "type": "post.approval.requested", + "data": { + "approval": { + "post": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "title": "Q4 Product Launch Announcement", + "url": "https://app.tryordinal.com/acme/posts/550e8400-e29b-41d4-a716-446655440001" + }, + "campaign": { + "id": "550e8400-e29b-41d4-a716-446655440003", + "name": "Launch 2025", + "startDate": "2025-01-01", + "endDate": "2025-12-31" + }, + "workspace": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "slug": "acme", + "name": "Acme Inc" + }, + "createdApprovals": [ + { + "id": "550e8400-e29b-41d4-a716-446655440700", + "isBlocking": true, + "message": "Please review before we publish.", + "dueDate": "2025-02-28T17:00:00.000Z", + "createdAt": "2025-02-26T14:00:00.000Z", + "status": "Requested", + "user": { + "id": "550e8400-e29b-41d4-a716-446655440020", + "firstName": "Alex", + "lastName": "Rivera", + "email": "alex@example.com" + }, + "requestedBy": { + "id": "550e8400-e29b-41d4-a716-446655440010", + "firstName": "Jane", + "lastName": "Doe", + "email": "jane@example.com" + } + } + ], + "existingApprovals": [] + } + }, + "createdAt": "2025-02-26T14:00:00.000Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:ordinal/latest", + "snapshotDate": "2026-07" + } + }, + { + "provider": "ordinal", + "providerLabel": "Ordinal", + "eventType": "post.archived", + "name": "post.archived", + "body": { + "type": "post.archived", + "data": { + "post": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "title": "Draft Post", + "status": "archived", + "channels": ["LinkedIn", "Twitter"], + "campaign": null, + "url": "https://app.tryordinal.com/acme/posts/550e8400-e29b-41d4-a716-446655440001", + "labels": [ + { + "id": "550e8400-e29b-41d4-a716-446655440400", + "name": "Marketing", + "color": "#9333ea", + "backgroundColor": "#f3e8ff" + } + ], + "workspace": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "slug": "acme", + "name": "Acme Inc" + }, + "archivedBy": { + "id": "550e8400-e29b-41d4-a716-446655440010", + "firstName": "Jane", + "lastName": "Doe", + "email": "jane@example.com" + }, + "archivedAt": "2025-02-26T15:00:00.000Z", + "linkedIn": { + "profile": { + "id": "550e8400-e29b-41d4-a716-446655440002", + "name": "Acme Inc", + "detail": "acme-inc" + }, + "copy": "Draft post content for LinkedIn", + "assets": [ + { + "id": "550e8400-e29b-41d4-a716-446655440010", + "name": "product-photo.jpg", + "url": "https://cdn.tryordinal.com/assets/product-photo.jpg", + "mimetype": "image/jpeg", + "size": 245678, + "width": 1920, + "height": 1080, + "duration": null + } + ] + }, + "x": { + "profile": { + "id": "550e8400-e29b-41d4-a716-446655440004", + "name": "Acme Inc", + "detail": "@acme_inc" + }, + "tweets": [ + { + "copy": "Draft post content for Twitter", + "assets": [ + { + "id": "550e8400-e29b-41d4-a716-446655440011", + "name": "product-photo.jpg", + "url": "https://cdn.tryordinal.com/assets/product-photo.jpg", + "mimetype": "image/jpeg", + "size": 245678, + "width": 1920, + "height": 1080, + "duration": null + } + ] + } + ] + } + } + }, + "createdAt": "2025-02-26T15:00:00.000Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:ordinal/latest", + "snapshotDate": "2026-07" + } + }, + { + "provider": "ordinal", + "providerLabel": "Ordinal", + "eventType": "post.comment.created", + "name": "post.comment.created", + "body": { + "type": "post.comment.created", + "data": { + "comment": { + "id": "550e8400-e29b-41d4-a716-446655440050", + "message": "Looks good! Let's add a CTA at the end.", + "post": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "title": "Q4 Product Launch Announcement", + "url": "https://app.tryordinal.com/acme/posts/550e8400-e29b-41d4-a716-446655440001" + }, + "workspace": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "slug": "acme", + "name": "Acme Inc" + }, + "createdBy": { + "id": "550e8400-e29b-41d4-a716-446655440010", + "firstName": "Jane", + "lastName": "Doe", + "email": "jane@example.com" + }, + "createdAt": "2025-02-26T16:00:00.000Z" + } + }, + "createdAt": "2025-02-26T16:00:00.000Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:ordinal/latest", + "snapshotDate": "2026-07" + } + }, + { + "provider": "ordinal", + "providerLabel": "Ordinal", + "eventType": "post.content.edited", + "name": "post.content.edited", + "body": { + "type": "post.content.edited", + "data": { + "post": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "title": "Q4 Product Launch Announcement", + "channels": ["LinkedIn", "Twitter"], + "campaign": { + "id": "550e8400-e29b-41d4-a716-446655440003", + "name": "Launch 2025", + "startDate": "2025-01-01", + "endDate": "2025-12-31" + }, + "url": "https://app.tryordinal.com/acme/posts/550e8400-e29b-41d4-a716-446655440001", + "workspace": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "slug": "acme", + "name": "Acme Inc" + }, + "editedBy": { + "id": "550e8400-e29b-41d4-a716-446655440010", + "firstName": "Jane", + "lastName": "Doe", + "email": "jane@example.com" + }, + "editedAt": "2025-02-26T15:30:00.000Z", + "labels": [ + { + "id": "550e8400-e29b-41d4-a716-446655440400", + "name": "Marketing", + "color": "#9333ea", + "backgroundColor": "#f3e8ff" + } + ], + "linkedIn": { + "profile": { + "id": "550e8400-e29b-41d4-a716-446655440002", + "name": "Acme Inc", + "detail": "acme-inc" + }, + "copy": "Updated LinkedIn content for the launch!", + "assets": [ + { + "id": "550e8400-e29b-41d4-a716-446655440010", + "name": "product-photo.jpg", + "url": "https://cdn.tryordinal.com/assets/product-photo.jpg", + "mimetype": "image/jpeg", + "size": 245678, + "width": 1920, + "height": 1080, + "duration": null + } + ] + }, + "x": { + "profile": { + "id": "550e8400-e29b-41d4-a716-446655440004", + "name": "Acme Inc", + "detail": "@acme_inc" + }, + "tweets": [ + { + "copy": "Updated Twitter content for the launch!", + "assets": [] + } + ] + } + } + }, + "createdAt": "2025-02-26T15:30:00.000Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:ordinal/latest", + "snapshotDate": "2026-07" + } + }, + { + "provider": "ordinal", + "providerLabel": "Ordinal", + "eventType": "post.created", + "name": "post.created", + "body": { + "type": "post.created", + "data": { + "post": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "title": "New Draft", + "status": "draft", + "channels": ["LinkedIn"], + "campaign": null, + "url": "https://app.tryordinal.com/acme/posts/550e8400-e29b-41d4-a716-446655440001", + "labels": [ + { + "id": "550e8400-e29b-41d4-a716-446655440400", + "name": "Marketing", + "color": "#9333ea", + "backgroundColor": "#f3e8ff" + } + ], + "workspace": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "slug": "acme", + "name": "Acme Inc" + }, + "createdBy": { + "id": "550e8400-e29b-41d4-a716-446655440010", + "firstName": "Jane", + "lastName": "Doe", + "email": "jane@example.com" + }, + "createdAt": "2025-02-26T12:00:00.000Z", + "linkedIn": { + "profile": { + "id": "550e8400-e29b-41d4-a716-446655440002", + "name": "Acme Inc", + "detail": "acme-inc" + }, + "copy": "Excited to share our latest product update!", + "assets": [ + { + "id": "550e8400-e29b-41d4-a716-446655440010", + "name": "product-photo.jpg", + "url": "https://cdn.tryordinal.com/assets/product-photo.jpg", + "mimetype": "image/jpeg", + "size": 245678, + "width": 1920, + "height": 1080, + "duration": null + } + ] + }, + "x": null + } + }, + "createdAt": "2025-02-26T12:00:00.000Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:ordinal/latest", + "snapshotDate": "2026-07" + } + }, + { + "provider": "ordinal", + "providerLabel": "Ordinal", + "eventType": "post.inline_comment.created", + "name": "post.inline_comment.created", + "body": { + "type": "post.inline_comment.created", + "data": { + "comment": { + "thread": { + "id": "550e8400-e29b-41d4-a716-446655440060", + "channel": "LinkedIn", + "highlightedText": "coming next week", + "resolved": false, + "createdAt": "2025-02-26T16:05:00.000Z", + "updatedAt": null + }, + "id": "550e8400-e29b-41d4-a716-446655440051", + "message": "Can we change this to 'launching soon'?", + "post": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "title": "Q4 Product Launch Announcement", + "url": "https://app.tryordinal.com/acme/posts/550e8400-e29b-41d4-a716-446655440001" + }, + "workspace": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "slug": "acme", + "name": "Acme Inc" + }, + "createdBy": { + "id": "550e8400-e29b-41d4-a716-446655440010", + "firstName": "Jane", + "lastName": "Doe", + "email": "jane@example.com" + }, + "createdAt": "2025-02-26T16:05:00.000Z" + } + }, + "createdAt": "2025-02-26T16:05:00.000Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:ordinal/latest", + "snapshotDate": "2026-07" + } + }, + { + "provider": "ordinal", + "providerLabel": "Ordinal", + "eventType": "post.permanently_deleted", + "name": "post.permanently_deleted", + "body": { + "type": "post.permanently_deleted", + "data": { + "post": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "title": "Old Draft", + "status": "archived", + "channels": ["LinkedIn"], + "campaign": null, + "url": "https://app.tryordinal.com/acme/posts/550e8400-e29b-41d4-a716-446655440001", + "labels": [ + { + "id": "550e8400-e29b-41d4-a716-446655440400", + "name": "Marketing", + "color": "#9333ea", + "backgroundColor": "#f3e8ff" + } + ], + "workspace": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "slug": "acme", + "name": "Acme Inc" + }, + "deletedBy": { + "id": "550e8400-e29b-41d4-a716-446655440010", + "firstName": "Jane", + "lastName": "Doe", + "email": "jane@example.com" + }, + "deletedAt": "2025-03-15T10:00:00.000Z", + "linkedIn": { + "profile": { + "id": "550e8400-e29b-41d4-a716-446655440002", + "name": "Acme Inc", + "detail": "acme-inc" + }, + "copy": "Old draft content", + "assets": [ + { + "id": "550e8400-e29b-41d4-a716-446655440010", + "name": "product-photo.jpg", + "url": "https://cdn.tryordinal.com/assets/product-photo.jpg", + "mimetype": "image/jpeg", + "size": 245678, + "width": 1920, + "height": 1080, + "duration": null + } + ] + }, + "x": null + } + }, + "createdAt": "2025-03-15T10:00:00.000Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:ordinal/latest", + "snapshotDate": "2026-07" + } + }, + { + "provider": "ordinal", + "providerLabel": "Ordinal", + "eventType": "post.publish_failed", + "name": "post.publish_failed", + "body": { + "type": "post.publish_failed", + "data": { + "post": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "title": "Q4 Product Launch Announcement", + "channel": "LinkedIn", + "error": "Token expired", + "campaign": null, + "url": "https://app.tryordinal.com/acme/posts/550e8400-e29b-41d4-a716-446655440001", + "profile": { + "id": "550e8400-e29b-41d4-a716-446655440002", + "name": "Acme Inc", + "detail": "acme-inc" + }, + "scheduledPublishAt": "2025-03-01T14:00:00.000Z", + "workspace": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "slug": "acme", + "name": "Acme Inc" + }, + "createdBy": { + "id": "550e8400-e29b-41d4-a716-446655440010", + "firstName": "Jane", + "lastName": "Doe", + "email": "jane@example.com" + }, + "failedAt": "2025-03-01T14:00:05.000Z" + } + }, + "createdAt": "2025-03-01T14:00:05.000Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:ordinal/latest", + "snapshotDate": "2026-07" + } + }, + { + "provider": "ordinal", + "providerLabel": "Ordinal", + "eventType": "post.published", + "name": "post.published", + "body": { + "type": "post.published", + "data": { + "post": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "title": "Q4 Product Launch Announcement", + "channel": "LinkedIn", + "campaign": { + "id": "550e8400-e29b-41d4-a716-446655440003", + "name": "Launch 2025", + "startDate": "2025-01-01", + "endDate": "2025-12-31" + }, + "url": "https://app.tryordinal.com/acme/posts/550e8400-e29b-41d4-a716-446655440001", + "postUrl": "https://www.linkedin.com/feed/update/urn:li:share:7123456789012345678", + "profile": { + "id": "550e8400-e29b-41d4-a716-446655440002", + "name": "Acme Inc", + "detail": "acme-inc" + }, + "workspace": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "slug": "acme", + "name": "Acme Inc" + }, + "publishedBy": { + "id": "550e8400-e29b-41d4-a716-446655440010", + "firstName": "Jane", + "lastName": "Doe", + "email": "jane@example.com" + }, + "publishedAt": "2025-02-26T14:30:00.000Z" + } + }, + "createdAt": "2025-02-26T14:30:00.000Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:ordinal/latest", + "snapshotDate": "2026-07" + } + }, + { + "provider": "ordinal", + "providerLabel": "Ordinal", + "eventType": "post.rescheduled", + "name": "post.rescheduled", + "body": { + "type": "post.rescheduled", + "data": { + "post": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "title": "Q4 Product Launch Announcement", + "channels": ["LinkedIn"], + "campaign": null, + "url": "https://app.tryordinal.com/acme/posts/550e8400-e29b-41d4-a716-446655440001", + "labels": [ + { + "id": "550e8400-e29b-41d4-a716-446655440400", + "name": "Marketing", + "color": "#9333ea", + "backgroundColor": "#f3e8ff" + } + ], + "previousPublishAt": "2025-03-01T14:00:00.000Z", + "newPublishAt": "2025-03-01T16:00:00.000Z", + "workspace": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "slug": "acme", + "name": "Acme Inc" + }, + "rescheduledBy": { + "id": "550e8400-e29b-41d4-a716-446655440010", + "firstName": "Jane", + "lastName": "Doe", + "email": "jane@example.com" + }, + "rescheduledAt": "2025-02-26T13:00:00.000Z", + "linkedIn": { + "profile": { + "id": "550e8400-e29b-41d4-a716-446655440002", + "name": "Acme Inc", + "detail": "acme-inc" + }, + "copy": "Excited to announce our new product launch!", + "assets": [ + { + "id": "550e8400-e29b-41d4-a716-446655440010", + "name": "product-photo.jpg", + "url": "https://cdn.tryordinal.com/assets/product-photo.jpg", + "mimetype": "image/jpeg", + "size": 245678, + "width": 1920, + "height": 1080, + "duration": null + } + ] + }, + "x": null + } + }, + "createdAt": "2025-02-26T13:00:00.000Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:ordinal/latest", + "snapshotDate": "2026-07" + } + }, + { + "provider": "ordinal", + "providerLabel": "Ordinal", + "eventType": "post.scheduled", + "name": "post.scheduled", + "body": { + "type": "post.scheduled", + "data": { + "post": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "title": "Q4 Product Launch Announcement", + "channels": ["LinkedIn", "Twitter"], + "campaign": { + "id": "550e8400-e29b-41d4-a716-446655440003", + "name": "Launch 2025", + "startDate": "2025-01-01", + "endDate": "2025-12-31" + }, + "url": "https://app.tryordinal.com/acme/posts/550e8400-e29b-41d4-a716-446655440001", + "labels": [ + { + "id": "550e8400-e29b-41d4-a716-446655440400", + "name": "Marketing", + "color": "#9333ea", + "backgroundColor": "#f3e8ff" + } + ], + "publishAt": "2025-03-01T14:00:00.000Z", + "postedImmediately": false, + "workspace": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "slug": "acme", + "name": "Acme Inc" + }, + "scheduledBy": { + "id": "550e8400-e29b-41d4-a716-446655440010", + "firstName": "Jane", + "lastName": "Doe", + "email": "jane@example.com" + }, + "scheduledAt": "2025-02-26T12:00:00.000Z", + "linkedIn": { + "profile": { + "id": "550e8400-e29b-41d4-a716-446655440002", + "name": "Acme Inc", + "detail": "acme-inc" + }, + "copy": "Excited to announce our new product launch!", + "assets": [ + { + "id": "550e8400-e29b-41d4-a716-446655440010", + "name": "product-photo.jpg", + "url": "https://cdn.tryordinal.com/assets/product-photo.jpg", + "mimetype": "image/jpeg", + "size": 245678, + "width": 1920, + "height": 1080, + "duration": null + } + ] + }, + "x": { + "profile": { + "id": "550e8400-e29b-41d4-a716-446655440004", + "name": "Acme Inc", + "detail": "@acme_inc" + }, + "tweets": [ + { + "copy": "Excited to announce our new product launch!", + "assets": [ + { + "id": "550e8400-e29b-41d4-a716-446655440011", + "name": "product-photo.jpg", + "url": "https://cdn.tryordinal.com/assets/product-photo.jpg", + "mimetype": "image/jpeg", + "size": 245678, + "width": 1920, + "height": 1080, + "duration": null + } + ] + } + ] + } + } + }, + "createdAt": "2025-02-26T12:00:00.000Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:ordinal/latest", + "snapshotDate": "2026-07" + } + }, + { + "provider": "ordinal", + "providerLabel": "Ordinal", + "eventType": "post.unscheduled", + "name": "post.unscheduled", + "body": { + "type": "post.unscheduled", + "data": { + "post": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "title": "Q4 Product Launch Announcement", + "channels": ["LinkedIn"], + "campaign": null, + "url": "https://app.tryordinal.com/acme/posts/550e8400-e29b-41d4-a716-446655440001", + "labels": [ + { + "id": "550e8400-e29b-41d4-a716-446655440400", + "name": "Marketing", + "color": "#9333ea", + "backgroundColor": "#f3e8ff" + } + ], + "workspace": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "slug": "acme", + "name": "Acme Inc" + }, + "unscheduledBy": { + "id": "550e8400-e29b-41d4-a716-446655440010", + "firstName": "Jane", + "lastName": "Doe", + "email": "jane@example.com" + }, + "unscheduledAt": "2025-02-26T14:00:00.000Z", + "linkedIn": { + "profile": { + "id": "550e8400-e29b-41d4-a716-446655440002", + "name": "Acme Inc", + "detail": "acme-inc" + }, + "copy": "Excited to announce our new product launch!", + "assets": [ + { + "id": "550e8400-e29b-41d4-a716-446655440010", + "name": "product-photo.jpg", + "url": "https://cdn.tryordinal.com/assets/product-photo.jpg", + "mimetype": "image/jpeg", + "size": 245678, + "width": 1920, + "height": 1080, + "duration": null + } + ] + }, + "x": null + } + }, + "createdAt": "2025-02-26T14:00:00.000Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:ordinal/latest", + "snapshotDate": "2026-07" + } + }, + { + "provider": "ordinal", + "providerLabel": "Ordinal", + "eventType": "social_profile.connected", + "name": "social_profile.connected", + "body": { + "type": "social_profile.connected", + "data": { + "profile": { + "id": "550e8400-e29b-41d4-a716-446655440002", + "name": "Acme Inc", + "detail": "acme-inc", + "channel": "LinkedIn", + "profileImageUrl": "https://cdn.tryordinal.com/profiles/acme.png", + "workspace": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "slug": "acme", + "name": "Acme Inc" + }, + "connectedBy": { + "id": "550e8400-e29b-41d4-a716-446655440010", + "firstName": "Jane", + "lastName": "Doe", + "email": "jane@example.com" + }, + "connectedAt": "2025-02-26T10:00:00.000Z" + } + }, + "createdAt": "2025-02-26T10:00:00.000Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:ordinal/latest", + "snapshotDate": "2026-07" + } + }, + { + "provider": "ordinal", + "providerLabel": "Ordinal", + "eventType": "social_profile.disconnected", + "name": "social_profile.disconnected", + "body": { + "type": "social_profile.disconnected", + "data": { + "profile": { + "id": "550e8400-e29b-41d4-a716-446655440002", + "name": "Acme Inc", + "detail": "acme-inc", + "channel": "LinkedIn", + "profileImageUrl": null, + "workspace": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "slug": "acme", + "name": "Acme Inc" + }, + "disconnectedBy": { + "id": "550e8400-e29b-41d4-a716-446655440010", + "firstName": "Jane", + "lastName": "Doe", + "email": "jane@example.com" + }, + "disconnectedAt": "2025-02-26T11:00:00.000Z", + "unscheduledPosts": [ + { + "id": "550e8400-e29b-41d4-a716-446655440001", + "title": "Q4 Product Launch Announcement", + "url": "https://app.tryordinal.com/acme/posts/550e8400-e29b-41d4-a716-446655440001" + } + ] + } + }, + "createdAt": "2025-02-26T11:00:00.000Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:ordinal/latest", + "snapshotDate": "2026-07" + } + }, + { + "provider": "ordinal", + "providerLabel": "Ordinal", + "eventType": "social_profile.reconnect_needed", + "name": "social_profile.reconnect_needed", + "body": { + "type": "social_profile.reconnect_needed", + "data": { + "profile": { + "id": "550e8400-e29b-41d4-a716-446655440002", + "name": "Acme Inc", + "detail": "acme-inc", + "channel": "LinkedIn", + "profileImageUrl": "https://cdn.tryordinal.com/profiles/acme.png", + "workspace": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "slug": "acme", + "name": "Acme Inc" + }, + "detectedAt": "2025-02-26T09:00:00.000Z" + } + }, + "createdAt": "2025-02-26T09:00:00.000Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:ordinal/latest", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddlebilling", + "providerLabel": "Paddle Billing", + "eventType": "address.created", + "name": "address.created", + "body": { + "data": { + "id": "add_01j7br89t3wgn5v5w49hm1mepv", + "city": null, + "region": null, + "status": "active", + "created_at": "2024-09-09T16:00:47.939Z", + "first_line": null, + "updated_at": "2024-09-09T16:00:47.939Z", + "custom_data": null, + "customer_id": "ctm_01j7br89shp7xpaenx22h8qetw", + "description": null, + "import_meta": null, + "postal_code": "10021", + "second_line": null, + "country_code": "US" + }, + "event_id": "evt_01j7br8a9wah2vavmp3pe9k6r1", + "event_type": "address.created", + "occurred_at": "2024-09-09T16:00:48.444751Z", + "notification_id": "ntf_01j7br8ap7fjjm2424q81qt5zc" + }, + "extraHeaders": { + "paddle-version": "1" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddlebilling/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddlebilling", + "providerLabel": "Paddle Billing", + "eventType": "adjustment.created", + "name": "adjustment.created", + "body": { + "data": { + "id": "adj_01j7brvp4836axtqpwvpgdssh7", + "items": [ + { + "id": "adjitm_01j7brvp4836axtqpwvqa6z30p", + "type": "full", + "amount": "10887", + "totals": { + "tax": "887", + "total": "10887", + "subtotal": "10000" + }, + "item_id": "txnitm_01j7br8aez018mgmrj3bpmxmh7", + "proration": null + } + ], + "action": "refund", + "reason": "error", + "status": "pending_approval", + "totals": { + "fee": "553", + "tax": "887", + "total": "10887", + "earnings": "9447", + "subtotal": "10000", + "currency_code": "USD" + }, + "created_at": "2024-09-09T16:11:23.156526Z", + "updated_at": "2024-09-09T16:11:23Z", + "customer_id": "ctm_01j7br89shp7xpaenx22h8qetw", + "currency_code": "USD", + "payout_totals": { + "fee": "553", + "tax": "887", + "total": "10887", + "earnings": "9447", + "subtotal": "10000", + "currency_code": "USD" + }, + "transaction_id": "txn_01j7br78cgr083zqw1jhqym40s", + "subscription_id": "sub_01j7br9jpk4rmmcd5crn2th1bq", + "credit_applied_to_balance": null + }, + "event_id": "evt_01j7brvpf76ndzrenetzbk8m0x", + "event_type": "adjustment.created", + "occurred_at": "2024-09-09T16:11:23.495326Z", + "notification_id": "ntf_01j7brvpnh3dbqmpnz2skccsq0" + }, + "extraHeaders": { + "paddle-version": "1" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddlebilling/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddlebilling", + "providerLabel": "Paddle Billing", + "eventType": "adjustment.updated", + "name": "adjustment.updated", + "body": { + "data": { + "id": "adj_01j7brvp4836axtqpwvpgdssh7", + "items": [ + { + "id": "adjitm_01j7brvp4836axtqpwvqa6z30p", + "type": "full", + "amount": "10887", + "totals": { + "tax": "887", + "total": "10887", + "subtotal": "10000" + }, + "item_id": "txnitm_01j7br8aez018mgmrj3bpmxmh7", + "proration": null + } + ], + "action": "refund", + "reason": "error", + "status": "approved", + "totals": { + "fee": "553", + "tax": "887", + "total": "10887", + "earnings": "9447", + "subtotal": "10000", + "currency_code": "USD" + }, + "created_at": "2024-09-09T16:11:23.156526Z", + "updated_at": "2024-09-09T16:20:01.336183Z", + "customer_id": "ctm_01j7br89shp7xpaenx22h8qetw", + "currency_code": "USD", + "payout_totals": { + "fee": "553", + "tax": "887", + "total": "10887", + "earnings": "9447", + "subtotal": "10000", + "currency_code": "USD" + }, + "transaction_id": "txn_01j7br78cgr083zqw1jhqym40s", + "subscription_id": "sub_01j7br9jpk4rmmcd5crn2th1bq", + "credit_applied_to_balance": null + }, + "event_id": "evt_01j7bsbgpvc60cc727n9k76xcd", + "event_type": "adjustment.updated", + "occurred_at": "2024-09-09T16:20:01.883390Z", + "notification_id": "ntf_01j7bsbh2d217449az4tf0k7m6" + }, + "extraHeaders": { + "paddle-version": "1" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddlebilling/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddlebilling", + "providerLabel": "Paddle Billing", + "eventType": "customer.created", + "name": "customer.created", + "body": { + "data": { + "id": "ctm_01j7br89shp7xpaenx22h8qetw", + "name": null, + "email": "sam@example.com", + "locale": "en", + "status": "active", + "created_at": "2024-09-09T16:00:47.921Z", + "updated_at": "2024-09-09T16:00:47.921Z", + "custom_data": null, + "import_meta": null, + "marketing_consent": false + }, + "event_id": "evt_01j7br8aax25dzcc8xe1xq32d0", + "event_type": "customer.created", + "occurred_at": "2024-09-09T16:00:48.477852Z", + "notification_id": "ntf_01j7br8ancx88m6hp38d3ecvab" + }, + "extraHeaders": { + "paddle-version": "1" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddlebilling/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddlebilling", + "providerLabel": "Paddle Billing", + "eventType": "subscription.activated", + "name": "subscription.activated", + "body": { + "data": { + "id": "sub_01j7br9jpk4rmmcd5crn2th1bq", + "items": [ + { + "price": { + "id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "name": "Monthly (per seat)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 999, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-02-23T13:55:22.538367Z", + "product_id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "unit_price": { + "amount": "3000", + "currency_code": "USD" + }, + "updated_at": "2024-04-11T13:54:52.254748Z", + "custom_data": null, + "description": "Monthly", + "import_meta": null, + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "status": "active", + "product": { + "id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "name": "AeroEdit Pro", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/bT1XUOJAQhOUxGs83cbk_pro.png", + "created_at": "2023-02-23T12:43:46.605Z", + "updated_at": "2024-04-05T15:53:44.687Z", + "custom_data": { + "features": { + "sso": false, + "route_planning": true, + "payment_by_invoice": false, + "aircraft_performance": true, + "compliance_monitoring": true, + "flight_log_management": true + }, + "suggested_addons": [ + "pro_01h1vjes1y163xfj1rh1tkfb65", + "pro_01gsz97mq9pa4fkyy0wqenepkz" + ], + "upgrade_description": "Move from Basic to Pro to take advantage of aircraft performance, advanced route planning, and compliance monitoring." + }, + "description": "Designed for professional pilots, including all features plus in Basic plus compliance monitoring, route optimization, and third-party integrations.", + "import_meta": null, + "tax_category": "standard" + }, + "quantity": 10, + "recurring": true, + "created_at": "2024-09-09T16:01:29.811Z", + "updated_at": "2024-09-09T16:01:29.811Z", + "trial_dates": null, + "next_billed_at": "2024-10-09T16:01:29.378686Z", + "previously_billed_at": "2024-09-09T16:01:29.378686Z" + }, + { + "price": { + "id": "pri_01h1vjfevh5etwq3rb416a23h2", + "name": "Monthly (recurring addon)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 100, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-06-01T13:31:12.625056Z", + "product_id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "unit_price": { + "amount": "10000", + "currency_code": "USD" + }, + "updated_at": "2024-04-12T10:42:45.476453Z", + "custom_data": null, + "description": "Monthly", + "import_meta": null, + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "status": "active", + "product": { + "id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "name": "Analytics addon", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/97dRpA6SXzcE6ekK9CAr_analytics.png", + "created_at": "2023-06-01T13:30:50.302Z", + "updated_at": "2024-04-05T15:47:17.163Z", + "custom_data": null, + "description": "Unlock advanced insights into your flight data with enhanced analytics and reporting features. Includes customizable reporting templates and trend analysis across flights.", + "import_meta": null, + "tax_category": "standard" + }, + "quantity": 1, + "recurring": true, + "created_at": "2024-09-09T16:01:29.811Z", + "updated_at": "2024-09-09T16:01:29.811Z", + "trial_dates": null, + "next_billed_at": "2024-10-09T16:01:29.378686Z", + "previously_billed_at": "2024-09-09T16:01:29.378686Z" + } + ], + "status": "active", + "discount": null, + "paused_at": null, + "address_id": "add_01j7br89t3wgn5v5w49hm1mepv", + "created_at": "2024-09-09T16:01:29.811Z", + "started_at": "2024-09-09T16:01:29.378686Z", + "updated_at": "2024-09-09T16:01:29.811Z", + "business_id": null, + "canceled_at": null, + "custom_data": null, + "customer_id": "ctm_01j7br89shp7xpaenx22h8qetw", + "import_meta": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "currency_code": "USD", + "next_billed_at": "2024-10-09T16:01:29.378686Z", + "billing_details": null, + "collection_mode": "automatic", + "first_billed_at": "2024-09-09T16:01:29.378686Z", + "scheduled_change": null, + "current_billing_period": { + "ends_at": "2024-10-09T16:01:29.378686Z", + "starts_at": "2024-09-09T16:01:29.378686Z" + } + }, + "event_id": "evt_01j7br9m1s4bh0db0sb8n9wfdp", + "event_type": "subscription.activated", + "occurred_at": "2024-09-09T16:01:31.193492Z", + "notification_id": "ntf_01j7br9m6xfnajmm0mbr0zm0tg" + }, + "extraHeaders": { + "paddle-version": "1" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddlebilling/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddlebilling", + "providerLabel": "Paddle Billing", + "eventType": "subscription.created", + "name": "subscription.created", + "body": { + "data": { + "id": "sub_01j7br9jpk4rmmcd5crn2th1bq", + "items": [ + { + "price": { + "id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "name": "Monthly (per seat)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 999, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-02-23T13:55:22.538367Z", + "product_id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "unit_price": { + "amount": "3000", + "currency_code": "USD" + }, + "updated_at": "2024-04-11T13:54:52.254748Z", + "custom_data": null, + "description": "Monthly", + "import_meta": null, + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "status": "active", + "product": { + "id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "name": "AeroEdit Pro", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/bT1XUOJAQhOUxGs83cbk_pro.png", + "created_at": "2023-02-23T12:43:46.605Z", + "updated_at": "2024-04-05T15:53:44.687Z", + "custom_data": { + "features": { + "sso": false, + "route_planning": true, + "payment_by_invoice": false, + "aircraft_performance": true, + "compliance_monitoring": true, + "flight_log_management": true + }, + "suggested_addons": [ + "pro_01h1vjes1y163xfj1rh1tkfb65", + "pro_01gsz97mq9pa4fkyy0wqenepkz" + ], + "upgrade_description": "Move from Basic to Pro to take advantage of aircraft performance, advanced route planning, and compliance monitoring." + }, + "description": "Designed for professional pilots, including all features plus in Basic plus compliance monitoring, route optimization, and third-party integrations.", + "import_meta": null, + "tax_category": "standard" + }, + "quantity": 10, + "recurring": true, + "created_at": "2024-09-09T16:01:29.811Z", + "updated_at": "2024-09-09T16:01:29.811Z", + "trial_dates": null, + "next_billed_at": "2024-10-09T16:01:29.378686Z", + "previously_billed_at": "2024-09-09T16:01:29.378686Z" + }, + { + "price": { + "id": "pri_01h1vjfevh5etwq3rb416a23h2", + "name": "Monthly (recurring addon)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 100, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-06-01T13:31:12.625056Z", + "product_id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "unit_price": { + "amount": "10000", + "currency_code": "USD" + }, + "updated_at": "2024-04-12T10:42:45.476453Z", + "custom_data": null, + "description": "Monthly", + "import_meta": null, + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "status": "active", + "product": { + "id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "name": "Analytics addon", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/97dRpA6SXzcE6ekK9CAr_analytics.png", + "created_at": "2023-06-01T13:30:50.302Z", + "updated_at": "2024-04-05T15:47:17.163Z", + "custom_data": null, + "description": "Unlock advanced insights into your flight data with enhanced analytics and reporting features. Includes customizable reporting templates and trend analysis across flights.", + "import_meta": null, + "tax_category": "standard" + }, + "quantity": 1, + "recurring": true, + "created_at": "2024-09-09T16:01:29.811Z", + "updated_at": "2024-09-09T16:01:29.811Z", + "trial_dates": null, + "next_billed_at": "2024-10-09T16:01:29.378686Z", + "previously_billed_at": "2024-09-09T16:01:29.378686Z" + } + ], + "status": "active", + "discount": null, + "paused_at": null, + "address_id": "add_01j7br89t3wgn5v5w49hm1mepv", + "created_at": "2024-09-09T16:01:29.811Z", + "started_at": "2024-09-09T16:01:29.378686Z", + "updated_at": "2024-09-09T16:01:29.811Z", + "business_id": null, + "canceled_at": null, + "custom_data": null, + "customer_id": "ctm_01j7br89shp7xpaenx22h8qetw", + "import_meta": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "currency_code": "USD", + "next_billed_at": "2024-10-09T16:01:29.378686Z", + "transaction_id": "txn_01j7br78cgr083zqw1jhqym40s", + "billing_details": null, + "collection_mode": "automatic", + "first_billed_at": "2024-09-09T16:01:29.378686Z", + "scheduled_change": null, + "current_billing_period": { + "ends_at": "2024-10-09T16:01:29.378686Z", + "starts_at": "2024-09-09T16:01:29.378686Z" + } + }, + "event_id": "evt_01j7br9m2aesan7r77ckkast3s", + "event_type": "subscription.created", + "occurred_at": "2024-09-09T16:01:31.210540Z", + "notification_id": "ntf_01j7br9m7y1vm3ms0jxd7jf8v9" + }, + "extraHeaders": { + "paddle-version": "1" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddlebilling/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddlebilling", + "providerLabel": "Paddle Billing", + "eventType": "subscription.past_due", + "name": "subscription.past_due", + "body": { + "data": { + "id": "sub_01j7br9jpk4rmmcd5crn2th1bq", + "items": [ + { + "price": { + "id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "name": "Monthly (per seat)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 999, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-02-23T13:55:22.538367Z", + "product_id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "unit_price": { + "amount": "3000", + "currency_code": "USD" + }, + "updated_at": "2024-04-11T13:54:52.254748Z", + "custom_data": null, + "description": "Monthly", + "import_meta": null, + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "status": "active", + "product": { + "id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "name": "AeroEdit Pro", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/bT1XUOJAQhOUxGs83cbk_pro.png", + "created_at": "2023-02-23T12:43:46.605Z", + "updated_at": "2024-04-05T15:53:44.687Z", + "custom_data": { + "features": { + "sso": false, + "route_planning": true, + "payment_by_invoice": false, + "aircraft_performance": true, + "compliance_monitoring": true, + "flight_log_management": true + }, + "suggested_addons": [ + "pro_01h1vjes1y163xfj1rh1tkfb65", + "pro_01gsz97mq9pa4fkyy0wqenepkz" + ], + "upgrade_description": "Move from Basic to Pro to take advantage of aircraft performance, advanced route planning, and compliance monitoring." + }, + "description": "Designed for professional pilots, including all features plus in Basic plus compliance monitoring, route optimization, and third-party integrations.", + "import_meta": null, + "tax_category": "standard" + }, + "quantity": 10, + "recurring": true, + "created_at": "2024-09-09T16:01:29.811Z", + "updated_at": "2024-10-09T16:01:39.146686Z", + "trial_dates": null, + "next_billed_at": "2024-11-09T16:01:29.378686Z", + "previously_billed_at": "2024-10-09T16:01:29.378686Z" + }, + { + "price": { + "id": "pri_01h1vjfevh5etwq3rb416a23h2", + "name": "Monthly (recurring addon)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 100, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-06-01T13:31:12.625056Z", + "product_id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "unit_price": { + "amount": "10000", + "currency_code": "USD" + }, + "updated_at": "2024-04-12T10:42:45.476453Z", + "custom_data": null, + "description": "Monthly", + "import_meta": null, + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "status": "active", + "product": { + "id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "name": "Analytics addon", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/97dRpA6SXzcE6ekK9CAr_analytics.png", + "created_at": "2023-06-01T13:30:50.302Z", + "updated_at": "2024-04-05T15:47:17.163Z", + "custom_data": null, + "description": "Unlock advanced insights into your flight data with enhanced analytics and reporting features. Includes customizable reporting templates and trend analysis across flights.", + "import_meta": null, + "tax_category": "standard" + }, + "quantity": 1, + "recurring": true, + "created_at": "2024-09-09T16:01:29.811Z", + "updated_at": "2024-10-09T16:01:39.147686Z", + "trial_dates": null, + "next_billed_at": "2024-11-09T16:01:29.378686Z", + "previously_billed_at": "2024-10-09T16:01:29.378686Z" + } + ], + "status": "past_due", + "discount": null, + "paused_at": null, + "address_id": "add_01j7br89t3wgn5v5w49hm1mepv", + "created_at": "2024-09-09T16:01:29.811Z", + "started_at": "2024-09-09T16:01:29.378686Z", + "updated_at": "2024-10-09T16:01:39.148686Z", + "business_id": null, + "canceled_at": null, + "custom_data": null, + "customer_id": "ctm_01j7br89shp7xpaenx22h8qetw", + "import_meta": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "currency_code": "USD", + "next_billed_at": "2024-11-09T16:01:29.378686Z", + "billing_details": null, + "collection_mode": "automatic", + "first_billed_at": "2024-09-09T16:01:29.378686Z", + "scheduled_change": null, + "current_billing_period": { + "ends_at": "2024-11-09T16:01:29.378686Z", + "starts_at": "2024-10-09T16:01:29.378686Z" + } + }, + "event_id": "evt_01j7breb96dre2qv2bf7eq07d3", + "event_type": "subscription.past_due", + "occurred_at": "2024-09-09T16:04:06.054130Z", + "notification_id": "ntf_01j7brebh5yztyk1f98vyj25xt" + }, + "extraHeaders": { + "paddle-version": "1" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddlebilling/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddlebilling", + "providerLabel": "Paddle Billing", + "eventType": "subscription.updated", + "name": "subscription.updated", + "body": { + "data": { + "id": "sub_01j7br9jpk4rmmcd5crn2th1bq", + "items": [ + { + "price": { + "id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "name": "Monthly (per seat)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 999, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-02-23T13:55:22.538367Z", + "product_id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "unit_price": { + "amount": "3000", + "currency_code": "USD" + }, + "updated_at": "2024-04-11T13:54:52.254748Z", + "custom_data": null, + "description": "Monthly", + "import_meta": null, + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "status": "active", + "product": { + "id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "name": "AeroEdit Pro", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/bT1XUOJAQhOUxGs83cbk_pro.png", + "created_at": "2023-02-23T12:43:46.605Z", + "updated_at": "2024-04-05T15:53:44.687Z", + "custom_data": { + "features": { + "sso": false, + "route_planning": true, + "payment_by_invoice": false, + "aircraft_performance": true, + "compliance_monitoring": true, + "flight_log_management": true + }, + "suggested_addons": [ + "pro_01h1vjes1y163xfj1rh1tkfb65", + "pro_01gsz97mq9pa4fkyy0wqenepkz" + ], + "upgrade_description": "Move from Basic to Pro to take advantage of aircraft performance, advanced route planning, and compliance monitoring." + }, + "description": "Designed for professional pilots, including all features plus in Basic plus compliance monitoring, route optimization, and third-party integrations.", + "import_meta": null, + "tax_category": "standard" + }, + "quantity": 10, + "recurring": true, + "created_at": "2024-09-09T16:01:29.811Z", + "updated_at": "2024-10-09T16:01:39.146686Z", + "trial_dates": null, + "next_billed_at": "2024-11-09T16:01:29.378686Z", + "previously_billed_at": "2024-10-09T16:01:29.378686Z" + }, + { + "price": { + "id": "pri_01h1vjfevh5etwq3rb416a23h2", + "name": "Monthly (recurring addon)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 100, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-06-01T13:31:12.625056Z", + "product_id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "unit_price": { + "amount": "10000", + "currency_code": "USD" + }, + "updated_at": "2024-04-12T10:42:45.476453Z", + "custom_data": null, + "description": "Monthly", + "import_meta": null, + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "status": "active", + "product": { + "id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "name": "Analytics addon", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/97dRpA6SXzcE6ekK9CAr_analytics.png", + "created_at": "2023-06-01T13:30:50.302Z", + "updated_at": "2024-04-05T15:47:17.163Z", + "custom_data": null, + "description": "Unlock advanced insights into your flight data with enhanced analytics and reporting features. Includes customizable reporting templates and trend analysis across flights.", + "import_meta": null, + "tax_category": "standard" + }, + "quantity": 1, + "recurring": true, + "created_at": "2024-09-09T16:01:29.811Z", + "updated_at": "2024-10-09T16:01:39.147686Z", + "trial_dates": null, + "next_billed_at": "2024-11-09T16:01:29.378686Z", + "previously_billed_at": "2024-10-09T16:01:29.378686Z" + } + ], + "status": "past_due", + "discount": null, + "paused_at": null, + "address_id": "add_01j7br89t3wgn5v5w49hm1mepv", + "created_at": "2024-09-09T16:01:29.811Z", + "started_at": "2024-09-09T16:01:29.378686Z", + "updated_at": "2024-10-09T16:01:39.148686Z", + "business_id": null, + "canceled_at": null, + "custom_data": null, + "customer_id": "ctm_01j7br89shp7xpaenx22h8qetw", + "import_meta": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "currency_code": "USD", + "next_billed_at": "2024-11-09T16:01:29.378686Z", + "billing_details": null, + "collection_mode": "automatic", + "first_billed_at": "2024-09-09T16:01:29.378686Z", + "scheduled_change": null, + "current_billing_period": { + "ends_at": "2024-11-09T16:01:29.378686Z", + "starts_at": "2024-10-09T16:01:29.378686Z" + } + }, + "event_id": "evt_01j7breb93syekfvzq86r4940f", + "event_type": "subscription.updated", + "occurred_at": "2024-09-09T16:04:06.051917Z", + "notification_id": "ntf_01j7brebggpc2gcq38bzpphzmd" + }, + "extraHeaders": { + "paddle-version": "1" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddlebilling/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddlebilling", + "providerLabel": "Paddle Billing", + "eventType": "transaction.billed", + "name": "transaction.billed", + "body": { + "data": { + "id": "txn_01j7bre7fka75pa1k8arkf0by3", + "items": [ + { + "price": { + "id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "name": "Monthly (per seat)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 999, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-02-23T13:55:22.538367Z", + "product_id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "unit_price": { + "amount": "3000", + "currency_code": "USD" + }, + "updated_at": "2024-04-11T13:54:52.254748Z", + "custom_data": null, + "description": "Monthly", + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "price_id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "quantity": 10, + "proration": null + }, + { + "price": { + "id": "pri_01h1vjfevh5etwq3rb416a23h2", + "name": "Monthly (recurring addon)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 100, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-06-01T13:31:12.625056Z", + "product_id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "unit_price": { + "amount": "10000", + "currency_code": "USD" + }, + "updated_at": "2024-04-12T10:42:45.476453Z", + "custom_data": null, + "description": "Monthly", + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "price_id": "pri_01h1vjfevh5etwq3rb416a23h2", + "quantity": 1, + "proration": null + } + ], + "origin": "subscription_recurring", + "status": "billed", + "details": { + "totals": { + "fee": null, + "tax": "3549", + "total": "43549", + "credit": "0", + "balance": "43549", + "discount": "0", + "earnings": null, + "subtotal": "40000", + "grand_total": "43549", + "currency_code": "USD", + "credit_to_balance": "0" + }, + "line_items": [ + { + "id": "txnitm_01j7bre7vtfvxd0mvhr7s4x55h", + "totals": { + "tax": "2662", + "total": "32662", + "discount": "0", + "subtotal": "30000" + }, + "item_id": null, + "product": { + "id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "name": "AeroEdit Pro", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/bT1XUOJAQhOUxGs83cbk_pro.png", + "created_at": "2023-02-23T12:43:46.605Z", + "updated_at": "2024-04-05T15:53:44.687Z", + "custom_data": { + "features": { + "sso": false, + "route_planning": true, + "payment_by_invoice": false, + "aircraft_performance": true, + "compliance_monitoring": true, + "flight_log_management": true + }, + "suggested_addons": [ + "pro_01h1vjes1y163xfj1rh1tkfb65", + "pro_01gsz97mq9pa4fkyy0wqenepkz" + ], + "upgrade_description": "Move from Basic to Pro to take advantage of aircraft performance, advanced route planning, and compliance monitoring." + }, + "description": "Designed for professional pilots, including all features plus in Basic plus compliance monitoring, route optimization, and third-party integrations.", + "tax_category": "standard" + }, + "price_id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "quantity": 10, + "tax_rate": "0.08875", + "unit_totals": { + "tax": "266", + "total": "3266", + "discount": "0", + "subtotal": "3000" + } + }, + { + "id": "txnitm_01j7bre7vvfcpg0hjkgypbp002", + "totals": { + "tax": "887", + "total": "10887", + "discount": "0", + "subtotal": "10000" + }, + "item_id": null, + "product": { + "id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "name": "Analytics addon", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/97dRpA6SXzcE6ekK9CAr_analytics.png", + "created_at": "2023-06-01T13:30:50.302Z", + "updated_at": "2024-04-05T15:47:17.163Z", + "custom_data": null, + "description": "Unlock advanced insights into your flight data with enhanced analytics and reporting features. Includes customizable reporting templates and trend analysis across flights.", + "tax_category": "standard" + }, + "price_id": "pri_01h1vjfevh5etwq3rb416a23h2", + "quantity": 1, + "tax_rate": "0.08875", + "unit_totals": { + "tax": "887", + "total": "10887", + "discount": "0", + "subtotal": "10000" + } + } + ], + "payout_totals": null, + "tax_rates_used": [ + { + "totals": { + "tax": "3549", + "total": "43549", + "discount": "0", + "subtotal": "40000" + }, + "tax_rate": "0.08875" + } + ], + "adjusted_totals": { + "fee": "0", + "tax": "3549", + "total": "43549", + "earnings": "0", + "subtotal": "40000", + "grand_total": "43549", + "currency_code": "USD" + } + }, + "checkout": { + "url": "https://magnificent-entremet-7ae0c6.netlify.app/default/overlay?_ptxn=txn_01j7bre7fka75pa1k8arkf0by3" + }, + "payments": [], + "billed_at": "2024-09-09T16:04:02.163775934Z", + "address_id": "add_01j7br89t3wgn5v5w49hm1mepv", + "created_at": "2024-09-09T16:04:02.613735612Z", + "invoice_id": null, + "updated_at": "2024-09-09T16:04:02.613735612Z", + "business_id": null, + "custom_data": null, + "customer_id": "ctm_01j7br89shp7xpaenx22h8qetw", + "discount_id": null, + "receipt_data": null, + "currency_code": "USD", + "billing_period": { + "ends_at": "2024-11-09T16:01:29.378686Z", + "starts_at": "2024-10-09T16:01:29.378686Z" + }, + "invoice_number": null, + "billing_details": null, + "collection_mode": "automatic", + "subscription_id": "sub_01j7br9jpk4rmmcd5crn2th1bq" + }, + "event_id": "evt_01j7bre89dmgqdecwpznemhjxz", + "event_type": "transaction.billed", + "occurred_at": "2024-09-09T16:04:02.989795Z", + "notification_id": "ntf_01j7bre8fzam079nxvx1808trs" + }, + "extraHeaders": { + "paddle-version": "1" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddlebilling/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddlebilling", + "providerLabel": "Paddle Billing", + "eventType": "transaction.completed", + "name": "transaction.completed", + "body": { + "data": { + "id": "txn_01j7br78cgr083zqw1jhqym40s", + "items": [ + { + "price": { + "id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "name": "Monthly (per seat)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 999, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-02-23T13:55:22.538367Z", + "product_id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "unit_price": { + "amount": "3000", + "currency_code": "USD" + }, + "updated_at": "2024-04-11T13:54:52.254748Z", + "custom_data": null, + "description": "Monthly", + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "price_id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "quantity": 10, + "proration": null + }, + { + "price": { + "id": "pri_01h1vjfevh5etwq3rb416a23h2", + "name": "Monthly (recurring addon)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 100, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-06-01T13:31:12.625056Z", + "product_id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "unit_price": { + "amount": "10000", + "currency_code": "USD" + }, + "updated_at": "2024-04-12T10:42:45.476453Z", + "custom_data": null, + "description": "Monthly", + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "price_id": "pri_01h1vjfevh5etwq3rb416a23h2", + "quantity": 1, + "proration": null + }, + { + "price": { + "id": "pri_01gsz98e27ak2tyhexptwc58yk", + "name": "One-time addon", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 1, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-02-23T14:01:28.391712Z", + "product_id": "pro_01gsz97mq9pa4fkyy0wqenepkz", + "unit_price": { + "amount": "19900", + "currency_code": "USD" + }, + "updated_at": "2024-04-09T07:23:10.921392Z", + "custom_data": null, + "description": "One-time addon", + "trial_period": null, + "billing_cycle": null, + "unit_price_overrides": [] + }, + "price_id": "pri_01gsz98e27ak2tyhexptwc58yk", + "quantity": 1, + "proration": null + } + ], + "origin": "web", + "status": "completed", + "details": { + "totals": { + "fee": "3311", + "tax": "5315", + "total": "65215", + "credit": "0", + "balance": "0", + "discount": "0", + "earnings": "56589", + "subtotal": "59900", + "grand_total": "65215", + "currency_code": "USD", + "credit_to_balance": "0" + }, + "line_items": [ + { + "id": "txnitm_01j7br8aeyz8r6wm2zywjw97d4", + "totals": { + "tax": "2662", + "total": "32662", + "discount": "0", + "subtotal": "30000" + }, + "item_id": null, + "product": { + "id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "name": "AeroEdit Pro", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/bT1XUOJAQhOUxGs83cbk_pro.png", + "created_at": "2023-02-23T12:43:46.605Z", + "updated_at": "2024-04-05T15:53:44.687Z", + "custom_data": { + "features": { + "sso": false, + "route_planning": true, + "payment_by_invoice": false, + "aircraft_performance": true, + "compliance_monitoring": true, + "flight_log_management": true + }, + "suggested_addons": [ + "pro_01h1vjes1y163xfj1rh1tkfb65", + "pro_01gsz97mq9pa4fkyy0wqenepkz" + ], + "upgrade_description": "Move from Basic to Pro to take advantage of aircraft performance, advanced route planning, and compliance monitoring." + }, + "description": "Designed for professional pilots, including all features plus in Basic plus compliance monitoring, route optimization, and third-party integrations.", + "tax_category": "standard" + }, + "price_id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "quantity": 10, + "tax_rate": "0.08875", + "unit_totals": { + "tax": "266", + "total": "3266", + "discount": "0", + "subtotal": "3000" + } + }, + { + "id": "txnitm_01j7br8aez018mgmrj3bpmxmh7", + "totals": { + "tax": "887", + "total": "10887", + "discount": "0", + "subtotal": "10000" + }, + "item_id": null, + "product": { + "id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "name": "Analytics addon", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/97dRpA6SXzcE6ekK9CAr_analytics.png", + "created_at": "2023-06-01T13:30:50.302Z", + "updated_at": "2024-04-05T15:47:17.163Z", + "custom_data": null, + "description": "Unlock advanced insights into your flight data with enhanced analytics and reporting features. Includes customizable reporting templates and trend analysis across flights.", + "tax_category": "standard" + }, + "price_id": "pri_01h1vjfevh5etwq3rb416a23h2", + "quantity": 1, + "tax_rate": "0.08875", + "unit_totals": { + "tax": "887", + "total": "10887", + "discount": "0", + "subtotal": "10000" + } + }, + { + "id": "txnitm_01j7br8aez018mgmrj3e9nbbr7", + "totals": { + "tax": "1766", + "total": "21666", + "discount": "0", + "subtotal": "19900" + }, + "item_id": null, + "product": { + "id": "pro_01gsz97mq9pa4fkyy0wqenepkz", + "name": "Custom domains", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/XIG7UXoJQHmlIAiKcnkA_custom-domains.png", + "created_at": "2023-02-23T14:01:02.441Z", + "updated_at": "2024-04-05T15:43:28.971Z", + "custom_data": null, + "description": "Make AeroEdit truly your own with custom domains. Custom domains reinforce your brand identity and make it easy for your team to access your account.", + "tax_category": "standard" + }, + "price_id": "pri_01gsz98e27ak2tyhexptwc58yk", + "quantity": 1, + "tax_rate": "0.08875", + "unit_totals": { + "tax": "1766", + "total": "21666", + "discount": "0", + "subtotal": "19900" + } + } + ], + "payout_totals": { + "fee": "3311", + "tax": "5315", + "total": "65215", + "credit": "0", + "balance": "0", + "discount": "0", + "earnings": "56589", + "fee_rate": "0.05", + "subtotal": "59900", + "grand_total": "65215", + "currency_code": "USD", + "exchange_rate": "1", + "credit_to_balance": "0" + }, + "tax_rates_used": [ + { + "totals": { + "tax": "5315", + "total": "65215", + "discount": "0", + "subtotal": "59900" + }, + "tax_rate": "0.08875" + } + ], + "adjusted_totals": { + "fee": "3311", + "tax": "5315", + "total": "65215", + "earnings": "56589", + "subtotal": "59900", + "grand_total": "65215", + "currency_code": "USD" + } + }, + "checkout": { + "url": "https://magnificent-entremet-7ae0c6.netlify.app/default/overlay?_ptxn=txn_01j7br78cgr083zqw1jhqym40s" + }, + "payments": [ + { + "amount": "65215", + "status": "captured", + "created_at": "2024-09-09T16:01:17.683756Z", + "error_code": null, + "captured_at": "2024-09-09T16:01:29.378686Z", + "method_details": { + "card": { + "type": "visa", + "last4": "3184", + "expiry_year": 2029, + "expiry_month": 10, + "cardholder_name": "Michael McGovern" + }, + "type": "card" + }, + "payment_method_id": "paymtd_01j7br96tsmrhw9zef1dc0rmef", + "payment_attempt_id": "01e1c65b-3eca-4fca-b160-f162d641852e", + "stored_payment_method_id": "9708381b-bd52-4c65-abc0-705e636c5c47" + } + ], + "billed_at": "2024-09-09T16:01:29.536914Z", + "address_id": "add_01j7br89t3wgn5v5w49hm1mepv", + "created_at": "2024-09-09T16:00:13.888989Z", + "invoice_id": "inv_01j7br9jq8k57phrbkyehe9kwj", + "updated_at": "2024-09-09T16:01:31.565947359Z", + "business_id": null, + "custom_data": null, + "customer_id": "ctm_01j7br89shp7xpaenx22h8qetw", + "discount_id": null, + "receipt_data": null, + "currency_code": "USD", + "billing_period": { + "ends_at": "2024-10-09T16:01:29.378686Z", + "starts_at": "2024-09-09T16:01:29.378686Z" + }, + "invoice_number": "325-11889", + "billing_details": null, + "collection_mode": "automatic", + "subscription_id": "sub_01j7br9jpk4rmmcd5crn2th1bq" + }, + "event_id": "evt_01j7br9myw2s8wzb7t8x73f7sn", + "event_type": "transaction.completed", + "occurred_at": "2024-09-09T16:01:32.124939Z", + "notification_id": "ntf_01j7br9n4yhaq2r7hzndzaa33d" + }, + "extraHeaders": { + "paddle-version": "1" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddlebilling/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddlebilling", + "providerLabel": "Paddle Billing", + "eventType": "transaction.created", + "name": "transaction.created", + "body": { + "data": { + "id": "txn_01j7br78cgr083zqw1jhqym40s", + "items": [ + { + "price": { + "id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "name": "Monthly (per seat)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 999, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-02-23T13:55:22.538367Z", + "product_id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "unit_price": { + "amount": "3000", + "currency_code": "USD" + }, + "updated_at": "2024-04-11T13:54:52.254748Z", + "custom_data": null, + "description": "Monthly", + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "price_id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "quantity": 10, + "proration": null + }, + { + "price": { + "id": "pri_01h1vjfevh5etwq3rb416a23h2", + "name": "Monthly (recurring addon)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 100, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-06-01T13:31:12.625056Z", + "product_id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "unit_price": { + "amount": "10000", + "currency_code": "USD" + }, + "updated_at": "2024-04-12T10:42:45.476453Z", + "custom_data": null, + "description": "Monthly", + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "price_id": "pri_01h1vjfevh5etwq3rb416a23h2", + "quantity": 1, + "proration": null + }, + { + "price": { + "id": "pri_01gsz98e27ak2tyhexptwc58yk", + "name": "One-time addon", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 1, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-02-23T14:01:28.391712Z", + "product_id": "pro_01gsz97mq9pa4fkyy0wqenepkz", + "unit_price": { + "amount": "19900", + "currency_code": "USD" + }, + "updated_at": "2024-04-09T07:23:10.921392Z", + "custom_data": null, + "description": "One-time addon", + "trial_period": null, + "billing_cycle": null, + "unit_price_overrides": [] + }, + "price_id": "pri_01gsz98e27ak2tyhexptwc58yk", + "quantity": 1, + "proration": null + } + ], + "origin": "web", + "status": "draft", + "details": { + "totals": { + "fee": null, + "tax": "9157", + "total": "54940", + "credit": "0", + "balance": "54940", + "discount": "0", + "earnings": null, + "subtotal": "45783", + "grand_total": "54940", + "currency_code": "GBP", + "credit_to_balance": "0" + }, + "line_items": [ + { + "id": "txnitm_01j7br78hpq4j57aq69gp6cvp8", + "totals": { + "tax": "4586", + "total": "27516", + "discount": "0", + "subtotal": "22930" + }, + "item_id": null, + "product": { + "id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "name": "AeroEdit Pro", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/bT1XUOJAQhOUxGs83cbk_pro.png", + "created_at": "2023-02-23T12:43:46.605Z", + "updated_at": "2024-04-05T15:53:44.687Z", + "custom_data": { + "features": { + "sso": false, + "route_planning": true, + "payment_by_invoice": false, + "aircraft_performance": true, + "compliance_monitoring": true, + "flight_log_management": true + }, + "suggested_addons": [ + "pro_01h1vjes1y163xfj1rh1tkfb65", + "pro_01gsz97mq9pa4fkyy0wqenepkz" + ], + "upgrade_description": "Move from Basic to Pro to take advantage of aircraft performance, advanced route planning, and compliance monitoring." + }, + "description": "Designed for professional pilots, including all features plus in Basic plus compliance monitoring, route optimization, and third-party integrations.", + "tax_category": "standard" + }, + "price_id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "quantity": 10, + "tax_rate": "0.2", + "unit_totals": { + "tax": "459", + "total": "2752", + "discount": "0", + "subtotal": "2293" + } + }, + { + "id": "txnitm_01j7br78hpq4j57aq69p5eh0ra", + "totals": { + "tax": "1529", + "total": "9172", + "discount": "0", + "subtotal": "7643" + }, + "item_id": null, + "product": { + "id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "name": "Analytics addon", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/97dRpA6SXzcE6ekK9CAr_analytics.png", + "created_at": "2023-06-01T13:30:50.302Z", + "updated_at": "2024-04-05T15:47:17.163Z", + "custom_data": null, + "description": "Unlock advanced insights into your flight data with enhanced analytics and reporting features. Includes customizable reporting templates and trend analysis across flights.", + "tax_category": "standard" + }, + "price_id": "pri_01h1vjfevh5etwq3rb416a23h2", + "quantity": 1, + "tax_rate": "0.2", + "unit_totals": { + "tax": "1529", + "total": "9172", + "discount": "0", + "subtotal": "7643" + } + }, + { + "id": "txnitm_01j7br78hpq4j57aq69sc1dxje", + "totals": { + "tax": "3042", + "total": "18252", + "discount": "0", + "subtotal": "15210" + }, + "item_id": null, + "product": { + "id": "pro_01gsz97mq9pa4fkyy0wqenepkz", + "name": "Custom domains", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/XIG7UXoJQHmlIAiKcnkA_custom-domains.png", + "created_at": "2023-02-23T14:01:02.441Z", + "updated_at": "2024-04-05T15:43:28.971Z", + "custom_data": null, + "description": "Make AeroEdit truly your own with custom domains. Custom domains reinforce your brand identity and make it easy for your team to access your account.", + "tax_category": "standard" + }, + "price_id": "pri_01gsz98e27ak2tyhexptwc58yk", + "quantity": 1, + "tax_rate": "0.2", + "unit_totals": { + "tax": "3042", + "total": "18252", + "discount": "0", + "subtotal": "15210" + } + } + ], + "payout_totals": null, + "tax_rates_used": [ + { + "totals": { + "tax": "9157", + "total": "54940", + "discount": "0", + "subtotal": "45783" + }, + "tax_rate": "0.2" + } + ], + "adjusted_totals": { + "fee": "0", + "tax": "9157", + "total": "54940", + "earnings": "0", + "subtotal": "45783", + "grand_total": "54940", + "currency_code": "GBP" + } + }, + "checkout": { + "url": "https://magnificent-entremet-7ae0c6.netlify.app/default/overlay?_ptxn=txn_01j7br78cgr083zqw1jhqym40s" + }, + "payments": [], + "billed_at": null, + "address_id": null, + "created_at": "2024-09-09T16:00:13.888989481Z", + "invoice_id": null, + "updated_at": "2024-09-09T16:00:13.888989481Z", + "business_id": null, + "custom_data": null, + "customer_id": null, + "discount_id": null, + "receipt_data": null, + "currency_code": "GBP", + "billing_period": null, + "invoice_number": null, + "billing_details": null, + "collection_mode": "automatic", + "subscription_id": null + }, + "event_id": "evt_01j7br78zywewybejv0njrzhan", + "event_type": "transaction.created", + "occurred_at": "2024-09-09T16:00:14.334401Z", + "notification_id": "ntf_01j7br797ss25hp5rnshksjq6y" + }, + "extraHeaders": { + "paddle-version": "1" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddlebilling/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddlebilling", + "providerLabel": "Paddle Billing", + "eventType": "transaction.paid", + "name": "transaction.paid", + "body": { + "data": { + "id": "txn_01j7br78cgr083zqw1jhqym40s", + "items": [ + { + "price": { + "id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "name": "Monthly (per seat)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 999, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-02-23T13:55:22.538367Z", + "product_id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "unit_price": { + "amount": "3000", + "currency_code": "USD" + }, + "updated_at": "2024-04-11T13:54:52.254748Z", + "custom_data": null, + "description": "Monthly", + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "price_id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "quantity": 10, + "proration": null + }, + { + "price": { + "id": "pri_01h1vjfevh5etwq3rb416a23h2", + "name": "Monthly (recurring addon)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 100, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-06-01T13:31:12.625056Z", + "product_id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "unit_price": { + "amount": "10000", + "currency_code": "USD" + }, + "updated_at": "2024-04-12T10:42:45.476453Z", + "custom_data": null, + "description": "Monthly", + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "price_id": "pri_01h1vjfevh5etwq3rb416a23h2", + "quantity": 1, + "proration": null + }, + { + "price": { + "id": "pri_01gsz98e27ak2tyhexptwc58yk", + "name": "One-time addon", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 1, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-02-23T14:01:28.391712Z", + "product_id": "pro_01gsz97mq9pa4fkyy0wqenepkz", + "unit_price": { + "amount": "19900", + "currency_code": "USD" + }, + "updated_at": "2024-04-09T07:23:10.921392Z", + "custom_data": null, + "description": "One-time addon", + "trial_period": null, + "billing_cycle": null, + "unit_price_overrides": [] + }, + "price_id": "pri_01gsz98e27ak2tyhexptwc58yk", + "quantity": 1, + "proration": null + } + ], + "origin": "web", + "status": "paid", + "details": { + "totals": { + "fee": null, + "tax": "5315", + "total": "65215", + "credit": "0", + "balance": "0", + "discount": "0", + "earnings": null, + "subtotal": "59900", + "grand_total": "65215", + "currency_code": "USD", + "credit_to_balance": "0" + }, + "line_items": [ + { + "id": "txnitm_01j7br8aeyz8r6wm2zywjw97d4", + "totals": { + "tax": "2662", + "total": "32662", + "discount": "0", + "subtotal": "30000" + }, + "item_id": null, + "product": { + "id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "name": "AeroEdit Pro", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/bT1XUOJAQhOUxGs83cbk_pro.png", + "created_at": "2023-02-23T12:43:46.605Z", + "updated_at": "2024-04-05T15:53:44.687Z", + "custom_data": { + "features": { + "sso": false, + "route_planning": true, + "payment_by_invoice": false, + "aircraft_performance": true, + "compliance_monitoring": true, + "flight_log_management": true + }, + "suggested_addons": [ + "pro_01h1vjes1y163xfj1rh1tkfb65", + "pro_01gsz97mq9pa4fkyy0wqenepkz" + ], + "upgrade_description": "Move from Basic to Pro to take advantage of aircraft performance, advanced route planning, and compliance monitoring." + }, + "description": "Designed for professional pilots, including all features plus in Basic plus compliance monitoring, route optimization, and third-party integrations.", + "tax_category": "standard" + }, + "price_id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "quantity": 10, + "tax_rate": "0.08875", + "unit_totals": { + "tax": "266", + "total": "3266", + "discount": "0", + "subtotal": "3000" + } + }, + { + "id": "txnitm_01j7br8aez018mgmrj3bpmxmh7", + "totals": { + "tax": "887", + "total": "10887", + "discount": "0", + "subtotal": "10000" + }, + "item_id": null, + "product": { + "id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "name": "Analytics addon", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/97dRpA6SXzcE6ekK9CAr_analytics.png", + "created_at": "2023-06-01T13:30:50.302Z", + "updated_at": "2024-04-05T15:47:17.163Z", + "custom_data": null, + "description": "Unlock advanced insights into your flight data with enhanced analytics and reporting features. Includes customizable reporting templates and trend analysis across flights.", + "tax_category": "standard" + }, + "price_id": "pri_01h1vjfevh5etwq3rb416a23h2", + "quantity": 1, + "tax_rate": "0.08875", + "unit_totals": { + "tax": "887", + "total": "10887", + "discount": "0", + "subtotal": "10000" + } + }, + { + "id": "txnitm_01j7br8aez018mgmrj3e9nbbr7", + "totals": { + "tax": "1766", + "total": "21666", + "discount": "0", + "subtotal": "19900" + }, + "item_id": null, + "product": { + "id": "pro_01gsz97mq9pa4fkyy0wqenepkz", + "name": "Custom domains", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/XIG7UXoJQHmlIAiKcnkA_custom-domains.png", + "created_at": "2023-02-23T14:01:02.441Z", + "updated_at": "2024-04-05T15:43:28.971Z", + "custom_data": null, + "description": "Make AeroEdit truly your own with custom domains. Custom domains reinforce your brand identity and make it easy for your team to access your account.", + "tax_category": "standard" + }, + "price_id": "pri_01gsz98e27ak2tyhexptwc58yk", + "quantity": 1, + "tax_rate": "0.08875", + "unit_totals": { + "tax": "1766", + "total": "21666", + "discount": "0", + "subtotal": "19900" + } + } + ], + "payout_totals": null, + "tax_rates_used": [ + { + "totals": { + "tax": "5315", + "total": "65215", + "discount": "0", + "subtotal": "59900" + }, + "tax_rate": "0.08875" + } + ], + "adjusted_totals": { + "fee": "0", + "tax": "5315", + "total": "65215", + "earnings": "0", + "subtotal": "59900", + "grand_total": "65215", + "currency_code": "USD" + } + }, + "checkout": { + "url": "https://magnificent-entremet-7ae0c6.netlify.app/default/overlay?_ptxn=txn_01j7br78cgr083zqw1jhqym40s" + }, + "payments": [ + { + "amount": "65215", + "status": "captured", + "created_at": "2024-09-09T16:01:17.683756Z", + "error_code": null, + "captured_at": "2024-09-09T16:01:29.378686Z", + "method_details": { + "card": { + "type": "visa", + "last4": "3184", + "expiry_year": 2029, + "expiry_month": 10, + "cardholder_name": "Michael McGovern" + }, + "type": "card" + }, + "payment_method_id": "paymtd_01j7br96tsmrhw9zef1dc0rmef", + "payment_attempt_id": "01e1c65b-3eca-4fca-b160-f162d641852e", + "stored_payment_method_id": "9708381b-bd52-4c65-abc0-705e636c5c47" + } + ], + "billed_at": "2024-09-09T16:01:29.53691403Z", + "address_id": "add_01j7br89t3wgn5v5w49hm1mepv", + "created_at": "2024-09-09T16:00:13.888989Z", + "invoice_id": null, + "updated_at": "2024-09-09T16:01:29.536915778Z", + "business_id": null, + "custom_data": null, + "customer_id": "ctm_01j7br89shp7xpaenx22h8qetw", + "discount_id": null, + "receipt_data": null, + "currency_code": "USD", + "billing_period": null, + "invoice_number": null, + "billing_details": null, + "collection_mode": "automatic", + "subscription_id": null + }, + "event_id": "evt_01j7br9jtryxbtd89yzj3pkyec", + "event_type": "transaction.paid", + "occurred_at": "2024-09-09T16:01:29.944764Z", + "notification_id": "ntf_01j7br9k4jznazhz99sb26z34t" + }, + "extraHeaders": { + "paddle-version": "1" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddlebilling/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddlebilling", + "providerLabel": "Paddle Billing", + "eventType": "transaction.past_due", + "name": "transaction.past_due", + "body": { + "data": { + "id": "txn_01j7bre7fka75pa1k8arkf0by3", + "items": [ + { + "price": { + "id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "name": "Monthly (per seat)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 999, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-02-23T13:55:22.538367Z", + "product_id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "unit_price": { + "amount": "3000", + "currency_code": "USD" + }, + "updated_at": "2024-04-11T13:54:52.254748Z", + "custom_data": null, + "description": "Monthly", + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "price_id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "quantity": 10, + "proration": null + }, + { + "price": { + "id": "pri_01h1vjfevh5etwq3rb416a23h2", + "name": "Monthly (recurring addon)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 100, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-06-01T13:31:12.625056Z", + "product_id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "unit_price": { + "amount": "10000", + "currency_code": "USD" + }, + "updated_at": "2024-04-12T10:42:45.476453Z", + "custom_data": null, + "description": "Monthly", + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "price_id": "pri_01h1vjfevh5etwq3rb416a23h2", + "quantity": 1, + "proration": null + } + ], + "origin": "subscription_recurring", + "status": "past_due", + "details": { + "totals": { + "fee": null, + "tax": "3549", + "total": "43549", + "credit": "0", + "balance": "43549", + "discount": "0", + "earnings": null, + "subtotal": "40000", + "grand_total": "43549", + "currency_code": "USD", + "credit_to_balance": "0" + }, + "line_items": [ + { + "id": "txnitm_01j7bre7vtfvxd0mvhr7s4x55h", + "totals": { + "tax": "2662", + "total": "32662", + "discount": "0", + "subtotal": "30000" + }, + "item_id": null, + "product": { + "id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "name": "AeroEdit Pro", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/bT1XUOJAQhOUxGs83cbk_pro.png", + "created_at": "2023-02-23T12:43:46.605Z", + "updated_at": "2024-04-05T15:53:44.687Z", + "custom_data": { + "features": { + "sso": false, + "route_planning": true, + "payment_by_invoice": false, + "aircraft_performance": true, + "compliance_monitoring": true, + "flight_log_management": true + }, + "suggested_addons": [ + "pro_01h1vjes1y163xfj1rh1tkfb65", + "pro_01gsz97mq9pa4fkyy0wqenepkz" + ], + "upgrade_description": "Move from Basic to Pro to take advantage of aircraft performance, advanced route planning, and compliance monitoring." + }, + "description": "Designed for professional pilots, including all features plus in Basic plus compliance monitoring, route optimization, and third-party integrations.", + "tax_category": "standard" + }, + "price_id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "quantity": 10, + "tax_rate": "0.08875", + "unit_totals": { + "tax": "266", + "total": "3266", + "discount": "0", + "subtotal": "3000" + } + }, + { + "id": "txnitm_01j7bre7vvfcpg0hjkgypbp002", + "totals": { + "tax": "887", + "total": "10887", + "discount": "0", + "subtotal": "10000" + }, + "item_id": null, + "product": { + "id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "name": "Analytics addon", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/97dRpA6SXzcE6ekK9CAr_analytics.png", + "created_at": "2023-06-01T13:30:50.302Z", + "updated_at": "2024-04-05T15:47:17.163Z", + "custom_data": null, + "description": "Unlock advanced insights into your flight data with enhanced analytics and reporting features. Includes customizable reporting templates and trend analysis across flights.", + "tax_category": "standard" + }, + "price_id": "pri_01h1vjfevh5etwq3rb416a23h2", + "quantity": 1, + "tax_rate": "0.08875", + "unit_totals": { + "tax": "887", + "total": "10887", + "discount": "0", + "subtotal": "10000" + } + } + ], + "payout_totals": null, + "tax_rates_used": [ + { + "totals": { + "tax": "3549", + "total": "43549", + "discount": "0", + "subtotal": "40000" + }, + "tax_rate": "0.08875" + } + ], + "adjusted_totals": { + "fee": "0", + "tax": "3549", + "total": "43549", + "earnings": "0", + "subtotal": "40000", + "grand_total": "43549", + "currency_code": "USD" + } + }, + "checkout": { + "url": "https://magnificent-entremet-7ae0c6.netlify.app/default/overlay?_ptxn=txn_01j7bre7fka75pa1k8arkf0by3" + }, + "payments": [ + { + "amount": "43549", + "status": "error", + "created_at": "2024-09-09T16:04:02.697051Z", + "error_code": "authentication_failed", + "captured_at": null, + "method_details": { + "card": { + "type": "visa", + "last4": "3184", + "expiry_year": 2029, + "expiry_month": 10, + "cardholder_name": "Michael McGovern" + }, + "type": "card" + }, + "payment_method_id": "paymtd_01j7br96tsmrhw9zef1dc0rmef", + "payment_attempt_id": "58c9c9a3-593c-428b-a2d4-cfc1603d4c36", + "stored_payment_method_id": "9708381b-bd52-4c65-abc0-705e636c5c47" + } + ], + "billed_at": "2024-09-09T16:04:02.163775Z", + "address_id": "add_01j7br89t3wgn5v5w49hm1mepv", + "created_at": "2024-09-09T16:04:02.613735Z", + "invoice_id": null, + "updated_at": "2024-09-09T16:04:04.278528938Z", + "business_id": null, + "custom_data": null, + "customer_id": "ctm_01j7br89shp7xpaenx22h8qetw", + "discount_id": null, + "receipt_data": null, + "currency_code": "USD", + "billing_period": { + "ends_at": "2024-11-09T16:01:29.378686Z", + "starts_at": "2024-10-09T16:01:29.378686Z" + }, + "invoice_number": null, + "billing_details": null, + "collection_mode": "automatic", + "subscription_id": "sub_01j7br9jpk4rmmcd5crn2th1bq" + }, + "event_id": "evt_01j7brea32v1fg5hzt0fdyc7x1", + "event_type": "transaction.past_due", + "occurred_at": "2024-09-09T16:04:04.834513Z", + "notification_id": "ntf_01j7breaj7w2vemrk7f92vmgbj" + }, + "extraHeaders": { + "paddle-version": "1" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddlebilling/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddlebilling", + "providerLabel": "Paddle Billing", + "eventType": "transaction.payment_failed", + "name": "transaction.payment_failed", + "body": { + "data": { + "id": "txn_01j7bre7fka75pa1k8arkf0by3", + "items": [ + { + "price": { + "id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "name": "Monthly (per seat)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 999, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-02-23T13:55:22.538367Z", + "product_id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "unit_price": { + "amount": "3000", + "currency_code": "USD" + }, + "updated_at": "2024-04-11T13:54:52.254748Z", + "custom_data": null, + "description": "Monthly", + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "price_id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "quantity": 10, + "proration": null + }, + { + "price": { + "id": "pri_01h1vjfevh5etwq3rb416a23h2", + "name": "Monthly (recurring addon)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 100, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-06-01T13:31:12.625056Z", + "product_id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "unit_price": { + "amount": "10000", + "currency_code": "USD" + }, + "updated_at": "2024-04-12T10:42:45.476453Z", + "custom_data": null, + "description": "Monthly", + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "price_id": "pri_01h1vjfevh5etwq3rb416a23h2", + "quantity": 1, + "proration": null + } + ], + "origin": "subscription_recurring", + "status": "billed", + "details": { + "totals": { + "fee": null, + "tax": "3549", + "total": "43549", + "credit": "0", + "balance": "43549", + "discount": "0", + "earnings": null, + "subtotal": "40000", + "grand_total": "43549", + "currency_code": "USD", + "credit_to_balance": "0" + }, + "line_items": [ + { + "id": "txnitm_01j7bre7vtfvxd0mvhr7s4x55h", + "totals": { + "tax": "2662", + "total": "32662", + "discount": "0", + "subtotal": "30000" + }, + "item_id": null, + "product": { + "id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "name": "AeroEdit Pro", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/bT1XUOJAQhOUxGs83cbk_pro.png", + "created_at": "2023-02-23T12:43:46.605Z", + "updated_at": "2024-04-05T15:53:44.687Z", + "custom_data": { + "features": { + "sso": false, + "route_planning": true, + "payment_by_invoice": false, + "aircraft_performance": true, + "compliance_monitoring": true, + "flight_log_management": true + }, + "suggested_addons": [ + "pro_01h1vjes1y163xfj1rh1tkfb65", + "pro_01gsz97mq9pa4fkyy0wqenepkz" + ], + "upgrade_description": "Move from Basic to Pro to take advantage of aircraft performance, advanced route planning, and compliance monitoring." + }, + "description": "Designed for professional pilots, including all features plus in Basic plus compliance monitoring, route optimization, and third-party integrations.", + "tax_category": "standard" + }, + "price_id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "quantity": 10, + "tax_rate": "0.08875", + "unit_totals": { + "tax": "266", + "total": "3266", + "discount": "0", + "subtotal": "3000" + } + }, + { + "id": "txnitm_01j7bre7vvfcpg0hjkgypbp002", + "totals": { + "tax": "887", + "total": "10887", + "discount": "0", + "subtotal": "10000" + }, + "item_id": null, + "product": { + "id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "name": "Analytics addon", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/97dRpA6SXzcE6ekK9CAr_analytics.png", + "created_at": "2023-06-01T13:30:50.302Z", + "updated_at": "2024-04-05T15:47:17.163Z", + "custom_data": null, + "description": "Unlock advanced insights into your flight data with enhanced analytics and reporting features. Includes customizable reporting templates and trend analysis across flights.", + "tax_category": "standard" + }, + "price_id": "pri_01h1vjfevh5etwq3rb416a23h2", + "quantity": 1, + "tax_rate": "0.08875", + "unit_totals": { + "tax": "887", + "total": "10887", + "discount": "0", + "subtotal": "10000" + } + } + ], + "payout_totals": null, + "tax_rates_used": [ + { + "totals": { + "tax": "3549", + "total": "43549", + "discount": "0", + "subtotal": "40000" + }, + "tax_rate": "0.08875" + } + ], + "adjusted_totals": { + "fee": "0", + "tax": "3549", + "total": "43549", + "earnings": "0", + "subtotal": "40000", + "grand_total": "43549", + "currency_code": "USD" + } + }, + "checkout": { + "url": "https://magnificent-entremet-7ae0c6.netlify.app/default/overlay?_ptxn=txn_01j7bre7fka75pa1k8arkf0by3" + }, + "payments": [ + { + "amount": "43549", + "status": "error", + "created_at": "2024-09-09T16:04:02.697051Z", + "error_code": "authentication_failed", + "captured_at": null, + "method_details": { + "card": { + "type": "visa", + "last4": "3184", + "expiry_year": 2029, + "expiry_month": 10, + "cardholder_name": "Michael McGovern" + }, + "type": "card" + }, + "payment_method_id": "paymtd_01j7br96tsmrhw9zef1dc0rmef", + "payment_attempt_id": "58c9c9a3-593c-428b-a2d4-cfc1603d4c36", + "stored_payment_method_id": "9708381b-bd52-4c65-abc0-705e636c5c47" + } + ], + "billed_at": "2024-09-09T16:04:02.163775Z", + "address_id": "add_01j7br89t3wgn5v5w49hm1mepv", + "created_at": "2024-09-09T16:04:02.613735Z", + "invoice_id": null, + "updated_at": "2024-09-09T16:04:02.613735Z", + "business_id": null, + "custom_data": null, + "customer_id": "ctm_01j7br89shp7xpaenx22h8qetw", + "discount_id": null, + "receipt_data": null, + "currency_code": "USD", + "billing_period": { + "ends_at": "2024-11-09T16:01:29.378686Z", + "starts_at": "2024-10-09T16:01:29.378686Z" + }, + "invoice_number": null, + "billing_details": null, + "collection_mode": "automatic", + "subscription_id": "sub_01j7br9jpk4rmmcd5crn2th1bq" + }, + "event_id": "evt_01j7brea33qcp49ab8c1n6q034", + "event_type": "transaction.payment_failed", + "occurred_at": "2024-09-09T16:04:04.835508Z", + "notification_id": "ntf_01j7breaj8kq311b1514ytt040" + }, + "extraHeaders": { + "paddle-version": "1" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddlebilling/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddlebilling", + "providerLabel": "Paddle Billing", + "eventType": "transaction.ready", + "name": "transaction.ready", + "body": { + "data": { + "id": "txn_01j7br78cgr083zqw1jhqym40s", + "items": [ + { + "price": { + "id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "name": "Monthly (per seat)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 999, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-02-23T13:55:22.538367Z", + "product_id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "unit_price": { + "amount": "3000", + "currency_code": "USD" + }, + "updated_at": "2024-04-11T13:54:52.254748Z", + "custom_data": null, + "description": "Monthly", + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "price_id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "quantity": 10, + "proration": null + }, + { + "price": { + "id": "pri_01h1vjfevh5etwq3rb416a23h2", + "name": "Monthly (recurring addon)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 100, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-06-01T13:31:12.625056Z", + "product_id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "unit_price": { + "amount": "10000", + "currency_code": "USD" + }, + "updated_at": "2024-04-12T10:42:45.476453Z", + "custom_data": null, + "description": "Monthly", + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "price_id": "pri_01h1vjfevh5etwq3rb416a23h2", + "quantity": 1, + "proration": null + }, + { + "price": { + "id": "pri_01gsz98e27ak2tyhexptwc58yk", + "name": "One-time addon", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 1, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-02-23T14:01:28.391712Z", + "product_id": "pro_01gsz97mq9pa4fkyy0wqenepkz", + "unit_price": { + "amount": "19900", + "currency_code": "USD" + }, + "updated_at": "2024-04-09T07:23:10.921392Z", + "custom_data": null, + "description": "One-time addon", + "trial_period": null, + "billing_cycle": null, + "unit_price_overrides": [] + }, + "price_id": "pri_01gsz98e27ak2tyhexptwc58yk", + "quantity": 1, + "proration": null + } + ], + "origin": "web", + "status": "ready", + "details": { + "totals": { + "fee": null, + "tax": "5315", + "total": "65215", + "credit": "0", + "balance": "65215", + "discount": "0", + "earnings": null, + "subtotal": "59900", + "grand_total": "65215", + "currency_code": "USD", + "credit_to_balance": "0" + }, + "line_items": [ + { + "id": "txnitm_01j7br8aeyz8r6wm2zywjw97d4", + "totals": { + "tax": "2662", + "total": "32662", + "discount": "0", + "subtotal": "30000" + }, + "item_id": null, + "product": { + "id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "name": "AeroEdit Pro", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/bT1XUOJAQhOUxGs83cbk_pro.png", + "created_at": "2023-02-23T12:43:46.605Z", + "updated_at": "2024-04-05T15:53:44.687Z", + "custom_data": { + "features": { + "sso": false, + "route_planning": true, + "payment_by_invoice": false, + "aircraft_performance": true, + "compliance_monitoring": true, + "flight_log_management": true + }, + "suggested_addons": [ + "pro_01h1vjes1y163xfj1rh1tkfb65", + "pro_01gsz97mq9pa4fkyy0wqenepkz" + ], + "upgrade_description": "Move from Basic to Pro to take advantage of aircraft performance, advanced route planning, and compliance monitoring." + }, + "description": "Designed for professional pilots, including all features plus in Basic plus compliance monitoring, route optimization, and third-party integrations.", + "tax_category": "standard" + }, + "price_id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "quantity": 10, + "tax_rate": "0.08875", + "unit_totals": { + "tax": "266", + "total": "3266", + "discount": "0", + "subtotal": "3000" + } + }, + { + "id": "txnitm_01j7br8aez018mgmrj3bpmxmh7", + "totals": { + "tax": "887", + "total": "10887", + "discount": "0", + "subtotal": "10000" + }, + "item_id": null, + "product": { + "id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "name": "Analytics addon", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/97dRpA6SXzcE6ekK9CAr_analytics.png", + "created_at": "2023-06-01T13:30:50.302Z", + "updated_at": "2024-04-05T15:47:17.163Z", + "custom_data": null, + "description": "Unlock advanced insights into your flight data with enhanced analytics and reporting features. Includes customizable reporting templates and trend analysis across flights.", + "tax_category": "standard" + }, + "price_id": "pri_01h1vjfevh5etwq3rb416a23h2", + "quantity": 1, + "tax_rate": "0.08875", + "unit_totals": { + "tax": "887", + "total": "10887", + "discount": "0", + "subtotal": "10000" + } + }, + { + "id": "txnitm_01j7br8aez018mgmrj3e9nbbr7", + "totals": { + "tax": "1766", + "total": "21666", + "discount": "0", + "subtotal": "19900" + }, + "item_id": null, + "product": { + "id": "pro_01gsz97mq9pa4fkyy0wqenepkz", + "name": "Custom domains", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/XIG7UXoJQHmlIAiKcnkA_custom-domains.png", + "created_at": "2023-02-23T14:01:02.441Z", + "updated_at": "2024-04-05T15:43:28.971Z", + "custom_data": null, + "description": "Make AeroEdit truly your own with custom domains. Custom domains reinforce your brand identity and make it easy for your team to access your account.", + "tax_category": "standard" + }, + "price_id": "pri_01gsz98e27ak2tyhexptwc58yk", + "quantity": 1, + "tax_rate": "0.08875", + "unit_totals": { + "tax": "1766", + "total": "21666", + "discount": "0", + "subtotal": "19900" + } + } + ], + "payout_totals": null, + "tax_rates_used": [ + { + "totals": { + "tax": "5315", + "total": "65215", + "discount": "0", + "subtotal": "59900" + }, + "tax_rate": "0.08875" + } + ], + "adjusted_totals": { + "fee": "0", + "tax": "5315", + "total": "65215", + "earnings": "0", + "subtotal": "59900", + "grand_total": "65215", + "currency_code": "USD" + } + }, + "checkout": { + "url": "https://magnificent-entremet-7ae0c6.netlify.app/default/overlay?_ptxn=txn_01j7br78cgr083zqw1jhqym40s" + }, + "payments": [], + "billed_at": null, + "address_id": "add_01j7br89t3wgn5v5w49hm1mepv", + "created_at": "2024-09-09T16:00:13.888989Z", + "invoice_id": null, + "updated_at": "2024-09-09T16:00:48.609971244Z", + "business_id": null, + "custom_data": null, + "customer_id": "ctm_01j7br89shp7xpaenx22h8qetw", + "discount_id": null, + "receipt_data": null, + "currency_code": "USD", + "billing_period": null, + "invoice_number": null, + "billing_details": null, + "collection_mode": "automatic", + "subscription_id": null + }, + "event_id": "evt_01j7br8avv2rjxwfgkqt26232e", + "event_type": "transaction.ready", + "occurred_at": "2024-09-09T16:00:49.019800Z", + "notification_id": "ntf_01j7br8b12tc3egc4ej67je35y" + }, + "extraHeaders": { + "paddle-version": "1" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddlebilling/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddlebilling", + "providerLabel": "Paddle Billing", + "eventType": "transaction.updated", + "name": "transaction.updated", + "body": { + "data": { + "id": "txn_01j7br78cgr083zqw1jhqym40s", + "items": [ + { + "price": { + "id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "name": "Monthly (per seat)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 999, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-02-23T13:55:22.538367Z", + "product_id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "unit_price": { + "amount": "3000", + "currency_code": "USD" + }, + "updated_at": "2024-04-11T13:54:52.254748Z", + "custom_data": null, + "description": "Monthly", + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "price_id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "quantity": 10, + "proration": null + }, + { + "price": { + "id": "pri_01h1vjfevh5etwq3rb416a23h2", + "name": "Monthly (recurring addon)", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 100, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-06-01T13:31:12.625056Z", + "product_id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "unit_price": { + "amount": "10000", + "currency_code": "USD" + }, + "updated_at": "2024-04-12T10:42:45.476453Z", + "custom_data": null, + "description": "Monthly", + "trial_period": null, + "billing_cycle": { + "interval": "month", + "frequency": 1 + }, + "unit_price_overrides": [] + }, + "price_id": "pri_01h1vjfevh5etwq3rb416a23h2", + "quantity": 1, + "proration": null + }, + { + "price": { + "id": "pri_01gsz98e27ak2tyhexptwc58yk", + "name": "One-time addon", + "type": "standard", + "status": "active", + "quantity": { + "maximum": 1, + "minimum": 1 + }, + "tax_mode": "account_setting", + "created_at": "2023-02-23T14:01:28.391712Z", + "product_id": "pro_01gsz97mq9pa4fkyy0wqenepkz", + "unit_price": { + "amount": "19900", + "currency_code": "USD" + }, + "updated_at": "2024-04-09T07:23:10.921392Z", + "custom_data": null, + "description": "One-time addon", + "trial_period": null, + "billing_cycle": null, + "unit_price_overrides": [] + }, + "price_id": "pri_01gsz98e27ak2tyhexptwc58yk", + "quantity": 1, + "proration": null + } + ], + "origin": "web", + "status": "completed", + "details": { + "totals": { + "fee": "3311", + "tax": "5315", + "total": "65215", + "credit": "0", + "balance": "0", + "discount": "0", + "earnings": "56589", + "subtotal": "59900", + "grand_total": "65215", + "currency_code": "USD", + "credit_to_balance": "0" + }, + "line_items": [ + { + "id": "txnitm_01j7br8aeyz8r6wm2zywjw97d4", + "totals": { + "tax": "2662", + "total": "32662", + "discount": "0", + "subtotal": "30000" + }, + "item_id": null, + "product": { + "id": "pro_01gsz4t5hdjse780zja8vvr7jg", + "name": "AeroEdit Pro", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/bT1XUOJAQhOUxGs83cbk_pro.png", + "created_at": "2023-02-23T12:43:46.605Z", + "updated_at": "2024-04-05T15:53:44.687Z", + "custom_data": { + "features": { + "sso": false, + "route_planning": true, + "payment_by_invoice": false, + "aircraft_performance": true, + "compliance_monitoring": true, + "flight_log_management": true + }, + "suggested_addons": [ + "pro_01h1vjes1y163xfj1rh1tkfb65", + "pro_01gsz97mq9pa4fkyy0wqenepkz" + ], + "upgrade_description": "Move from Basic to Pro to take advantage of aircraft performance, advanced route planning, and compliance monitoring." + }, + "description": "Designed for professional pilots, including all features plus in Basic plus compliance monitoring, route optimization, and third-party integrations.", + "tax_category": "standard" + }, + "price_id": "pri_01gsz8x8sawmvhz1pv30nge1ke", + "quantity": 10, + "tax_rate": "0.08875", + "unit_totals": { + "tax": "266", + "total": "3266", + "discount": "0", + "subtotal": "3000" + } + }, + { + "id": "txnitm_01j7br8aez018mgmrj3bpmxmh7", + "totals": { + "tax": "887", + "total": "10887", + "discount": "0", + "subtotal": "10000" + }, + "item_id": null, + "product": { + "id": "pro_01h1vjes1y163xfj1rh1tkfb65", + "name": "Analytics addon", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/97dRpA6SXzcE6ekK9CAr_analytics.png", + "created_at": "2023-06-01T13:30:50.302Z", + "updated_at": "2024-04-05T15:47:17.163Z", + "custom_data": null, + "description": "Unlock advanced insights into your flight data with enhanced analytics and reporting features. Includes customizable reporting templates and trend analysis across flights.", + "tax_category": "standard" + }, + "price_id": "pri_01h1vjfevh5etwq3rb416a23h2", + "quantity": 1, + "tax_rate": "0.08875", + "unit_totals": { + "tax": "887", + "total": "10887", + "discount": "0", + "subtotal": "10000" + } + }, + { + "id": "txnitm_01j7br8aez018mgmrj3e9nbbr7", + "totals": { + "tax": "1766", + "total": "21666", + "discount": "0", + "subtotal": "19900" + }, + "item_id": null, + "product": { + "id": "pro_01gsz97mq9pa4fkyy0wqenepkz", + "name": "Custom domains", + "type": "standard", + "status": "active", + "image_url": "https://paddle.s3.amazonaws.com/user/165798/XIG7UXoJQHmlIAiKcnkA_custom-domains.png", + "created_at": "2023-02-23T14:01:02.441Z", + "updated_at": "2024-04-05T15:43:28.971Z", + "custom_data": null, + "description": "Make AeroEdit truly your own with custom domains. Custom domains reinforce your brand identity and make it easy for your team to access your account.", + "tax_category": "standard" + }, + "price_id": "pri_01gsz98e27ak2tyhexptwc58yk", + "quantity": 1, + "tax_rate": "0.08875", + "unit_totals": { + "tax": "1766", + "total": "21666", + "discount": "0", + "subtotal": "19900" + } + } + ], + "payout_totals": { + "fee": "3311", + "tax": "5315", + "total": "65215", + "credit": "0", + "balance": "0", + "discount": "0", + "earnings": "56589", + "fee_rate": "0.05", + "subtotal": "59900", + "grand_total": "65215", + "currency_code": "USD", + "exchange_rate": "1", + "credit_to_balance": "0" + }, + "tax_rates_used": [ + { + "totals": { + "tax": "5315", + "total": "65215", + "discount": "0", + "subtotal": "59900" + }, + "tax_rate": "0.08875" + } + ], + "adjusted_totals": { + "fee": "3311", + "tax": "5315", + "total": "65215", + "earnings": "56589", + "subtotal": "59900", + "grand_total": "65215", + "currency_code": "USD" + } + }, + "checkout": { + "url": "https://magnificent-entremet-7ae0c6.netlify.app/default/overlay?_ptxn=txn_01j7br78cgr083zqw1jhqym40s" + }, + "payments": [ + { + "amount": "65215", + "status": "captured", + "created_at": "2024-09-09T16:01:17.683756Z", + "error_code": null, + "captured_at": "2024-09-09T16:01:29.378686Z", + "method_details": { + "card": { + "type": "visa", + "last4": "3184", + "expiry_year": 2029, + "expiry_month": 10, + "cardholder_name": "Michael McGovern" + }, + "type": "card" + }, + "payment_method_id": "paymtd_01j7br96tsmrhw9zef1dc0rmef", + "payment_attempt_id": "01e1c65b-3eca-4fca-b160-f162d641852e", + "stored_payment_method_id": "9708381b-bd52-4c65-abc0-705e636c5c47" + } + ], + "billed_at": "2024-09-09T16:01:29.536914Z", + "address_id": "add_01j7br89t3wgn5v5w49hm1mepv", + "created_at": "2024-09-09T16:00:13.888989Z", + "invoice_id": "inv_01j7br9jq8k57phrbkyehe9kwj", + "updated_at": "2024-09-09T16:01:31.565947359Z", + "business_id": null, + "custom_data": null, + "customer_id": "ctm_01j7br89shp7xpaenx22h8qetw", + "discount_id": null, + "receipt_data": null, + "currency_code": "USD", + "billing_period": { + "ends_at": "2024-10-09T16:01:29.378686Z", + "starts_at": "2024-09-09T16:01:29.378686Z" + }, + "invoice_number": "325-11889", + "billing_details": null, + "collection_mode": "automatic", + "subscription_id": "sub_01j7br9jpk4rmmcd5crn2th1bq" + }, + "event_id": "evt_01j7br9myygc09mszbeh4d808h", + "event_type": "transaction.updated", + "occurred_at": "2024-09-09T16:01:32.126219Z", + "notification_id": "ntf_01j7br9n5mk8wgrdcfj6y03xey" + }, + "extraHeaders": { + "paddle-version": "1" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddlebilling/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddleclassic", + "providerLabel": "Paddle Classic", + "eventType": "payment_dispute_closed", + "name": "payment_dispute_closed", + "body": { + "alert_id": "1467192647", + "alert_name": "payment_dispute_closed", + "amount": "689.54", + "balance_amount": "89.39", + "balance_currency": "EUR", + "balance_fee": "362.12", + "checkout_id": "9-f7f8b847ac4908e-89357ebf6e", + "currency": "EUR", + "custom_data": "custom_data", + "email": "qmohr@example.net", + "event_time": "2023-03-08 09:33:44", + "fee_usd": "0.51", + "marketing_consent": "", + "order_id": "9", + "passthrough": "Example String", + "status": "closed", + "p_signature": "G6+OBImxbqBSJm0FJX2fpJYNxsm/gzS9qcdtYmy560f5a2GEzS600AI0l2/hIel8i3AaqytYUMxxr7XfI/ixY/sxQ/lMd7LTqnEfMIhQ1aJ+iEjbtKdceEEXiCcVwSZt3ACKv8Ce+CZDHmVy/ebdEFCwwalucqZH6mMyMqXRBzLs/5K1c6JIvIz65vktfJWJuuNvu55Bu0oydHLAV8tJYgDQ08hjW19ZuaLpi05zz+VFYGh2txz6t/lnhInQKg9rv+0gvIDTFi9LJn79dqx6O2MjSiBhmSLEadk3Z9+9PT/6AUGJEiCZmZ54bimddDRmXMQLcNFRb5StVyIGgYlIXHtJQPg3OzHFxfCMiuSim29Rxb7ILJlBWar655Ctcjw3aSK97W/lB/60uZADslRduxMANDtOw+KQrvWLcLTL7ymTx1WN5c43U6sjglWVU3lpVMlV/1p/xJAFTmoF/9NvXSZsI4Zmxq5QKF8FfbpLMud3veMLzwUeiiVmlMPLbKZDq2smu1gEKwQ+jPrFytb+ssa6T+NXcwsWDSyLhZ/eFZ+bYpei9AO2mwHYHB5LEiVodP+U7VIz/Ksh4r5Y8DjXizD0qIWOAg62QtFMnUkWgKqIk3UccPjXtfovBrjcC5/RAhPyilK//cEoYf3kDUQfqiwP/7xfaYNhNeGTZ5+NRWE=" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddleclassic/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddleclassic", + "providerLabel": "Paddle Classic", + "eventType": "payment_dispute_created", + "name": "payment_dispute_created", + "body": { + "alert_id": "649721479", + "alert_name": "payment_dispute_created", + "amount": "473.05", + "balance_amount": "452.78", + "balance_currency": "EUR", + "balance_fee": "424.33", + "checkout_id": "9-f4ddb7a7b26753e-34bf2cbd3d", + "currency": "USD", + "custom_data": "custom_data", + "email": "roberts.rashawn@example.com", + "event_time": "2023-03-08 09:33:29", + "fee_usd": "0.46", + "marketing_consent": "1", + "order_id": "2", + "passthrough": "Example String", + "status": "open", + "p_signature": "lBTh/VODf1bK7lyyLFeZzGv0MaLsjV8iSN5gT/KFktMfAbRnaVqzfxteXYDotxYRnJj7h1mJ4KvtAtEjQv9ce31G7F0ywvUaQjUUz5KR3SzXGN0E+BKb7jI57+m4au6YVX+DepEUlEig34iEMkow4bAhSNhbvxu5WV4PxK9s1WhLr8FnmHPhwY+jLEb3HYUXtvw0vZPnjmh7Szm3LlonCL0yriokOYVzhvlrVUeMXqfWSxzfeR0pivGJFZn6xUjvj2kB/bFLjAssGZfFOAr0IRqVStNx7s2WB6UGGLex8/tCxawQ4cNo1MeXvrO4SFlUUy12KxZZGt2jgMbBBJCrXlUFoCZYctX+2/1JH0mialpS0R69uQSOJTAHTaDPOvkxPvSrycPcdS5IF3R+1dpM5ATDaPukSolKkWJ6Z60W6AEVKWEXIe1HS8tx/v+YlGKA0UNrk4gTxNjIQOLjYu/nxQoKYwDEge5g5bI347vzH+4uAgEPpLwRadct7fGIPnPiSkYgiACO5estaelG8uhuggfoagGLzTFcWzF7T5vzl7BbwsHh5JrgqGiw5zFhwoL12XRvX64XoVGPkEvxwQBQeXYxud1CwK0my0X1ggFT9a3EdDUd/kqRAg+95UaLMC85CxpJIpij6qeiEfDUBZqUD/ICEStae17vXFl9LYmVFR8=" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddleclassic/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddleclassic", + "providerLabel": "Paddle Classic", + "eventType": "payment_refunded", + "name": "payment_refunded", + "body": { + "alert_id": "1873493642", + "alert_name": "payment_refunded", + "amount": "950.51", + "balance_currency": "GBP", + "balance_earnings_decrease": "0.97", + "balance_fee_refund": "0.94", + "balance_gross_refund": "0.48", + "balance_tax_refund": "0.18", + "checkout_id": "5-53dfe7eaa37a4c3-8009830048", + "currency": "USD", + "custom_data": "custom_data", + "earnings_decrease": "0.36", + "email": "kenyatta.goyette@example.org", + "event_time": "2023-03-08 09:33:02", + "fee_refund": "0.21", + "gross_refund": "0.81", + "marketing_consent": "", + "order_id": "3", + "passthrough": "Example String", + "quantity": "97", + "refund_reason": "refund_reason", + "refund_type": "vat", + "tax_refund": "0.71", + "p_signature": "b7UedYpNmqIUsqMDB/3jeOJ1jaXWrnxQPBci51xBNtoaN+jUcu2pD419yk3cijlwv0fpWrr/rJaiszPTFUS2jn0ILvoAf9x5YO22STPp8QeI57iRa8EPOaBaAAC6gk9j4/SUjpIMPUZnaQRQ6OvA9krmJJB2pqDC1JIidn6XBJvQT1/XQ8fj/9HJ9DqM0HwuSMLiApyMrLOwnCijuAowC53TFulj7A8pGsBrtWe/BajHCG25XCPPKKfvzw2pP2/pQcAz/IxO19WII8Z39Iv8hA0QU7482oGPhGuIPj2T0O2NMyUxCbD51e2+5aFM5pBrUJpRtAppcbG0f/6o7A44A/EqpKz1aPtkvGhQxgTXIFRXoreeqzQAd8QmWB7O/0lUP8ITU2Gq30Sr5OXbrEuRRO4dk+mVMUQY+vmba5acce5oqdpxMeL9zPoE6xX5nCppoIy0MSmGMAMrMtoPTrqlq95jVG28hdltBUeR8n3qGrXk1sAw9PGgrKqxOK2ADFXYMwGkr9qoQ/OCj0zF0dWL1+rfHbUVPIJ9XpJezDRmzYXckwWOtBmVis7tkKQ11twl4LAjux48oXuKGkQB5cD3k1cfnrDd2yKxZHLRGWhEat1F3lrRbD6N3ovg4LWdhuuJamceysWdJ7NTfJhaj7xaossrxmLsUOmkVwgbN6bVLUw=" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddleclassic/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddleclassic", + "providerLabel": "Paddle Classic", + "eventType": "payment_succeeded", + "name": "payment_succeeded", + "body": { + "alert_id": "1367177662", + "alert_name": "payment_succeeded", + "balance_currency": "GBP", + "balance_earnings": "344.14", + "balance_fee": "779.32", + "balance_gross": "981.69", + "balance_tax": "431.73", + "checkout_id": "7-9540155b027c1b9-20dddece1e", + "country": "GB", + "coupon": "Coupon 9", + "currency": "GBP", + "custom_data": "custom_data", + "customer_name": "customer_name", + "earnings": "29", + "email": "gschimmel@example.org", + "event_time": "2023-03-08 09:32:47", + "fee": "0.59", + "ip": "23.220.176.0", + "marketing_consent": "", + "order_id": "4", + "passthrough": "Example String", + "payment_method": "card", + "payment_tax": "0.21", + "product_id": "1", + "product_name": "Example String", + "quantity": "53", + "receipt_url": "https://sandbox-my.paddle.com/receipt/4/7095047bfd65d79-0244b6e48e", + "sale_gross": "912.59", + "used_price_override": "", + "p_signature": "YIyoeN7qz8JbgLZv9yFcYNjtob0zz+WRrBypgmesHPXtqGpJzYBZit4cCQ/259D1vArAGoGqytf4a17ePOuJBYSfZJXwNVxbOTYFBVMu7J38VeCMu3XhdIBTXopHIpbfnrXrP953J3dUOdpdJ/DvQLsA4Dg9FFld6Eapg6o9RXNHE910HgBjTDY8UJnt7lHTU3IQiyCKejhC8EHKrM6PV1KMTbUmgte71jT+tBBJvILb7JBxSOokYbH8as9lsJUho9HnI1lcxoVj0p6Osyw2Vq8NnMoPNaOFH3hDp4HQlRqXVbRyePOtP6OL78g02W2KHYJNGdXJ1XJ9qhnwrk+jo3whl5p8kwlSK02sSFQSniCCGNDnon18WmXgluGN3xlzpU4R3unueEbice8pL6FBKbYePvCEHVoBno0PubTdu1YOEWxosuBQtXxiuhtGGW6xhMHlPsjceSBkAoVJmCkpg5Ft9hpUfxsMGgTqV9WS3H4FGdzrd+hVuYcalAye2pS0rPMKUqxo6HQD8W+bFpj3/hhyDhPyQ4Qi0DOgvyvkokvwHHzg01tXlJoquLWGfNs8u22dEwG3T7hPEN1wEINkcBvmiXCi+yr02r1JUawBsm02qhZWiuhRCkw0HHJOU5HEeUo66ofu1lJc3DH/7ultO8MGhaL5FOepwjR2CxEvNpk=" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddleclassic/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddleclassic", + "providerLabel": "Paddle Classic", + "eventType": "subscription_created", + "name": "subscription_created", + "body": { + "alert_id": "485191415", + "alert_name": "subscription_created", + "cancel_url": "https://sandbox-checkout.paddle.com/subscription/cancel?user=6&subscription=9&hash=d3c456f38ed984f0c2fdd2558d02fbf78ab02a09", + "checkout_id": "6-3d5b947be235b14-9940a81df2", + "currency": "GBP", + "custom_data": "custom_data", + "email": "mann.meggie@example.com", + "event_time": "2023-03-08 09:34:33", + "linked_subscriptions": "5, 6, 3", + "marketing_consent": "1", + "next_bill_date": "2023-03-19", + "passthrough": "Example String", + "quantity": "47", + "source": "Trial", + "status": "active", + "subscription_id": "8", + "subscription_plan_id": "1", + "unit_price": "unit_price", + "update_url": "https://sandbox-checkout.paddle.com/subscription/update?user=6&subscription=3&hash=4a69274867e85e38ede73bee41f4ab98d0734b38", + "user_id": "3", + "p_signature": "JFhLdx5pUvZbBzIh2xxOR0E6OiBNCAkoeGpDNhy+Ji1hiXz/4RyJsKr6R9zSR1qNYWv0rtCpzmclY6knu+EcVQyY/napZ2Jgu2leKhzbMW3087T2WIWXODbIH4n5oJQj8tdAe3olyO7C6AoRCGIR7vKK8OQkgeAGfsdnTyc37G4va6WjNlDBTNcuuxKsRriHPg6AOOFpWwsUAhaPSmRQdGYzi3zu5N+1zgSOn7eYZJ1rF4xf4A0P60BsmQutZ82cdiSVsYNo8ErNSnC+1SgLRKp84oyhl5wcUZRqLwqbApOQv+3o6FsdGP1aRBGUaMcJG9P+svVCwGMdS7wM+DknFQFwlvxaYevMADRgObnaDbERWyjtiOPB0ZCkSfy/OW+ujZa4DZodFaSc6QYfRzFjiuMyGLg91GjxtAfoSVlZ8y5TpSe462pqZCbc0uC3JarIrdqWvMKwufp4TijKzPg60BASr27Ou0B6eGFxL+gUCAEQOoImU1bXz5XsKYumAEaGXzMFuMnIyKNkre07QoJeQcsDzAuBWUeqhGy/7Tz2TJrhtB6js14hzs0fQkw2d1qPwp20kViEiSNQE1dC1W/LTrj45qE3NaP8GeF9lt8vLDFAWRj2kVVJf/wJS2NPyTzi0S136SYafQmkeR2JwCIUb3CyfVZ1rnPji2FhPK54go8=" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddleclassic/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddleclassic", + "providerLabel": "Paddle Classic", + "eventType": "subscription_updated", + "name": "subscription_updated", + "body": { + "alert_id": "1353619852", + "alert_name": "subscription_updated", + "cancel_url": "https://sandbox-checkout.paddle.com/subscription/cancel?user=4&subscription=3&hash=a169da876ee69cac5162a66ac63aceb786df1396", + "checkout_id": "3-d01f3e81f7de2f9-550b417f5a", + "currency": "USD", + "custom_data": "custom_data", + "email": "tzieme@example.org", + "event_time": "2023-03-08 09:34:55", + "linked_subscriptions": "3, 4, 8", + "marketing_consent": "1", + "new_price": "new_price", + "new_quantity": "new_quantity", + "new_unit_price": "new_unit_price", + "next_bill_date": "2023-04-01", + "old_next_bill_date": "old_next_bill_date", + "old_price": "old_price", + "old_quantity": "old_quantity", + "old_status": "old_status", + "old_subscription_plan_id": "old_subscription_plan_id", + "old_unit_price": "old_unit_price", + "passthrough": "Example String", + "status": "active", + "subscription_id": "6", + "subscription_plan_id": "7", + "update_url": "https://sandbox-checkout.paddle.com/subscription/update?user=8&subscription=1&hash=9f34f16c64700116c2a0916a40e2646f6c70d103", + "user_id": "8", + "p_signature": "PGvjvycOAxdR77IDY/17wIN0Zy2sLZS+qLI/SjFFsGIY2OkRC/BSaDS2525O1AXZMNmJIz7TDQTL0laxySm4fSgZiwkq0fP2w14NUs+TkoMnxfaGpMG/VGVnzrfhOm3qK2+iiSVSzj0EeT1UQyQd51p0Ud/EftDPZE3Gk8Nyu1hKBN2VZyzeilNwoQy1DcJFrVfIkY4FFl4JwIeAVhr36F8yGOBRnOKTStopueKq11sq+B3pFgPTpPmqCrMkB4fM/+E5HEEqhvhAclh/7NbvHZ0Fd5o1x/67GUV+u20A3t6ufmNZkxu1qboZ49fuZiOdFNFd4Mlpq5Mw7ZogUnGVoT66Pri/VnYztzwzxiC5ZcVj15S/TCKAbH8GI2zN3C8KMM0Xq5bY2Al5rt8I49qE5E5VSS1KUs7gqVJP+AcaftP/xQID5g3FhqpYJmGi4np9tgKC95oVsmYlkZUcjdgud+mY9AHQLhfDhnst52pip19tASwhsDVaBV11YRWeRBxbMZ/zuniFFVqcdAFrXwZhMDZl4lEXBqH30Hizx3b+MVKUHMsQMmv1MqxmVyNd0SIYPcbm5jiw0tTv1fhZsGrI1VU9GnaqdNffbOc7Eik98cc8KBmQJSsCgNVihnW4K2hkOD6+0jpLeT4JwNx4y901yWpD1aodX0zqCkeNJIYKF9Q=" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddleclassic/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddleclassic", + "providerLabel": "Paddle Classic", + "eventType": "transfer_created", + "name": "transfer_created", + "body": { + "alert_id": "853473778", + "alert_name": "transfer_created", + "amount": "497.14", + "currency": "EUR", + "event_time": "2023-03-08 09:34:07", + "payout_id": "5", + "status": "unpaid", + "p_signature": "mmrkP43GZW0VzLHhS//oBwxExqJlBU97+QcFCm8DQEl3okYjqrbPCLgIeSwBjhfrQafB4oHEVoLpDf5TOStdUs1ZVhiJdqi3Wz+ftakYsmTSbSiQVy17RqYWBxlD2yn77SdKCGgx51TELiKLrSimxSxo0ypfozAjfhva3Oq8h2rRuGkUudDVIXk1/3yJYJO7xqjuAp0VONOstLNbJspV1uQ1Rq79jNnqmGY+7CEJlbA6MdF2M/8C2FjCD/rQEg/IsQYQDgiGKc0GmDHYcbglIOXIVG8mJctIPCzG+OS8dsGR22Fn8hHtCS59dc8xAtXrTUwlAFySueQqQgs4Uk87tyToqjwjpzwiUCw0s+Vs2bklOrnyQiet+XDIUctz4BWjg6am9ar+Pjb+XktwjwCM2sgGbPt6714j70OEhiNbR7r9c2Fo+sjTM/Br3vMW8q1Q+zzJ4Akfoo2tFCf4FcL82p3217/ZEhDCd/peg3bHake6QfqHlVDxMGOXWE8iYWpNkrC110VVnjwhKE8Ap/Xh3jVYurS7tTAQMosmD3UlLMjls4ydJWTvQJHGeqoGw/b3BU+OrzMXsWAwP+Q4uV03+T9pwxED50a/lh8dvkVitOUSgZxeGjKA+wtkqPmNBOldss3/TG5FjebzZwn3rp4BnHaFS2nG24ax5PoR4DK9X54=" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddleclassic/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paddleclassic", + "providerLabel": "Paddle Classic", + "eventType": "transfer_paid", + "name": "transfer_paid", + "body": { + "alert_id": "839474484", + "alert_name": "transfer_paid", + "amount": "737.03", + "currency": "EUR", + "event_time": "2023-03-08 09:34:20", + "payout_id": "7", + "status": "paid", + "p_signature": "SjONPgXtEkNg+6Nm6H9XZ6ayEKRUcRpNdfFn3JsGR/fbJu3/jNOKbOXeNGyuy07YwaQtuI1VGu2pBvqnHFQTYNXhr63xx9XnAuYy386+6EYit2NQ1hZt0C0mvfk0gEuByIeUQPYkzxUC6OB9vcjKYn3F6O9/e8Wz8CuM5U6bntheE2iXKKNum15rHPEikaPzZhEBObBWvUAZRflWdYzCqqpcysnkMJrOHWDaJFy0PQzL4sE0unlYentyq7MwXdY+KIY8u5yAJ1YCOp+IvCZJIvUnmiFiHEvhRVwEXC+KMpVGEZ6Fv+O8vyY9Xn4QU87arS6425RKbr4gZLxZ6RFnemSqU8+ZabpLgCjswKASsUP4DVwqitKZejo09AL+Ck3ubQ7aUrl4SzpgVvBe7h411XelQPey1yY89Dkd6S222h4TMwf5xxGS/owcfN8u6QSqVoBShICTc4uQw8ju+J7QMES1MvMMGQ14xhv5o6OA+vy5GuHvOzSB4JC6MYfQneBOq+FiU+WwDiZ1LAnluSdIRI4hl8HMWQQYW94g6X9eJFSZ7pVisXUxijDRQR7vD+sYvzCHDBiEiAbTaPB6pxiO/WDtC7omX06qKOEMIEEWsCt0VwUrj3uk5s6t8AZ9y4zJWM1vOERC9cpntyZkdV2l5pfHZqdF09f7zeWL0Q6DrWY=" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paddleclassic/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "BILLING.PLAN.ACTIVATED", + "name": "BILLING.PLAN.ACTIVATED", + "body": { + "id": "WH-55TG7562XN2588878-8YH955435R661687G", + "create_time": "2018-19-12T22:20:32.000Z", + "event_type": "BILLING.PLAN.ACTIVATED", + "event_version": "1.0", + "resource_type": "plan", + "resource_version": "2.0", + "summary": "A billing plan was activated.", + "resource": { + "id": "P-7GL4271244454362WXNWU5NQ", + "product_id": "PROD-XXCD1234QWER65782", + "name": "Zoho Marketing Campaign Plan", + "description": "Zoho Marketing Campaign Plan", + "status": "ACTIVE", + "usage_type": "LICENSED", + "billing_cycles": [ + { + "frequency": { + "interval_unit": "MONTH", + "interval_count": 1 + }, + "tenure_type": "TRIAL", + "sequence": 1, + "total_cycles": 1, + "pricing_scheme": { + "fixed_price": { + "value": "50", + "currency_code": "USD" + }, + "tier_mode": "VOLUME", + "tiers": [ + { + "starting_quantity": "1", + "ending_quantity": "1000", + "amount": { + "value": "100", + "currency_code": "USD" + } + }, + { + "starting_quantity": "1001", + "amount": { + "value": "200", + "currency_code": "USD" + } + } + ] + } + }, + { + "frequency": { + "interval_unit": "MONTH", + "interval_count": 1 + }, + "tenure_type": "REGULAR", + "sequence": 2, + "total_cycles": 12, + "pricing_scheme": { + "fixed_price": { + "value": "100", + "currency_code": "USD" + }, + "tier_mode": "VOLUME", + "tiers": [ + { + "starting_quantity": "1", + "ending_quantity": "1000", + "amount": { + "value": "300", + "currency_code": "USD" + } + }, + { + "starting_quantity": "1001", + "amount": { + "value": "1000", + "currency_code": "USD" + } + } + ] + } + } + ], + "payment_preferences": { + "service_type": "PREPAID", + "auto_bill_outstanding": true, + "setup_fee": { + "value": "10", + "currency_code": "USD" + }, + "setup_fee_failure_action": "CONTINUE", + "payment_failure_threshold": 3 + }, + "taxes": { + "percentage": "10", + "inclusive": false + }, + "create_time": "2018-12-10T21:20:49Z", + "update_time": "2018-12-10T21:20:49Z", + "links": [ + { + "href": "https://api.paypal.com/v1/billing/plans/P-5ML4271244454362WXNWU5NQ", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/billing/plans/P-5ML4271244454362WXNWU5NQ", + "rel": "edit", + "method": "PATCH" + } + ] + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-55TG7562XN2588878-8YH955435R661687G", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-55TG7562XN2588878-8YH955435R661687G/resend", + "rel": "resend", + "method": "POST" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e0b2fe*Host=ccg14amqunphttpdeliveryd57", + "client_pid": "763685", + "correlation-id": "8304c2d1f9b20", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "e068dce0-ba1d-11ed-8635-fb7d730e54d3", + "paypal-transmission-sig": "fblPC+IWjjdLepncpX86WCHQij9XMQaA7zmgA62vmoc7DIR4ThIBIkZ349YetJFKQuBKi26uBJeKD7RroEC2UQ1KzIVUdZVwUTDfkyIk+lRzYjz0Tau0vgQHKHovZ1aiAKTA96jb2kPW5zATHBZzP30Yhca2ICcNCA2a71fIzITVdgK/q4yJkvbcnfdWu3CHi1vnKA+X0rXQUIH9O8+Bo51cSdXBBIWvvsd5ghqK1N2Ex0yC4H4R/2PQNOnNJpzXseaJzcmoumaH5LbtaOfX6WvJB6xS5cApNPQ46l7+anfRD72fDcon/bZA4vn25JtMOeY+j3eucOhdUgkwekQZWw==", + "paypal-transmission-time": "2023-03-03T23:48:20Z", + "x-b3-spanid": "2ae3db9ba6f04589" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "BILLING.PLAN.CREATED", + "name": "BILLING.PLAN.CREATED", + "body": { + "id": "WH-2N1678257S892762B-8MC99539P4557624Y", + "event_version": "1.0", + "create_time": "2016-04-28T11:21:44Z", + "resource_type": "plan", + "event_type": "BILLING.PLAN.CREATED", + "summary": "A billing plan was created", + "resource": { + "id": "P-7LT50814996943336LSNDODY", + "merchant_preferences": { + "setup_fee": { + "currency": "USD", + "value": "1" + }, + "return_url": "http://www.paypal.com", + "cancel_url": "http://www.yahoo.com", + "auto_bill_amount": "YES", + "initial_fail_amount_action": "CONTINUE", + "max_fail_attempts": "21" + }, + "update_time": "2016-04-28T11:21:31.151Z", + "description": "test web new", + "payment_definitions": [ + { + "name": "Payment Definition-1", + "type": "REGULAR", + "frequency": "Month", + "frequency_interval": "1", + "amount": { + "currency": "USD", + "value": "10" + }, + "cycles": "15", + "charge_models": [ + { + "type": "SHIPPING", + "amount": { + "currency": "USD", + "value": "1" + }, + "id": "CHM-29V063578B632154XLSNDODY" + }, + { + "type": "TAX", + "amount": { + "currency": "USD", + "value": "2" + }, + "id": "CHM-72N78855RJ303084YLSNDODY" + } + ], + "id": "PD-28U66480VB469201XLSNDODY" + }, + { + "name": "Payment Definition-2", + "type": "TRIAL", + "frequency": "Month", + "frequency_interval": "1", + "amount": { + "currency": "USD", + "value": "5" + }, + "cycles": "5", + "charge_models": [ + { + "type": "SHIPPING", + "amount": { + "currency": "USD", + "value": "1" + }, + "id": "CHM-4CT119433N5199501LSNDODY" + }, + { + "type": "TAX", + "amount": { + "currency": "USD", + "value": "1" + }, + "id": "CHM-38H015979N656741TLSNDODY" + } + ], + "id": "PD-99B78670BE878604GLSNDODY" + } + ], + "name": "Fast Speed Plan", + "state": "CREATED", + "create_time": "2016-04-28T11:21:31.151Z", + "links": [ + { + "href": "https://api.paypal.com/v1/payments/billing-plans/P-7LT50814996943336LSNDODY", + "rel": "self", + "method": "GET" + } + ], + "type": "FIXED" + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-2N1678257S892762B-8MC99539P4557624Y", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-2N1678257S892762B-8MC99539P4557624Y/resend", + "rel": "resend", + "method": "POST" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e1d6de*Host=ccg13amqunphttpdeliveryd34", + "client_pid": "652346", + "correlation-id": "2a5ee4870ad28", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "01056770-ba1e-11ed-82f7-c746d6224898", + "paypal-transmission-sig": "lCGXoAD9A/6EJ3cb15OOI8VLq8FFx37z9k6/cbzUR4Ll3UOGPcIYRm/4YVhqnIKA25XFHhYsyaPDimOLKr1JvImzASUe+Dod0X9KKAguEp7AAQI0nxnAlp3IEcSiRNCH6W9+MEEseZp2+ILLwiyZsx06vyUCViVwU6HKtfInj9xH9g/pjoI9X1wARVjwoHpbJrUGzCbQUwdZKfdQkGNKmoIso6xP1nlnUlX8VJFbSlHFvuF/eMVtBVx+XqVPqWo5Rj8leZwOmrFXLAWTsrKnolGm5SOQx8IB+5eMeln3hZyjTuDmcE/KBjvJ4/2eipX48xqAXsc1PRrTkLOArfbXxA==", + "paypal-transmission-time": "2023-03-03T23:49:14Z", + "x-b3-spanid": "328a9ad597f97a41" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "BILLING.PLAN.PRICING-CHANGE.ACTIVATED", + "name": "BILLING.PLAN.PRICING-CHANGE.ACTIVATED", + "body": { + "id": "WH-55TG7562XN2588878-8YH955435R771687G", + "create_time": "2018-19-12T22:20:32.000Z", + "event_type": "BILLING.PLAN.PRICING-CHANGE.ACTIVATED", + "event_version": "1.0", + "resource_type": "plan", + "resource_version": "2.0", + "summary": "A billing plan pricing change is activated.", + "resource": { + "id": "P-7GL4271244454362WXNWU5NQ", + "product_id": "PROD-XXCD1234QWER65782", + "name": "Zoho Marketing Campaign Plan", + "description": "Zoho Marketing Campaign Plan", + "status": "ACTIVE", + "usage_type": "LICENSED", + "billing_cycles": [ + { + "frequency": { + "interval_unit": "MONTH", + "interval_count": 1 + }, + "tenure_type": "TRIAL", + "sequence": 1, + "total_cycles": 1, + "pricing_scheme": { + "status": "ACTIVE", + "version": 2, + "fixed_price": { + "value": "50", + "currency_code": "USD" + }, + "tier_mode": "VOLUME", + "tiers": [ + { + "starting_quantity": "1", + "ending_quantity": "1000", + "amount": { + "value": "100", + "currency_code": "USD" + } + }, + { + "starting_quantity": "1001", + "amount": { + "value": "200", + "currency_code": "USD" + } + } + ] + } + }, + { + "frequency": { + "interval_unit": "MONTH", + "interval_count": 1 + }, + "tenure_type": "REGULAR", + "sequence": 2, + "total_cycles": 12, + "pricing_scheme": { + "status": "ACTIVE", + "version": 1, + "fixed_price": { + "value": "100", + "currency_code": "USD" + }, + "tier_mode": "VOLUME", + "tiers": [ + { + "starting_quantity": "1", + "ending_quantity": "1000", + "amount": { + "value": "300", + "currency_code": "USD" + } + }, + { + "starting_quantity": "1001", + "amount": { + "value": "1000", + "currency_code": "USD" + } + } + ] + } + } + ], + "payment_preferences": { + "service_type": "PREPAID", + "auto_bill_outstanding": true, + "setup_fee": { + "value": "10", + "currency_code": "USD" + }, + "setup_fee_failure_action": "CONTINUE", + "payment_failure_threshold": 3 + }, + "taxes": { + "percentage": "10", + "inclusive": false + }, + "create_time": "2018-12-10T21:20:49Z", + "update_time": "2018-12-10T21:20:49Z", + "links": [ + { + "href": "https://api.paypal.com/v1/billing/plans/P-5ML4271244454362WXNWU5NQ", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/billing/plans/P-5ML4271244454362WXNWU5NQ", + "rel": "edit", + "method": "PATCH" + } + ] + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-55TG7562XN2588878-8YH955435R771687G", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-55TG7562XN2588878-8YH955435R771687G/resend", + "rel": "resend", + "method": "POST" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e3122e*Host=ccg14amqunphttpdeliveryd1", + "client_pid": "763718", + "correlation-id": "16aa635264a86", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "267dfe90-ba1e-11ed-b9b7-69139b55be60", + "paypal-transmission-sig": "ioUvHMa+otPQM4IQBih6+eFuvo+mju0ncPQ6SKaCPd3aapiDitUJA8DHuVFXXnY+3b4ZmZyvPj4kV0Tvvp1LVcQuGz72BZql9FmoRoZlEeNFby11t0DjnzDkZN8nb//FN1xNA3XGwTlN6FT5dmNhY+gv+K17PUBrdbIpMN+Plkq2hirJSSUIWTMWORQFC6OhGsUYF53xO2f7oMTDozPgJjU0vuYpZiFnyGJAWlSSbspfd2aWvrIsUfW7D1unuorMOhA3SsNJ2lKlu3+lIK02qSYUnq0ZDY1mYQU7sWlyciLGBfLRTW/2kPV+Lhqt/+RJfVOlfBGpGN5ECXoBUlgCXg==", + "paypal-transmission-time": "2023-03-03T23:50:17Z", + "x-b3-spanid": "18c4d85396c02cbf" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "BILLING.PLAN.PRICING-CHANGE.INPROGRESS", + "name": "BILLING.PLAN.PRICING-CHANGE.INPROGRESS", + "body": { + "id": "WH-55TG7562XN2588878-8YH955435R672787G", + "create_time": "2018-19-12T22:20:32.000Z", + "event_type": "BILLING.PLAN.PRICING-CHANGE.INPROGRESS", + "event_version": "1.0", + "resource_type": "plan", + "resource_version": "2.0", + "summary": "A billing plan pricing change is being processed.", + "resource": { + "id": "P-7GL4271244454362WXNWU5NQ", + "product_id": "PROD-XXCD1234QWER65782", + "name": "Zoho Marketing Campaign Plan", + "description": "Zoho Marketing Campaign Plan", + "status": "ACTIVE", + "usage_type": "LICENSED", + "billing_cycles": [ + { + "frequency": { + "interval_unit": "MONTH", + "interval_count": 1 + }, + "tenure_type": "TRIAL", + "sequence": 1, + "total_cycles": 1, + "pricing_scheme": { + "status": "IN_PROGRESS", + "version": 2, + "fixed_price": { + "value": "50", + "currency_code": "USD" + }, + "tier_mode": "VOLUME", + "tiers": [ + { + "starting_quantity": "1", + "ending_quantity": "1000", + "amount": { + "value": "100", + "currency_code": "USD" + } + }, + { + "starting_quantity": "1001", + "amount": { + "value": "200", + "currency_code": "USD" + } + } + ] + } + }, + { + "frequency": { + "interval_unit": "MONTH", + "interval_count": 1 + }, + "tenure_type": "REGULAR", + "sequence": 2, + "total_cycles": 12, + "pricing_scheme": { + "status": "ACTIVE", + "version": 1, + "fixed_price": { + "value": "100", + "currency_code": "USD" + }, + "tier_mode": "VOLUME", + "tiers": [ + { + "starting_quantity": "1", + "ending_quantity": "1000", + "amount": { + "value": "300", + "currency_code": "USD" + } + }, + { + "starting_quantity": "1001", + "amount": { + "value": "1000", + "currency_code": "USD" + } + } + ] + } + } + ], + "payment_preferences": { + "service_type": "PREPAID", + "auto_bill_outstanding": true, + "setup_fee": { + "value": "10", + "currency_code": "USD" + }, + "setup_fee_failure_action": "CONTINUE", + "payment_failure_threshold": 3 + }, + "taxes": { + "percentage": "10", + "inclusive": false + }, + "create_time": "2018-12-10T21:20:49Z", + "update_time": "2018-12-10T21:20:49Z", + "links": [ + { + "href": "https://api.paypal.com/v1/billing/plans/P-5ML4271244454362WXNWU5NQ", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/billing/plans/P-5ML4271244454362WXNWU5NQ", + "rel": "edit", + "method": "PATCH" + } + ] + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-55TG7562XN2588878-8YH955435R672787G", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-55TG7562XN2588878-8YH955435R672787G/resend", + "rel": "resend", + "method": "POST" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e2943e*Host=ccg14amqunphttpdeliveryd12", + "client_pid": "764269", + "correlation-id": "d449a01c23c2c", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "29f83130-ba1e-11ed-9594-bb45e37dc329", + "paypal-transmission-sig": "BBtBrVrRGCrMuWaa/hrwp8KQwyVdLjOoQFjyT59tlDr9acxLvYk8ckdFCrzdtsxE23xn+rKE1AfBYyUF6/KWEkignOtN9dEmxFqYdm4zilKOGuiyP2aqBX5Zy3LgLynNBA/Hoxvemn0t8LLUQDxUhkQ0bU39XfrPlRpH5Lc5z+YVsEiEyTZUyT1tG2SHoWVjbgn8kpZDIeTSZwe36CthwwpZUMGh99gzJqNHo24S+fE2m5UynR+m6ERS1RlNh4X3vkSwxLQcYJ5GCU6IrQt3SohSRxoCVIiSq+O99K6UdY+N0F4ZybhdDUQlO9sq9hxON5pBNO6q25X6FeSKNDCx5Q==", + "paypal-transmission-time": "2023-03-03T23:50:23Z", + "x-b3-spanid": "5325233325a00fb5" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "BILLING.PLAN.UPDATED", + "name": "BILLING.PLAN.UPDATED", + "body": { + "id": "WH-303507964T970970H-60Y688551M4794625", + "event_version": "1.0", + "create_time": "2016-04-28T11:23:52Z", + "resource_type": "plan", + "event_type": "BILLING.PLAN.UPDATED", + "summary": "A billing plan was updated", + "resource": { + "id": "P-7LT50814996943336LSNDODY", + "merchant_preferences": { + "setup_fee": { + "currency": "USD", + "value": "1" + }, + "return_url": "http://www.paypal.com", + "cancel_url": "http://www.yahoo.com", + "auto_bill_amount": "YES", + "initial_fail_amount_action": "CONTINUE", + "max_fail_attempts": "21" + }, + "update_time": "2016-04-28T11:23:27.568Z", + "description": "test web new", + "payment_definitions": [ + { + "name": "Payment Definition-1", + "type": "REGULAR", + "frequency": "Month", + "frequency_interval": "1", + "amount": { + "currency": "USD", + "value": "10" + }, + "cycles": "15", + "charge_models": [ + { + "type": "TAX", + "amount": { + "currency": "USD", + "value": "2" + }, + "id": "CHM-72N78855RJ303084YLSNDODY" + }, + { + "type": "SHIPPING", + "amount": { + "currency": "USD", + "value": "1" + }, + "id": "CHM-29V063578B632154XLSNDODY" + } + ], + "id": "PD-28U66480VB469201XLSNDODY" + }, + { + "name": "Payment Definition-2", + "type": "TRIAL", + "frequency": "Month", + "frequency_interval": "1", + "amount": { + "currency": "USD", + "value": "5" + }, + "cycles": "5", + "charge_models": [ + { + "type": "TAX", + "amount": { + "currency": "USD", + "value": "1" + }, + "id": "CHM-38H015979N656741TLSNDODY" + }, + { + "type": "SHIPPING", + "amount": { + "currency": "USD", + "value": "1" + }, + "id": "CHM-4CT119433N5199501LSNDODY" + } + ], + "id": "PD-99B78670BE878604GLSNDODY" + } + ], + "name": "Fast Speed Plan", + "state": "ACTIVE", + "create_time": "2016-04-28T11:21:31.151Z", + "links": [ + { + "href": "https://api.paypal.com/v1/payments/billing-plans/P-7LT50814996943336LSNDODY", + "rel": "self", + "method": "GET" + } + ], + "type": "FIXED" + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-303507964T970970H-60Y688551M4794625", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-303507964T970970H-60Y688551M4794625/resend", + "rel": "resend", + "method": "POST" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e2add4*Host=ccg13amqunphttpdeliveryd3", + "client_pid": "715815", + "correlation-id": "183c29e59f2cf", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "2e124d50-ba1e-11ed-b43e-bdc2007fb7f3", + "paypal-transmission-sig": "ZDHvipBk/ybAXn3qprOldo8FKdTGB1C2U9UMZ/0/sLNydW9RIYzI+SwhoHS13jeM+LsUsyVdv5J7lQg1B1MqGm6jfEsGVTNOMW8okvPkw4c09qHds7XojOitYcjvEDjjryBNb8id+nawqvctLhkya3AxDT3EEpp7L57cjhrr+wLS/3ocqHpIsknWgx1IW/mSl/HnQRFG1hKiANV4SN8eWBN5nFwsP4UaJHTkuPq61osxOUwdnFvwPpWwZzPjdg3i59X5i6k6ID2H0UE5WiFwrcfRMrJD85zDPR7z3oMLQiqjmc/Wq2Lb7fvXDsAr18ks98nZmzUnDmHmPSfZOhUV1g==", + "paypal-transmission-time": "2023-03-03T23:50:30Z", + "x-b3-spanid": "81157169586502d4" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "BILLING.SUBSCRIPTION.ACTIVATED", + "name": "BILLING.SUBSCRIPTION.ACTIVATED", + "body": { + "id": "WH-77687562XN25889J8-8Y6T55435R66168T6", + "create_time": "2018-19-12T22:20:32.000Z", + "event_type": "BILLING.SUBSCRIPTION.ACTIVATED", + "event_version": "1.0", + "resource_type": "subscription", + "resource_version": "2.0", + "summary": "A billing agreement was activated.", + "resource": { + "id": "I-BW452GLLEP1G", + "status": "ACTIVE", + "status_update_time": "2018-12-10T21:20:49Z", + "plan_id": "P-5ML4271244454362WXNWU5NQ", + "start_time": "2018-11-01T00:00:00Z", + "quantity": "20", + "shipping_amount": { + "currency_code": "USD", + "value": "10.00" + }, + "subscriber": { + "name": { + "given_name": "John", + "surname": "Doe" + }, + "email_address": "customer@example.com", + "shipping_address": { + "name": { + "full_name": "John Doe" + }, + "address": { + "address_line_1": "2211 N First Street", + "address_line_2": "Building 17", + "admin_area_2": "San Jose", + "admin_area_1": "CA", + "postal_code": "95131", + "country_code": "US" + } + } + }, + "auto_renewal": true, + "billing_info": { + "outstanding_balance": { + "currency_code": "USD", + "value": "10.00" + }, + "cycle_executions": [ + { + "tenure_type": "TRIAL", + "sequence": 1, + "cycles_completed": 1, + "cycles_remaining": 0, + "current_pricing_scheme_version": 1 + }, + { + "tenure_type": "REGULAR", + "sequence": 2, + "cycles_completed": 1, + "cycles_remaining": 0, + "current_pricing_scheme_version": 2 + } + ], + "last_payment": { + "amount": { + "currency_code": "USD", + "value": "500.00" + }, + "time": "2018-12-01T01:20:49Z" + }, + "next_billing_time": "2019-01-01T00:20:49Z", + "final_payment_time": "2020-01-01T00:20:49Z", + "failed_payments_count": 2 + }, + "create_time": "2018-12-10T21:20:49Z", + "update_time": "2018-12-10T21:20:49Z", + "links": [ + { + "href": "https://api.paypal.com/v1/billing/subscriptions/I-BW452GLLEP1G", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/billing/subscriptions/I-BW452GLLEP1G", + "rel": "edit", + "method": "PATCH" + }, + { + "href": "https://api.paypal.com/v1/billing/subscriptions/I-BW452GLLEP1G/suspend", + "rel": "suspend", + "method": "POST" + }, + { + "href": "https://api.paypal.com/v1/billing/subscriptions/I-BW452GLLEP1G/cancel", + "rel": "cancel", + "method": "POST" + }, + { + "href": "https://api.paypal.com/v1/billing/subscriptions/I-BW452GLLEP1G/capture", + "rel": "capture", + "method": "POST" + } + ] + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-77687562XN25889J8-8Y6T55435R66168T6", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-77687562XN25889J8-8Y6T55435R66168T6/resend", + "rel": "resend", + "method": "POST" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e38132*Host=ccg13amqunphttpdeliveryd54", + "client_pid": "768928", + "correlation-id": "cc7cb2973ec9f", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "395db0a0-ba1e-11ed-9552-9babbd01d13a", + "paypal-transmission-sig": "Rmr5qG7LFk7G0GoI1sm7OT9w+cWG+RddBQEJo17jiEaVbFR7/WYzYSjgfSYup3phXKwb1y5LVTYR5OrhA879AVgxrW3llRGdf9Iq2q8/m3tQgpE1bnkmDHFpVm0Z028WXAxUEhk1rlNUfciJaEKv+yfSMSB/PVHdR+OwCLN1rMgPFfgvaH529fD07Zu9MqK6m0BePaxoII8O+9bb1wU+GC02PPkoAp6aVVaTqTilfy12lQOHz89NtNXKjebUjjxqpTksqEkLSWFaDYqwL21ns6nwqPIN/n6aI8a7Z+rG4QZM9a0oa94r+1iuK6rSflD8oYxlzi4ZX1uXOoff8GseWA==", + "paypal-transmission-time": "2023-03-03T23:50:49Z", + "x-b3-spanid": "3e987183e4807b4e" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "BILLING.SUBSCRIPTION.CANCELLED", + "name": "BILLING.SUBSCRIPTION.CANCELLED", + "body": { + "id": "WH-6TD369808N914414D-1YJ376786E892292F", + "event_version": "1.0", + "create_time": "2016-04-28T11:53:10Z", + "resource_type": "Agreement", + "event_type": "BILLING.SUBSCRIPTION.CANCELLED", + "summary": "A billing subscription was cancelled", + "resource": { + "id": "I-PE7JWXKGVN0R", + "shipping_address": { + "recipient_name": "Cool Buyer", + "line1": "3rd st", + "line2": "cool", + "city": "San Jose", + "state": "CA", + "postal_code": "95112", + "country_code": "US" + }, + "plan": { + "curr_code": "USD", + "links": [], + "payment_definitions": [ + { + "type": "TRIAL", + "frequency": "Month", + "frequency_interval": "1", + "amount": { + "value": "5.00" + }, + "cycles": "5", + "charge_models": [ + { + "type": "TAX", + "amount": { + "value": "1.00" + } + }, + { + "type": "SHIPPING", + "amount": { + "value": "1.00" + } + } + ] + }, + { + "type": "REGULAR", + "frequency": "Month", + "frequency_interval": "1", + "amount": { + "value": "10.00" + }, + "cycles": "15", + "charge_models": [ + { + "type": "TAX", + "amount": { + "value": "2.00" + } + }, + { + "type": "SHIPPING", + "amount": { + "value": "1.00" + } + } + ] + } + ], + "merchant_preferences": { + "setup_fee": { + "value": "0.00" + }, + "auto_bill_amount": "YES", + "max_fail_attempts": "21" + } + }, + "payer": { + "payment_method": "paypal", + "status": "verified", + "payer_info": { + "email": "coolbuyer@example.com", + "first_name": "Cool", + "last_name": "Buyer", + "payer_id": "XLHKRXRA4H7QY", + "shipping_address": { + "recipient_name": "Cool Buyer", + "line1": "3rd st", + "line2": "cool", + "city": "San Jose", + "state": "CA", + "postal_code": "95112", + "country_code": "US" + } + } + }, + "agreement_details": { + "outstanding_balance": { + "value": "0.00" + }, + "num_cycles_remaining": "5", + "num_cycles_completed": "0", + "last_payment_date": "2016-04-28T11:29:54Z", + "last_payment_amount": { + "value": "1.00" + }, + "final_payment_due_date": "2017-11-30T10:00:00Z", + "failed_payment_count": "0" + }, + "description": "update desc", + "state": "Cancelled", + "links": [ + { + "href": "https://api.paypal.com/v1/payments/billing-agreements/I-PE7JWXKGVN0R", + "rel": "self", + "method": "GET" + } + ], + "start_date": "2016-04-30T07:00:00Z" + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-6TD369808N914414D-1YJ376786E892292F", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-6TD369808N914414D-1YJ376786E892292F/resend", + "rel": "resend", + "method": "POST" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e35482*Host=ccg14amqunphttpdeliveryd21", + "client_pid": "765121", + "correlation-id": "b81ec97195256", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "3f983bc0-ba1e-11ed-b701-5da37665886d", + "paypal-transmission-sig": "XHEBcsYzyNtXXsdfGHdg72tu7OzOMRk10MdR5FO1EcAdn99LdBozRRrZzr4o7GQBQrmaDvxTfaSAnvtaZE5ccMt1wTNy0Hb9mlilqbFMDaiuzM9XYM+WgOIahLN8JrEPUyd4U60mKKAAWMAueZukFF4tTYeJgWLPsd4hidZN7tji0lDWQIwLdfTotGQ1lwAw9b9WBZrkj+kIU42nE1eGhchYkAJ0AmISRAYaA4dSHw1nFQQ6wqs4iU5p/Amv1u1KHXPqmfMBhJYwASe880OGfA1Pz/rGYyh0aMuc37rq4idzAaQHLYFYvjZ8MH/KJdB+QE+0D32qDiVQFu1yiF+TVA==", + "paypal-transmission-time": "2023-03-03T23:50:59Z", + "x-b3-spanid": "106851feec412bfd" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "BILLING.SUBSCRIPTION.CREATED", + "name": "BILLING.SUBSCRIPTION.CREATED", + "body": { + "id": "WH-19973937YW279670F-02S63370HL636500Y", + "event_version": "1.0", + "create_time": "2016-04-28T11:29:31Z", + "resource_type": "Agreement", + "event_type": "BILLING.SUBSCRIPTION.CREATED", + "summary": "A billing subscription was created", + "resource": { + "id": "I-PE7JWXKGVN0R", + "shipping_address": { + "recipient_name": "Cool Buyer", + "line1": "3rd st", + "line2": "cool", + "city": "San Jose", + "state": "CA", + "postal_code": "95112", + "country_code": "US" + }, + "plan": { + "curr_code": "USD", + "links": [], + "payment_definitions": [ + { + "type": "TRIAL", + "frequency": "Month", + "frequency_interval": "1", + "amount": { + "value": "5.00" + }, + "cycles": "5", + "charge_models": [ + { + "type": "TAX", + "amount": { + "value": "1.00" + } + }, + { + "type": "SHIPPING", + "amount": { + "value": "1.00" + } + } + ] + }, + { + "type": "REGULAR", + "frequency": "Month", + "frequency_interval": "1", + "amount": { + "value": "10.00" + }, + "cycles": "15", + "charge_models": [ + { + "type": "TAX", + "amount": { + "value": "2.00" + } + }, + { + "type": "SHIPPING", + "amount": { + "value": "1.00" + } + } + ] + } + ], + "merchant_preferences": { + "setup_fee": { + "value": "0.00" + }, + "auto_bill_amount": "YES", + "max_fail_attempts": "21" + } + }, + "payer": { + "payment_method": "paypal", + "status": "verified", + "payer_info": { + "email": "coolbuyer@example.com", + "first_name": "Cool", + "last_name": "Buyer", + "payer_id": "XLHKRXRA4H7QY", + "shipping_address": { + "recipient_name": "Cool Buyer", + "line1": "3rd st", + "line2": "cool", + "city": "San Jose", + "state": "CA", + "postal_code": "95112", + "country_code": "US" + } + } + }, + "agreement_details": { + "outstanding_balance": { + "value": "0.00" + }, + "num_cycles_remaining": "5", + "num_cycles_completed": "0", + "final_payment_due_date": "2017-11-30T10:00:00Z", + "failed_payment_count": "0" + }, + "description": "desc", + "state": "Pending", + "links": [ + { + "href": "https://api.paypal.com/v1/payments/billing-agreements/I-PE7JWXKGVN0R", + "rel": "self", + "method": "GET" + } + ], + "start_date": "2016-04-30T07:00:00Z" + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-19973937YW279670F-02S63370HL636500Y", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-19973937YW279670F-02S63370HL636500Y/resend", + "rel": "resend", + "method": "POST" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e37372*Host=ccg13amqunphttpdeliveryd56", + "client_pid": "708179", + "correlation-id": "17884c792681f", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "4604d360-ba1e-11ed-b26e-4700f011de08", + "paypal-transmission-sig": "UoWA6+seL6TCWr+o8Od2bTSEnNeor/HSbotB7qcYNBXWL6/s/GbS0I91Gg6RUf0ZtHfbW5MlfI8Kzufu9BdUP4fsNxdOLrSk4E/vLj/mNBkvLv1IrZh/ZcOGSM68c4FBlL2mkQCJdjp7iSgxNrAU3Uk4KgtUH14mGxR8zvR9iQeeLSoOA/AH846xwKbylxq0as7gQHfhgqR25rjnOlvESUiJewZx6ZguZMkMQlqCBciAE0R1KdBDgxZ8Rvu+P6/YFgI3HUwmyCUTNaZTNgqQt9VZpbQjWkflb6qctJUTVWLn5wteHhG46eC6ZTZyF8vYyswbqbQqTyFMR2UD5AgqVA==", + "paypal-transmission-time": "2023-03-03T23:51:10Z", + "x-b3-spanid": "f1ee2342940f8fcf" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "BILLING.SUBSCRIPTION.EXPIRED", + "name": "BILLING.SUBSCRIPTION.EXPIRED", + "body": { + "id": "WH-UY687577TY25889J9-2R6T55435R66168Y6", + "create_time": "2018-19-12T22:20:32.000Z", + "event_type": "BILLING.SUBSCRIPTION.EXPIRED", + "event_version": "1.0", + "resource_type": "subscription", + "resource_version": "2.0", + "summary": "A billing agreement has expired.", + "resource": { + "id": "I-BW452GLLEP1G", + "status": "EXPIRED", + "status_update_time": "2018-12-10T21:20:49Z", + "plan_id": "P-5ML4271244454362WXNWU5NQ", + "start_time": "2018-11-01T00:00:00Z", + "quantity": "20", + "shipping_amount": { + "currency_code": "USD", + "value": "10.00" + }, + "subscriber": { + "name": { + "given_name": "John", + "surname": "Doe" + }, + "email_address": "customer@example.com", + "shipping_address": { + "name": { + "full_name": "John Doe" + }, + "address": { + "address_line_1": "2211 N First Street", + "address_line_2": "Building 17", + "admin_area_2": "San Jose", + "admin_area_1": "CA", + "postal_code": "95131", + "country_code": "US" + } + } + }, + "auto_renewal": true, + "billing_info": { + "outstanding_balance": { + "currency_code": "USD", + "value": "10.00" + }, + "cycle_executions": [ + { + "tenure_type": "TRIAL", + "sequence": 1, + "cycles_completed": 1, + "cycles_remaining": 0, + "current_pricing_scheme_version": 1 + }, + { + "tenure_type": "REGULAR", + "sequence": 2, + "cycles_completed": 1, + "cycles_remaining": 0, + "current_pricing_scheme_version": 1 + } + ], + "last_payment": { + "amount": { + "currency_code": "USD", + "value": "500.00" + }, + "time": "2018-12-01T01:20:49Z" + }, + "next_billing_time": "2019-01-01T00:20:49Z", + "final_payment_time": "2020-01-01T00:20:49Z", + "failed_payments_count": 2 + }, + "create_time": "2018-12-10T21:20:49Z", + "update_time": "2018-12-10T21:20:49Z", + "links": [ + { + "href": "https://api.paypal.com/v1/billing/subscriptions/I-BW452GLLEP1G", + "rel": "self", + "method": "GET" + } + ] + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-UY687577TY25889J9-2R6T55435R66168Y6", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-UY687577TY25889J9-2R6T55435R66168Y6/resend", + "rel": "resend", + "method": "POST" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e3992e*Host=ccg14amqunphttpdeliveryd14", + "client_pid": "763312", + "correlation-id": "935e2dfa05f35", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "4925c270-ba1e-11ed-89ca-b71bfd0b813f", + "paypal-transmission-sig": "ZDTn0v/sZ3+LweerJ8omVwsnQVWepYVgXxM55pYzEDt8Qcnte7REnfbeHQtFtGtBoEWc/p2ntu3cMD0qnO1SU1ttO08uq9bVnQubdcc8BGYFJLTZbUVumzNSoGmcHoB9nScTY+qF+kBPPQXawzJHIQtIu1xyABoEflGJvFGR7YuKVZr1i0vAhtG1riLfTe6K77WjLVxTmFrBC1JdnZ9IBCcSDUBKyGtEl/DFJev5zyHjXCs7v1+WhQNZGAMJvHOPiHojYWHrtPMEQGsW5tTopTNV9eKgwzoeduFPKgfZrVo12nw4ehm0EaGc0p33cv3siN0f1pzrNAKT1F8cS9RAKg==", + "paypal-transmission-time": "2023-03-03T23:51:15Z", + "x-b3-spanid": "3530d1aa54d7793a" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "BILLING.SUBSCRIPTION.PAYMENT.FAILED", + "name": "BILLING.SUBSCRIPTION.PAYMENT.FAILED", + "body": { + "id": "WH-77687562XN25889J2-8Y6T55435R66168T4", + "create_time": "2019-01-01T01:20:54.000Z", + "event_type": "BILLING.SUBSCRIPTION.PAYMENT.FAILED", + "event_version": "1.0", + "resource_type": "subscription", + "resource_version": "2.0", + "summary": "Subscription payment failed", + "resource": { + "id": "I-BW452GLLEP1G", + "status": "ACTIVE", + "status_update_time": "2018-12-10T21:20:49Z", + "plan_id": "P-5ML4271244454362WXNWU5NQ", + "start_time": "2018-11-01T00:00:00Z", + "quantity": "20", + "shipping_amount": { + "currency_code": "USD", + "value": "10.00" + }, + "subscriber": { + "name": { + "given_name": "John", + "surname": "Doe" + }, + "email_address": "customer@example.com", + "payer_id": "2J6QB8YJQSJRJ", + "shipping_address": { + "name": { + "full_name": "John Doe" + }, + "address": { + "address_line_1": "2211 N First Street", + "address_line_2": "Building 17", + "admin_area_2": "San Jose", + "admin_area_1": "CA", + "postal_code": "95131", + "country_code": "US" + } + } + }, + "billing_info": { + "outstanding_balance": { + "currency_code": "USD", + "value": "10.00" + }, + "cycle_executions": [ + { + "tenure_type": "TRIAL", + "sequence": 1, + "cycles_completed": 1, + "cycles_remaining": 0, + "current_pricing_scheme_version": 1 + }, + { + "tenure_type": "REGULAR", + "sequence": 2, + "cycles_completed": 1, + "cycles_remaining": 0, + "current_pricing_scheme_version": 1 + } + ], + "last_payment": { + "amount": { + "currency_code": "USD", + "value": "500.00" + }, + "time": "2018-12-01T01:20:49Z" + }, + "last_failed_payment": { + "amount": { + "currency_code": "USD", + "value": "500.00" + }, + "time": "2019-01-01T01:20:49Z", + "reason_code": "PAYMENT_DENIED", + "next_payment_retry_date": "2019-01-06T01:20:49Z" + }, + "next_billing_time": "2019-01-01T00:20:49Z", + "final_payment_time": "2020-01-01T00:20:49Z", + "failed_payments_count": 2 + }, + "create_time": "2018-10-20T21:20:49Z", + "update_time": "2018-12-10T21:20:49Z", + "links": [ + { + "href": "https://api.paypal.com/v1/billing/subscriptions/I-BW452GLLEP1G?fields=last_failed_payment", + "rel": "self", + "method": "GET" + } + ] + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-77687562XN25889J2-8Y6T55435R66168T4", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-77687562XN25889J2-8Y6T55435R66168T4/resend", + "rel": "resend", + "method": "POST" + }, + { + "href": "https://api.paypal.com/v1/billing/subscriptions/I-BW452GLLEP1G", + "rel": "subscription", + "method": "GET" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e34230*Host=ccg13amqunphttpdeliveryd37", + "client_pid": "775226", + "correlation-id": "66d2aac69842b", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "4bf7ce30-ba1e-11ed-8c49-d5ed14aef4b1", + "paypal-transmission-sig": "J0zVAJHvfk5kB80TShebVnsi70T18afMB4nT+0pRRlF21aqc/hahC5a8SU1pdB1HSre1FvZtBW2vAekraoqYwd7ur17iyoEBNyT2ofa32wXEDmZeUS7jDH2en8UTLAqbZ+k3DINwWPebgHIgBmP8d2hgbXin7+xYDctQFlFQThMeAshQxJKYGOkTsH7bUrWtcVcgb2gkJJfUYuyYASEGBy9xTWi1pkHt7WmM2OBBxtaGJo8i2eSf8fejU2BI7VLga3bQFKuTdQEmtReZWoWRKyL2hxM93ZEFJ30Slzcrm//GRdEzeGJBEGYuogGodTltFFEdaNeFZdI+JMeXo7VFPQ==", + "paypal-transmission-time": "2023-03-03T23:51:20Z", + "x-b3-spanid": "12258d7a1b42b227" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "BILLING.SUBSCRIPTION.RE-ACTIVATED", + "name": "BILLING.SUBSCRIPTION.RE-ACTIVATED", + "body": { + "id": "WH-8KT47529P7696805Y-3CS72897S14937140", + "event_version": "1.0", + "create_time": "2016-04-28T11:41:01Z", + "resource_type": "Agreement", + "event_type": "BILLING.SUBSCRIPTION.RE-ACTIVATED", + "summary": "A billing subscription was reactivated", + "resource": { + "id": "I-PE7JWXKGVN0R", + "shipping_address": { + "recipient_name": "Cool Buyer", + "line1": "3rd st", + "line2": "cool", + "city": "San Jose", + "state": "CA", + "postal_code": "95112", + "country_code": "US" + }, + "plan": { + "curr_code": "USD", + "links": [], + "payment_definitions": [ + { + "type": "TRIAL", + "frequency": "Month", + "frequency_interval": "1", + "amount": { + "value": "5.00" + }, + "cycles": "5", + "charge_models": [ + { + "type": "TAX", + "amount": { + "value": "1.00" + } + }, + { + "type": "SHIPPING", + "amount": { + "value": "1.00" + } + } + ] + }, + { + "type": "REGULAR", + "frequency": "Month", + "frequency_interval": "1", + "amount": { + "value": "10.00" + }, + "cycles": "15", + "charge_models": [ + { + "type": "TAX", + "amount": { + "value": "2.00" + } + }, + { + "type": "SHIPPING", + "amount": { + "value": "1.00" + } + } + ] + } + ], + "merchant_preferences": { + "setup_fee": { + "value": "0.00" + }, + "auto_bill_amount": "YES", + "max_fail_attempts": "21" + } + }, + "payer": { + "payment_method": "paypal", + "status": "verified", + "payer_info": { + "email": "coolbuyer@example.com", + "first_name": "Cool", + "last_name": "Buyer", + "payer_id": "XLHKRXRA4H7QY", + "shipping_address": { + "recipient_name": "Cool Buyer", + "line1": "3rd st", + "line2": "cool", + "city": "San Jose", + "state": "CA", + "postal_code": "95112", + "country_code": "US" + } + } + }, + "agreement_details": { + "outstanding_balance": { + "value": "0.00" + }, + "num_cycles_remaining": "5", + "num_cycles_completed": "0", + "next_billing_date": "2016-04-30T07:00:00Z", + "last_payment_date": "2016-04-28T11:29:54Z", + "last_payment_amount": { + "value": "1.00" + }, + "final_payment_due_date": "2017-11-30T10:00:00Z", + "failed_payment_count": "0" + }, + "description": "desc", + "state": "Active", + "links": [ + { + "href": "https://api.paypal.com/v1/payments/billing-agreements/I-PE7JWXKGVN0R", + "rel": "self", + "method": "GET" + } + ], + "start_date": "2016-04-30T07:00:00Z" + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-8KT47529P7696805Y-3CS72897S14937140", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-8KT47529P7696805Y-3CS72897S14937140/resend", + "rel": "resend", + "method": "POST" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e39366*Host=ccg13amqunphttpdeliveryd26", + "client_pid": "769475", + "correlation-id": "edf62a3dd97ff", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "4efcf7e0-ba1e-11ed-9552-9babbd01d13a", + "paypal-transmission-sig": "nStQQFzoNp4sRtorQQSDfbjj7L4efDHwdcBNpVHAsBnIr7RwwMWayz62QMtF3jhwfJwMfobA6wrs+a8QJzik3s2H8nRqh9SW9jfcu3xWj6bk0VT0qx6lGvg+oVKPJct9GFHJWx4a3H9l1b1vnvWuA56Bm/nTvQbAMAQxem373J+o0h+lvYPi66XUAu+AwWr8TWsEi8wxYYqsZ+aa0FcuTVoCEd8MS6MRy9tzXiZdb5xBBm/VNq3xXrqufDLmxXKh1CrvViMZB9gwN8fjdSUXDIm4gOVx0LxJFe+uddN0320uIqNhR3X5H5xwe/SE4L3mwSJcoSrDav9UiiyKOwfNhg==", + "paypal-transmission-time": "2023-03-03T23:51:25Z", + "x-b3-spanid": "c17d3c8668470309" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "BILLING.SUBSCRIPTION.SUSPENDED", + "name": "BILLING.SUBSCRIPTION.SUSPENDED", + "body": { + "id": "WH-0RD44774E41721427-487281337V896051W", + "event_version": "1.0", + "create_time": "2016-04-28T11:37:14Z", + "resource_type": "Agreement", + "event_type": "BILLING.SUBSCRIPTION.SUSPENDED", + "summary": "A billing subscription was suspended", + "resource": { + "id": "I-PE7JWXKGVN0R", + "shipping_address": { + "recipient_name": "Cool Buyer", + "line1": "3rd st", + "line2": "cool", + "city": "San Jose", + "state": "CA", + "postal_code": "95112", + "country_code": "US" + }, + "plan": { + "curr_code": "USD", + "links": [], + "payment_definitions": [ + { + "type": "TRIAL", + "frequency": "Month", + "frequency_interval": "1", + "amount": { + "value": "5.00" + }, + "cycles": "5", + "charge_models": [ + { + "type": "TAX", + "amount": { + "value": "1.00" + } + }, + { + "type": "SHIPPING", + "amount": { + "value": "1.00" + } + } + ] + }, + { + "type": "REGULAR", + "frequency": "Month", + "frequency_interval": "1", + "amount": { + "value": "10.00" + }, + "cycles": "15", + "charge_models": [ + { + "type": "TAX", + "amount": { + "value": "2.00" + } + }, + { + "type": "SHIPPING", + "amount": { + "value": "1.00" + } + } + ] + } + ], + "merchant_preferences": { + "setup_fee": { + "value": "0.00" + }, + "auto_bill_amount": "YES", + "max_fail_attempts": "21" + } + }, + "payer": { + "payment_method": "paypal", + "status": "verified", + "payer_info": { + "email": "coolbuyer@example.com", + "first_name": "Cool", + "last_name": "Buyer", + "payer_id": "XLHKRXRA4H7QY", + "shipping_address": { + "recipient_name": "Cool Buyer", + "line1": "3rd st", + "line2": "cool", + "city": "San Jose", + "state": "CA", + "postal_code": "95112", + "country_code": "US" + } + } + }, + "agreement_details": { + "outstanding_balance": { + "value": "0.00" + }, + "num_cycles_remaining": "5", + "num_cycles_completed": "0", + "last_payment_date": "2016-04-28T11:29:54Z", + "last_payment_amount": { + "value": "1.00" + }, + "final_payment_due_date": "1971-07-30T10:00:00Z", + "failed_payment_count": "0" + }, + "description": "desc", + "state": "Suspended", + "links": [ + { + "href": "https://api.paypal.com/v1/payments/billing-agreements/I-PE7JWXKGVN0R", + "rel": "self", + "method": "GET" + } + ], + "start_date": "2016-04-30T07:00:00Z" + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-0RD44774E41721427-487281337V896051W", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-0RD44774E41721427-487281337V896051W/resend", + "rel": "resend", + "method": "POST" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e3f572*Host=ccg14amqunphttpdeliveryd46", + "client_pid": "761921", + "correlation-id": "f6e5d463df567", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "58edad30-ba1e-11ed-a42f-c5267af5ab60", + "paypal-transmission-sig": "eS9blYRTmTAptEoqlQ+Tv/9d4/EpxRGiyOX9v4t0s9gHOmRzHLr1fLyBsZD+IZixI5QAMe3JMszLOcjPrfAlBYkXWvToREUsuwlLVeWJkB4zlFyanQxX+z/5h//6tKKlxPaKUJglkHU3JLY6YcanvZ3HRxe237Ngtum+6xYO41q2EYxSuP+cKSCLBcY0LaDRO5M6Zwz+lCKLax0lSBZym7YrZqSVbAux2wqvhWgeXlFwnR4NS2+NHewZ23z4H3w/0+EyYIX/UgtUu8+HdvkkaakUUD21gW4QneAEx0Gs3AVXK5FxTuarCyX01IesrVGSnBdTCZHIg1lhV4XHSPpFAw==", + "paypal-transmission-time": "2023-03-03T23:51:42Z", + "x-b3-spanid": "50ff6b21e56a0184" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "BILLING.SUBSCRIPTION.UPDATED", + "name": "BILLING.SUBSCRIPTION.UPDATED", + "body": { + "id": "WH-7BW55401AV391063H-02P24463AR970092S", + "event_version": "1.0", + "create_time": "2016-04-28T11:43:08Z", + "resource_type": "Agreement", + "event_type": "BILLING.SUBSCRIPTION.UPDATED", + "summary": "A billing subscription was updated", + "resource": { + "id": "I-PE7JWXKGVN0R", + "shipping_address": { + "recipient_name": "Cool Buyer", + "line1": "3rd st", + "line2": "cool", + "city": "San Jose", + "state": "CA", + "postal_code": "95112", + "country_code": "US" + }, + "plan": { + "curr_code": "USD", + "links": [], + "payment_definitions": [ + { + "type": "TRIAL", + "frequency": "Month", + "frequency_interval": "1", + "amount": { + "value": "5.00" + }, + "cycles": "5", + "charge_models": [ + { + "type": "TAX", + "amount": { + "value": "1.00" + } + }, + { + "type": "SHIPPING", + "amount": { + "value": "1.00" + } + } + ] + }, + { + "type": "REGULAR", + "frequency": "Month", + "frequency_interval": "1", + "amount": { + "value": "10.00" + }, + "cycles": "15", + "charge_models": [ + { + "type": "TAX", + "amount": { + "value": "2.00" + } + }, + { + "type": "SHIPPING", + "amount": { + "value": "1.00" + } + } + ] + } + ], + "merchant_preferences": { + "setup_fee": { + "value": "0.00" + }, + "auto_bill_amount": "YES", + "max_fail_attempts": "21" + } + }, + "payer": { + "payment_method": "paypal", + "status": "verified", + "payer_info": { + "email": "coolbuyer@example.com", + "first_name": "Cool", + "last_name": "Buyer", + "payer_id": "XLHKRXRA4H7QY", + "shipping_address": { + "recipient_name": "Cool Buyer", + "line1": "3rd st", + "line2": "cool", + "city": "San Jose", + "state": "CA", + "postal_code": "95112", + "country_code": "US" + } + } + }, + "agreement_details": { + "outstanding_balance": { + "value": "0.00" + }, + "num_cycles_remaining": "5", + "num_cycles_completed": "0", + "last_payment_date": "2016-04-28T11:29:54Z", + "last_payment_amount": { + "value": "1.00" + }, + "final_payment_due_date": "1971-07-30T10:00:00Z", + "failed_payment_count": "0" + }, + "description": "update desc", + "state": "Suspended", + "links": [ + { + "href": "https://api.paypal.com/v1/payments/billing-agreements/I-PE7JWXKGVN0R", + "rel": "self", + "method": "GET" + } + ], + "start_date": "2016-04-30T07:00:00Z" + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-7BW55401AV391063H-02P24463AR970092S", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-7BW55401AV391063H-02P24463AR970092S/resend", + "rel": "resend", + "method": "POST" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e4356e*Host=ccg13amqunphttpdeliveryd22", + "client_pid": "766546", + "correlation-id": "b51e908e399be", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "5ba5c850-ba1e-11ed-b26e-4700f011de08", + "paypal-transmission-sig": "cLP3Nk1bmFwbnJhNQ1I2+j2U5sxGftSmmB0lrIrR3TSiS9sfbVyvxdBIRj3V9AmJKUgcvr8lMwEhXQN42WTlLUJWKdZIh9QXmihYMHUna8AMzxV14OcBm3Y9O+Ngu4FWudr3EE/VPtQLkoxIgHRGljpLvOgz71ufRGLZGUzRZOtNVx000CPAHwtwLyfCS4JYR6MF26tWoJRagwQ7jflU9aQlgA5DYvxAlHsSZ5gI96CadIU/0tW+huYnJM7loJLwUOIiPHWBEn+eMEIkEiLRuo7C+N3etuiMf3oDHaobirnEhTjmmMyAKrucBwv06l+pzx+Yv1GZeGdDRwfDtCm45w==", + "paypal-transmission-time": "2023-03-03T23:51:46Z", + "x-b3-spanid": "872d5fc27a42d9ca" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "CATALOG.PRODUCT.CREATED", + "name": "CATALOG.PRODUCT.CREATED", + "body": { + "id": "WH-55TG7562XN2588878-8YH955435R661687G", + "create_time": "2018-12-19T22:20:32.000Z", + "event_type": "CATALOG.PRODUCT.CREATED", + "event_version": "1.0", + "resource_type": "product", + "resource_version": "2.0", + "summary": "Product created", + "resource": { + "id": "72255d4849af8ed6e0df1173", + "name": "Video Streaming Service", + "description": "Video streaming service", + "type": "SERVICE", + "category": "SAAS", + "image_url": "https://example.com/streaming.jpg", + "home_url": "https://example.com/home", + "create_time": "2018-12-10T21:20:49Z", + "update_time": "2018-12-10T21:20:49Z", + "links": [ + { + "href": "https://api.paypal.com/v1/catalogs/products/72255d4849af8ed6e0df1173", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/catalogs/products/72255d4849af8ed6e0df1173", + "rel": "edit", + "method": "PATCH" + } + ] + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-55TG7562XN2588878-8YH955435R661687G", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-55TG7562XN2588878-8YH955435R661687G/resend", + "rel": "resend", + "method": "POST" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e3dfa6*Host=ccg14amqunphttpdeliveryd39", + "client_pid": "761914", + "correlation-id": "d3a3ebfe3dbf4", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "5ea1f150-ba1e-11ed-a5f7-a56ac798bf6f", + "paypal-transmission-sig": "bO3qgys2KPjDTXm/4ti5GzhkGC/+YnxppjPdYUVHCq7P5JNfsvXw+6d9VEZncj6453vwuCBwDaHaEcm2hV3b9DWCiBCzqwByfecxd+iV8nNKUdJYzWXuTXmsGlzmtuVzZd6dZsS+IgjUlKTOpS++WqmSLnyWJMzJ9S2DZvqn9YCrknbI79bTmq0A/l4zxC10VoxRo1iwdWId+zxAVNVc0NlZHJhpMNs7lWgi9YmAsK0DEfFLHcBWDVPFvKXZkViDrg/Gk/ro9U7AWSTO4kavMyQ8mZX+tSAc1AaSLuJMj+MzYayIo0z+RqHZKVCTpp9bWhXTjCaQd2K5DdXjpj7DEQ==", + "paypal-transmission-time": "2023-03-03T23:51:51Z", + "x-b3-spanid": "c079798047992471" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "CATALOG.PRODUCT.UPDATED", + "name": "CATALOG.PRODUCT.UPDATED", + "body": { + "id": "WH-55TG7562XN2588878-8YH955435R661687G", + "create_time": "2018-12-19T22:20:32.000Z", + "event_type": "CATALOG.PRODUCT.UPDATED", + "event_version": "1.0", + "resource_type": "product", + "resource_version": "2.0", + "summary": "Product updated", + "resource": { + "id": "PROD-VFGB2314GFDE7658N", + "name": "Video Streaming Service", + "description": "Video streaming service", + "type": "SERVICE", + "category": "SAAS", + "image_url": "https://example.com/streaming.jpg", + "home_url": "https://example.com/home", + "create_time": "2018-12-10T21:20:49Z", + "update_time": "2018-12-10T21:20:49Z", + "links": [ + { + "href": "https://api.paypal.com/v1/catalogs/products/72255d4849af8ed6e0df1173", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/catalogs/products/72255d4849af8ed6e0df1173", + "rel": "edit", + "method": "PATCH" + } + ] + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-55TG7562XN2588878-8YH955435R661687G", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-55TG7562XN2588878-8YH955435R661687G/resend", + "rel": "resend", + "method": "POST" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e3f752*Host=ccg14amqunphttpdeliveryd37", + "client_pid": "764189", + "correlation-id": "b229fe9a4534b", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "60fa1220-ba1e-11ed-b284-5163cdac11a1", + "paypal-transmission-sig": "HTiHrgr2Qp4XIsMHKKnVMROFgX9ZDer5oVhnrG6guDzGAabEO6UZvTOkAqltyZRtGAVhuYAxQ/wxNsLadnVE4rfPGhZp8OfY9Qk6by4WNhMkaKgb7OIBj3ataVZ8bMU/uOc+ve9tg2rmusZWDjzGJGNWrrAgpP6cxVkQr5V0d44At/LLI2G3CWZkZAiw/55e2FDPxuAnq9U66+jl1Ioe1J9GwzqmTuVxs7mPvKObwVNf1lOkB8Ko/4ijWbsM0QvAvM2rzJeCUsaGb3sbP9jwAXxQBb7oQb6LQ+XIDAubCvkuMV1jqQ0qts9gM257zbOzDmqvy5SC/qcCI9EaoJmPdA==", + "paypal-transmission-time": "2023-03-03T23:51:55Z", + "x-b3-spanid": "8e5ea768f6be199e" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "CHECKOUT.CHECKOUT.BUYER-APPROVED", + "name": "CHECKOUT.CHECKOUT.BUYER-APPROVED", + "body": { + "id": "WH-6W4482673W002281V-61985753LP2332451", + "create_time": "2018-19-12T22:20:32.000Z", + "event_type": "CHECKOUT.CHECKOUT.BUYER-APPROVED", + "event_version": "1.0", + "resource_type": "payment", + "summary": "Checkout Payment is created and approved by buyer.", + "resource": { + "id": "EC-7JW39463WG7174715", + "payload": "payment_type=instant&payment_date=10%3A46%3A54%20Apr%2028%2C%202019%20PDT&payment_status=Completed&address_status=confirmed&payer_status=verified&first_name=John&last_name=Smith&payer_email=buyer@paypalsandbox.com&payer_id=TESTBUYERID01&address_name=John%20Smith&address_country=United%20States&address_country_code=US&address_zip=95131&address_state=CA&address_city=San%20Jose&address_street=123%20any%20street&business=seller@paypalsandbox.com&receiver_email=seller@paypalsandbox.com&receiver_id=seller@paypalsandbox.com&residence_country=US&item_name1=something&item_number1=AK-1234&tax=2.02&mc_currency=USD&mc_fee=0.44&mc_gross=12.34&mc_gross_1=12.34&mc_handling=2.06&mc_handling1=1.67&mc_shipping=3.02&mc_shipping1=1.02&txn_type=cart&txn_id=274113719¬ify_version=2.1&custom=xyz123&invoice=abc1234&test_ipn=1&verify_sign=AmoLQMSQnFNTX4UH11dp7XvPK.4bAfd10dnRIZeo9cONJNn0b9NquIGb" + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-6W4482673W002281V-61985753LP2332451", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-6W4482673W002281V-61985753LP2332451/resend", + "rel": "resend", + "method": "POST" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e203fc*Host=ccg13amqunphttpdeliveryd4", + "client_pid": "770183", + "correlation-id": "7aa54eb41e894", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "0d3f9790-ba1e-11ed-9150-2f866594276d", + "paypal-transmission-sig": "QSAXlIIUdEu8atHfhbnwpnxKI444ZX+xNb2jNi/Yj05bV5uR3EpWhq1y89SVxKLPXSdZocC0ibstVA9Dycd4XJTHo1BkYLkMfwBCDf7F3W6aQnMjkGST+FiUNT6etYtIsWQhRcH+k/J5krNj5pmabfOZQ/b1oPQHsDA62ctokZchUCkjjJEMVX7b5rHvJIFJa+2BQnBj2EUHdDVsz3MoU43uxqMjK1sIYsVDltOG1Dx+fgbjv4aGkHv6xkapnUGrad31eclbmfG/J2PQ5rqnY/lhq6g+fnD9UtDxq9gC8PXBHa2tIMPAUNMnK1Z/orCshYUTteYZc82B28staFPn/Q==", + "paypal-transmission-time": "2023-03-03T23:49:35Z", + "x-b3-spanid": "16fa6c3ed35f4113" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "CHECKOUT.ORDER.APPROVED", + "name": "CHECKOUT.ORDER.APPROVED", + "body": { + "id": "WH-COC11055RA711503B-4YM959094A144403T", + "create_time": "2018-04-16T21:21:49.000Z", + "event_type": "CHECKOUT.ORDER.APPROVED", + "resource_type": "checkout-order", + "resource_version": "2.0", + "summary": "An order has been approved by buyer", + "resource": { + "id": "5O190127TN364715T", + "status": "APPROVED", + "intent": "CAPTURE", + "payer": { + "name": { + "given_name": "John", + "surname": "Doe" + }, + "email_address": "customer@example.com", + "payer_id": "QYR5Z8XDVJNXQ" + }, + "purchase_units": [ + { + "reference_id": "d9f80740-38f0-11e8-b467-0ed5f89f718b", + "amount": { + "currency_code": "USD", + "value": "100.00" + }, + "payee": { + "email_address": "merchant@example.com" + }, + "shipping": { + "method": "United States Postal Service", + "address": { + "address_line_1": "2211 N First Street", + "address_line_2": "Building 17", + "admin_area_2": "San Jose", + "admin_area_1": "CA", + "postal_code": "95131", + "country_code": "US" + } + } + } + ], + "create_time": "2018-04-01T21:18:49Z", + "update_time": "2018-04-01T21:20:49Z", + "links": [ + { + "href": "https://api.paypal.com/v2/checkout/orders/5O190127TN364715T", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v2/checkout/orders/5O190127TN364715T/capture", + "method": "POST" + } + ] + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-COC11055RA711503B-4YM959094A144403T", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-COC11055RA711503B-4YM959094A144403T/resend", + "rel": "resend", + "method": "POST" + } + ], + "event_version": "1.0" + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e46390*Host=ccg14amqunphttpdeliveryd50", + "client_pid": "762622", + "correlation-id": "6d86a402e064e", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "65f03660-ba1e-11ed-b284-5163cdac11a1", + "paypal-transmission-sig": "dn4KcrSaxCEXd8I5yrHQWJtQ4FySM1Bfsob4dBTBo7FSGrNJYQF2YjiX1pkKI+/MvPHWPIA62aWIT5dqxl1RLxvuYmZOf1O6SRx5XVcil9zotqdAADDsC/7V91qb5Qh6mMUv/Hut11ny0ZEAJoUsc88O/XVchuwuDYbYU+WhCmdUywnposyyoddELfd/TSWv3BsW9a+K0/iUU3T2Yg3o7mxSP0jPlv/WiwAOqv7EABhkhdLlZIOznJVOwSeDp8W9Z3XiNpy1kWDR26YWqeM0RSjCq1k9CjHMjIGKm2SN8KymTKzE44eblh/Onm5LXXeiuOyQ636fnZOp5nyU91+O0Q==", + "paypal-transmission-time": "2023-03-03T23:52:04Z", + "x-b3-spanid": "9f4a678094f242d4" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "CHECKOUT.ORDER.COMPLETED", + "name": "CHECKOUT.ORDER.COMPLETED", + "body": { + "id": "WH-COC11055RA711503B-4YM959094A144403T", + "create_time": "2018-04-16T21:21:49.000Z", + "event_type": "CHECKOUT.ORDER.COMPLETED", + "resource_type": "checkout-order", + "resource_version": "2.0", + "summary": "Checkout Order Completed", + "resource": { + "id": "5O190127TN364715T", + "status": "COMPLETED", + "intent": "CAPTURE", + "gross_amount": { + "currency_code": "USD", + "value": "100.00" + }, + "payer": { + "name": { + "given_name": "John", + "surname": "Doe" + }, + "email_address": "buyer@example.com", + "payer_id": "QYR5Z8XDVJNXQ" + }, + "purchase_units": [ + { + "reference_id": "d9f80740-38f0-11e8-b467-0ed5f89f718b", + "amount": { + "currency_code": "USD", + "value": "100.00" + }, + "payee": { + "email_address": "seller@example.com" + }, + "shipping": { + "method": "United States Postal Service", + "address": { + "address_line_1": "2211 N First Street", + "address_line_2": "Building 17", + "admin_area_2": "San Jose", + "admin_area_1": "CA", + "postal_code": "95131", + "country_code": "US" + } + }, + "payments": { + "captures": [ + { + "id": "3C679366HH908993F", + "status": "COMPLETED", + "amount": { + "currency_code": "USD", + "value": "100.00" + }, + "seller_protection": { + "status": "ELIGIBLE", + "dispute_categories": ["ITEM_NOT_RECEIVED", "UNAUTHORIZED_TRANSACTION"] + }, + "final_capture": true, + "seller_receivable_breakdown": { + "gross_amount": { + "currency_code": "USD", + "value": "100.00" + }, + "paypal_fee": { + "currency_code": "USD", + "value": "3.00" + }, + "net_amount": { + "currency_code": "USD", + "value": "97.00" + } + }, + "create_time": "2018-04-01T21:20:49Z", + "update_time": "2018-04-01T21:20:49Z", + "links": [ + { + "href": "https://api.paypal.com/v2/payments/captures/3C679366HH908993F", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v2/payments/captures/3C679366HH908993F/refund", + "rel": "refund", + "method": "POST" + } + ] + } + ] + } + } + ], + "create_time": "2018-04-01T21:18:49Z", + "update_time": "2018-04-01T21:20:49Z", + "links": [ + { + "href": "https://api.paypal.com/v2/checkout/orders/5O190127TN364715T", + "rel": "self", + "method": "GET" + } + ] + }, + "links": [ + { + "href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-COC11055RA711503B-4YM959094A144403T", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-COC11055RA711503B-4YM959094A144403T/resend", + "rel": "resend", + "method": "POST" + } + ], + "zts": 1494957670, + "event_version": "1.0" + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e493d8*Host=ccg14amqunphttpdeliveryd47", + "client_pid": "760827", + "correlation-id": "d7b5e9cb06c81", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "689abcf0-ba1e-11ed-8635-fb7d730e54d3", + "paypal-transmission-sig": "IymBh75Dezm92b8XACF4mrXPWN8STab98czZHmd9BKrx0ty6SgJV/tkMmRORgebw/sXnvbgXSQDjv/kXrOsLWAR/A4jeU6ClIVKdfxaXeD4AJV6ufvepeQtfd91iClE1f2NSAKDrMCGmFtsfm/6XS9oTsHa/EGPH5ircl++7as3EfcTIMibDcieTpXIrdBJygrybMw4jNnV3inWD3UQ8ejhUCcKNICnMozAuQL0geWCURdvShrZZM0cKoukgK0oWNHecyVKjT81Dzh/2VCSFqazpOV3mCGGUo72le8LrisorDAha5KUsIf5iUrmpLHeFdWAfAlgqFrYU5xPvFD8obg==", + "paypal-transmission-time": "2023-03-03T23:52:08Z", + "x-b3-spanid": "51240d0a74d272b9" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "CHECKOUT.ORDER.SAVED", + "name": "CHECKOUT.ORDER.SAVED", + "body": { + "id": "WH-55TG7562XN2588878-8YH955435R661687G", + "create_time": "2018-05-03T22:20:32.000Z", + "event_type": "CHECKOUT.ORDER.SAVED", + "event_version": "1.0", + "resource_type": "order", + "resource_version": "2.0", + "summary": "Order 9NK63094MA726991F for 100 USD has been saved.", + "resource": { + "id": "9NK63094MA726991F", + "intent": "AUTHORIZE", + "purchase_units": [ + { + "reference_id": "REDFWE35G", + "amount": { + "currency_code": "USD", + "value": "100.00" + }, + "payee": { + "email_address": "pbehara-seller@paypal.com", + "merchant_id": "WDMEWZ63SR74G" + } + } + ], + "create_time": "2019-03-06T21:42:35Z", + "links": [ + { + "href": "https://api.paypal.com/v2/checkout/orders/9NK63094MA726991F", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/checkoutnow?token=9NK63094MA726991F", + "rel": "approve", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v2/checkout/orders/9NK63094MA726991F", + "rel": "update", + "method": "PATCH" + }, + { + "href": "https://api.paypal.com/v2/checkout/orders/9NK63094MA726991F/save", + "rel": "save", + "method": "POST" + } + ], + "status": "SAVED" + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-55TG7562XN2588878-8YH955435R661687G", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-55TG7562XN2588878-8YH955435R661687G/resend", + "rel": "resend", + "method": "POST" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e4ad8c*Host=ccg14amqunphttpdeliveryd66", + "client_pid": "763871", + "correlation-id": "26a1589f267c6", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "6b1fe130-ba1e-11ed-9ef6-d38729134cb6", + "paypal-transmission-sig": "gzPWDX/yJ5H1M/OIMiuHH6XzXMT05jy5JFopFr1hmDUxD7+pbevI6gcZSHOdW2FXAFZTF+iRbgzE0ny2lTLny0I/kGaiJ82nRsNNxz1XtchCC+LphRFpMTqSfO1CZWMLS4a1VWe8A//W899nWCmK8uBvuVwmpjbOhn2D1B0i627mKPT6fLiyT9mriUXD7/dOrRRVrV0CZh5tFdJyOWZ8wDIm7gqSfU60EJgLiR+mAgVtw1WtaX+IEBX9xVkTv/SpYkdattH9TBppRL5rNlEnghinLD2/xlO7Ve9g3RfsevSRCAizyAo3e9izbs7XwswsB8fakCK0B37cFkM4PzEKWQ==", + "paypal-transmission-time": "2023-03-03T23:52:12Z", + "x-b3-spanid": "8f603af310e885a6" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "CHECKOUT.ORDER.VOIDED", + "name": "CHECKOUT.ORDER.VOIDED", + "body": { + "id": "WH-55TG7562XN2588878-8YH955435R661687G", + "create_time": "2018-05-03T22:20:32.000Z", + "event_type": "CHECKOUT.ORDER.VOIDED", + "event_version": "1.0", + "resource_type": "order", + "resource_version": "2.0", + "summary": "An order 9NK63094MA726991F for 100 USD was voided.", + "resource": { + "id": "9NK63094MA726991F", + "intent": "AUTHORIZE", + "purchase_units": [ + { + "reference_id": "REDFWE35G", + "amount": { + "currency_code": "USD", + "value": "100.00" + }, + "payee": { + "email_address": "pbehara-seller@paypal.com", + "merchant_id": "WDMEWZ63SR74G" + } + } + ], + "create_time": "2019-03-06T21:42:35Z", + "status": "VOIDED", + "links": [ + { + "href": "https://api.paypal.com/v2/checkout/orders/9NK63094MA726991F", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/checkoutnow?token=9NK63094MA726991F", + "rel": "approve", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v2/checkout/orders/9NK63094MA726991F", + "rel": "update", + "method": "PATCH" + }, + { + "href": "https://api.paypal.com/v2/checkout/orders/9NK63094MA726991F/save", + "rel": "save", + "method": "POST" + } + ] + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-55TG7562XN2588878-8YH955435R661687G", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-55TG7562XN2588878-8YH955435R661687G/resend", + "rel": "resend", + "method": "POST" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e49aa4*Host=ccg13amqunphttpdeliveryd42", + "client_pid": "772760", + "correlation-id": "1e499050764f4", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "6dcd26e0-ba1e-11ed-b9b7-69139b55be60", + "paypal-transmission-sig": "YAt1roNA8zKecx+5+DLtjiNk+Vjwwbu+IQqLwBfBdboSKKFXoipQvj2EszkmIDX5Fee8HGT3ufzrDkQoJbFiZY5enaLgxB8Lm9zv673BIY86slYOAk1ixhfrNpz3bqU/31cCQVJttG5O0z76R9GezYMCbm+Q9iQVkR4Gw92MabPzXHX31NaJn9DssJHV+DDqqJKmmVAHJnPw05merbjDs+eQ0tA2jQgRsaJt7KdBNTAbSTjLesHhh81qQPwsJYXDScm9G0TMW9kWHfA0HNFYICeLeBwrv3HZHjpXCZMGeRVSWLc3aNzvgv7DkzISxp1pHyt5h935S89cH6Q3304pKQ==", + "paypal-transmission-time": "2023-03-03T23:52:17Z", + "x-b3-spanid": "e3e54ed340b26941" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "CUSTOMER-SUPPORT.CHARGEBACK.DECISION-RESPONDED", + "name": "CUSTOMER-SUPPORT.CHARGEBACK.DECISION-RESPONDED", + "body": { + "id": "WH-14672365205054426-9R575015BN3466209", + "event_version": "1.0", + "create_time": "2021-07-05T09:31:15.000Z", + "resource_type": "chargeback", + "event_type": "CUSTOMER-SUPPORT.CHARGEBACK.DECISION-RESPONDED", + "summary": "Chargeback decision response sent to tenant successfully.", + "resource": { + "transaction_id": null, + "id": "BTCBP96", + "stage": "CHARGEBACK", + "amount": { + "currency_code": "USD", + "value": "150" + }, + "originated_time": "2021-06-21T03:00:05Z", + "received_time": "2021-06-23T05:55:05Z", + "processor_reference_number": "WORLDLINE", + "response_by_time": "2021-07-09T13:30:07Z", + "contestation": { + "evidences": [ + { + "document_id": "001", + "document_name": "contestation document", + "document_source": "TENANT" + }, + { + "document_id": "002", + "document_name": "compelling evidence proof of delivery", + "document_source": "SELLER" + } + ], + "compelling_evidences": [ + { + "evidence_type": "PROOF_OF_DELIVERY" + }, + { + "evidence_type": "PROOF_OF_SAME_MERCHANDISE_NONDISPUTED", + "supporting_details": [ + { + "name": "ARN", + "value": "123abc" + }, + { + "name": "TRANSACTION_DATE", + "value": "2021-06-14" + }, + { + "name": "PHYSICAL_ADDRESS", + "value": "Seattle, WA" + } + ] + } + ], + "rebuttal_evidences": [ + { + "evidence_type": "SIGNED_ORDER_FORM" + } + ] + }, + "decision_notes": "We are contesting this chargeback", + "external_transaction_id": "567GssErT50", + "case_id": "345BhiJoLL", + "external_case_id": "NzM4MTAx123", + "decision": "CONTEST" + }, + "links": [ + { + "href": "https://msmaster.g.devqa.gcp.dev.paypalinc.com:14084/v1/notifications/webhooks-events/WH-14672365205054426-9R575015BN3466209", + "rel": "self", + "method": "GET" + }, + { + "href": "https://msmaster.g.devqa.gcp.dev.paypalinc.com:14084/v1/notifications/webhooks-events/WH-14672365205054426-9R575015BN3466209/resend", + "rel": "resend", + "method": "POST" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e43e6a*Host=ccg14amqunphttpdeliveryd40", + "client_pid": "764846", + "correlation-id": "8f57e30262ee7", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "72b0d490-ba1e-11ed-aae9-bfc5d90cc1da", + "paypal-transmission-sig": "Dy3H4ZMaVOtjQqCoKuqurt1XVRvlTLx9rf5/AP3VHb8Yxc0ZbGH6bcKZF5KmhRh7qcb+DIfNKfM7Kr4nYETHcYltQ7z0sXOueEZcvqezXiNsTIbEZHvIAv+cQEA6VuVNk7V4vbsgDO5elltdgnJIvP32Pu4PhzcIikH+fohV5LiG24yvLA6bkVs91LpODoqmyq+XY28l2+4rqokLxLQR72Wp0MMBT6aRTKS7rgNHGtzmhx7Ih7d0gw5sCZeDuUvKZHBvnpu9KJ7xjfucjX7jNzexAGwHIqn4BeJIbAOe4GiUnbVTOCHbJGjrxjNN+WuDfjVgGxph38NDnxvNC07YBg==", + "paypal-transmission-time": "2023-03-03T23:52:25Z", + "x-b3-spanid": "c83b4523cde72ef" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "CUSTOMER.DISPUTE.CREATED", + "name": "CUSTOMER.DISPUTE.CREATED", + "body": { + "id": "WH-4M0448861G563140B-9EX36365822141321", + "event_version": "1.0", + "create_time": "2018-06-21T13:36:33.000Z", + "resource_type": "dispute", + "event_type": "CUSTOMER.DISPUTE.CREATED", + "summary": "A new dispute opened with Case # PP-000-042-663-135", + "resource": { + "dispute_id": "PP-000-042-663-135", + "create_time": "2018-06-21T13:35:44.000Z", + "update_time": "2018-06-21T13:35:44.000Z", + "disputed_transactions": [ + { + "seller_transaction_id": "00D10444LD479031K", + "seller": { + "merchant_id": "RD465XN5VS364", + "name": "Test Store" + }, + "items": [], + "seller_protection_eligible": true + } + ], + "reason": "MERCHANDISE_OR_SERVICE_NOT_RECEIVED", + "status": "OPEN", + "dispute_amount": { + "currency_code": "USD", + "value": "3.00" + }, + "dispute_life_cycle_stage": "INQUIRY", + "dispute_channel": "INTERNAL", + "messages": [ + { + "posted_by": "BUYER", + "time_posted": "2018-06-21T13:35:52.000Z", + "content": "qwqwqwq" + } + ], + "links": [ + { + "href": "https://api.paypal.com/v1/customer/disputes/PP-000-042-663-135", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/customer/disputes/PP-000-042-663-135/send-message", + "rel": "send_message", + "method": "POST" + } + ] + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-4M0448861G563140B-9EX36365822141321", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-4M0448861G563140B-9EX36365822141321/resend", + "rel": "resend", + "method": "POST" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e2721a*Host=ccg14amqunphttpdeliveryd47", + "client_pid": "760267", + "correlation-id": "f0a9bcd1c3a20", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "12caf6f0-ba1e-11ed-8e1c-256216f79c11", + "paypal-transmission-sig": "krwAsHOnNUtYsqIAJQEQTgjSIeVdByhyQje27fXo4tKwaTCT7RLUGUaK9S2F595nM3LRd3jPjyDvtDSlEKGQX/eWkduyzMi8OpJjDRCwm/QkKMFNkUHtwmnjK2imd7cztpJgjFnU/g4sUz4WUessE7DgAoUKtjT2VG5usidY5SfYdTQ4ag/N4us8qFgp4jpubuCuBgi3NCyoKaxh5FVNCEyjV3HKBTKj1DPAr3bwdkMEuUIGcsisq+qa1M5Fqixk09W6SulKTDpNP4+3+TB9JsBAI5kR/CKvFoiP45RsGr1VmQLXTN3TOCyJpsyFC55nKf4X00Up+U+Ona9a6qehKQ==", + "paypal-transmission-time": "2023-03-03T23:49:44Z", + "x-b3-spanid": "24c1e9041a5ea4dc" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "CUSTOMER.MANAGED-ACCOUNT.UPDATED", + "name": "CUSTOMER.MANAGED-ACCOUNT.UPDATED", + "body": { + "id": "WH-3AE50806EM5305732-8C457463XB353952R", + "event_version": "1.0", + "create_time": "2018-05-24T07:18:28.264Z", + "resource_type": "managed-accounts", + "event_type": "CUSTOMER.MANAGED-ACCOUNT.UPDATED", + "summary": "A merchant account was updated.", + "resource": { + "payer_id": "7G4EPEEPEF74L", + "external_id": "demo-201@shopify.com", + "country_code": "US", + "email_id": "UNIQUE@GENERATED.com", + "name": { + "prefix": "Mr.", + "given_name": "John", + "surname": "Collins", + "middle_name": "Davis", + "suffix": "Jr" + }, + "addresses": [ + { + "type": "HOME", + "address_line_1": "150 E San Fernando St", + "address_line_2": "apt #1", + "admin_area_1": "CA", + "admin_area_2": "San Jose", + "postal_code": "95112", + "country_code": "US" + } + ], + "phones": [ + { + "type": "MOBILE", + "country_code": "1", + "national_number": "4089679174" + } + ], + "identifications": [ + { + "type": "DRIVERS_LICENSE", + "identifier": "G1111111", + "issuer": { + "country_code": "US", + "jurisdiction": "CA", + "authority": "DMV CA" + } + } + ], + "tax_id": { + "type": "SOCIAL_SECURITY_NUMBER", + "identifier": "123456789", + "issuer": { + "country_code": "US", + "authority": "IRS" + } + }, + "citizenship": "US", + "date_of_birth": "1990-01-01", + "business_info": { + "type": "INDIVIDUAL", + "names": [ + { + "type": "LEGAL", + "business_name": "Demo 201 Inc." + } + ], + "addresses": [ + { + "type": "WORK", + "address_line_1": "585 Franklin Str", + "address_line_2": "apt #1", + "admin_area_1": "CA", + "admin_area_2": "Mountain View", + "postal_code": "94041", + "country_code": "US" + } + ], + "email_address": "customer-service@demo201.com", + "phones": [ + { + "type": "CUSTOMER_SERVICE", + "country_code": "1", + "national_number": "4089673333" + } + ], + "identifications": [ + { + "type": "TAX_IDENTIFICATION_NUMBER", + "identifier": "111234501", + "issuer": { + "country_code": "US", + "authority": "IRS" + } + } + ], + "date_of_incorporation": "2001-01-17", + "business_category": { + "merchant_category_code": "3011" + }, + "website": "https://www.demo201.com", + "average_monthly_volume": { + "minimum_amount": { + "currency_code": "USD", + "value": "1000.00" + }, + "maximum_amount": { + "currency_code": "USD", + "value": "2000.00" + } + }, + "stakeholders": [ + { + "id": "ext_stakeholer_01", + "ownership_percentage": "100.0", + "type": "BENEFICIAL_OWNER", + "name": { + "prefix": "Mr.", + "given_name": "John", + "surname": "Collins", + "middle_name": "Davis", + "suffix": "Jr" + }, + "citizenship": "US", + "date_of_birth": "1990-01-01", + "addresses": [ + { + "address_line_1": "150 E San Fernando St", + "address_line_2": "apt #1", + "admin_area_1": "CA", + "admin_area_2": "San Jose", + "postal_code": "95112", + "country_code": "US" + } + ], + "phones": [ + { + "country_code": "1", + "national_number": "4089679174" + } + ], + "identifications": [ + { + "type": "DRIVERS_LICENSE", + "identifier": "G1111111", + "issuer": { + "country_code": "US", + "jurisdiction": "CA", + "authority": "DMV CA" + } + } + ], + "tax_id": { + "type": "SOCIAL_SECURITY_NUMBER", + "identifier": "123456789", + "issuer": { + "country_code": "US", + "authority": "IRS" + } + } + } + ] + }, + "primary_currency_code": "USD", + "secondary_currency_codes": ["CAD"], + "financial_instruments": { + "banks": [ + { + "mandate": { + "acceptance_time": "2018-05-12T01:23:45.678Z", + "accepted": true + }, + "id": "BA-KTJ7Z4FBMELZA", + "last_4_digits": "0937", + "account_number_type": "BASIC_BANK_ACCOUNT_NUMBER", + "account_type": "CHECKING", + "routing_number": "081500875", + "bank_name": "Wells Fargo Bank", + "bic": "WFBIUS6WFFX", + "account_holder_details": { + "name": "John Davis Collins", + "type": "BUSINESS" + } + } + ] + }, + "cc_soft_descriptor": "DEMO-201", + "terms_and_conditions_acceptance": { + "acceptance_time": "2018-05-12T01:23:45.678Z", + "accepted": true + }, + "organization": "us/shops", + "user_id": "demo-201FB", + "capabilities": [ + { + "name": "WITHDRAW_MONEY", + "status": "SUSPENDED", + "conditions": { + "threshold": "AMOUNT", + "threshold_value": "5000.00 USD", + "remaining_value": "1000.00 USD" + }, + "required_fields": [ + { + "name": "date_of_birth", + "link": { + "rel": "edit", + "href": "https://api.paypal.com/v2/customer/managed-accounts/7G4EPEEPEF74L", + "method": "PATCH" + } + } + ], + "required_documents": [ + { + "name": "PASSPORT", + "link": { + "rel": "upload", + "href": "https://api.paypal.com/v1/customer/supporting-documents/7G4EPEEPEF74LFG/upload", + "method": "POST" + } + } + ] + } + ], + "regulatory_obligations": [ + { + "name": "MARKETPLACES_KYC", + "status": "COMPLETED" + } + ], + "links": [ + { + "rel": "self", + "href": "https://api.paypal.com/v2/customer/managed-accounts/7G4EPEEPEF74L", + "method": "PATCH" + }, + { + "rel": "edit", + "href": "https://api.paypal.com/v2/customer/managed-accounts/7G4EPEEPEF74L", + "method": "PATCH" + } + ] + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notfications/webhooks-events/WH-3AE50806EM5305732-8C457463XB353952R", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/notfications/webhooks-events/WH-3AE50806EM5305732-8C457463XB353952R/resend", + "rel": "resend", + "method": "POST" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e237d2*Host=ccg14amqunphttpdeliveryd18", + "client_pid": "761923", + "correlation-id": "5e82bedfcbe34", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "16fe8e80-ba1e-11ed-a43c-7bbaa77c6dea", + "paypal-transmission-sig": "Htt7OSJ78RBzMAMHoAZdCsftVfAHeYUcGcFFG4Ek5l3ffoant99155FUn9kiLNopDxqcwZoDj4aVN/4XhI0dsGj5oETlOPqUXhC0IPL8Nn+2BP62YvFuYwYnDI6A4oG/q/WbgHg1TtmFe4+lPlXzQ1DZAQYKkTTFbPwQRbuGPt5eFGGolE6M8T9i8EOoMkJ6WA97/Zy/5Jxy0z1xQpoxAOEH2yRZfYtmsSyJPzqMicRXFkl9L6T84UoYkYxGsHpLjueDBwd6Pq0/k12vElj3E+Km8KRRJj+4+6Ckv43/iFYAIRxF/24hbU65sijyPLy66E0suSA+ia/QGaR4Mxg2Pg==", + "paypal-transmission-time": "2023-03-03T23:49:51Z", + "x-b3-spanid": "d5451fdbc4219a51" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "CUSTOMER.PAYOUT.COMPLETED", + "name": "CUSTOMER.PAYOUT.COMPLETED", + "body": { + "id": "WH-29H04174VA171590Y-15T39252MK897181P", + "event_version": "1.0", + "create_time": "2018-06-29T18:39:07.118Z", + "resource_type": "payouts", + "event_type": "CUSTOMER.PAYOUT.COMPLETED", + "summary": "Payout has completed.", + "resource": { + "id": "4324324DSFDSFDSF", + "merchant_id": "ZLFUUPW93YVFY", + "destination": { + "id": "CC-CBYJLKC9HGN7S", + "type": "FINANCIAL_INSTRUMENT_ID" + }, + "amount": { + "currency_code": "USD", + "value": "1500.00" + }, + "payout_status": { + "status": "COMPLETED" + }, + "create_time": "2018-06-29T18:38:38.000Z" + }, + "links": [ + { + "href": "https://stage2ma112402.pp-devqa-userstages.us-central1.gcp.dev.paypalinc.com:14084/v1/notifications/webhooks-events/WH-29H04174VA171590Y-15T39252MK897181P", + "rel": "self", + "method": "GET" + }, + { + "href": "https://stage2ma112402.pp-devqa-userstages.us-central1.gcp.dev.paypalinc.com:14084/v1/notifications/webhooks-events/WH-29H04174VA171590Y-15T39252MK897181P/resend", + "rel": "resend", + "method": "POST" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e4e04a*Host=ccg13amqunphttpdeliveryd34", + "client_pid": "651891", + "correlation-id": "0b53b22b6f914", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "83684f70-ba1e-11ed-a5f7-a56ac798bf6f", + "paypal-transmission-sig": "B7LpyBWXRUNrNeXoSh2TWNz0g1A0/qk1ggSljcrh/uPuhyaK+FEsXp6FqLzI3E+QY2/bsZF1Z75XBJiij2o8Ga+bMpFnmUV58vntJEzhCaC1D3SyL1Dag4pHwT3LkIogUJQMOubsWcu/qeGW2JE53hzfIFxFBvbxDEfYb3hHInHDwaYRBcZKV0zsyL7HmXNbZ19p1Iz/sQaNfP43dsl4q7nvMZBNaqj9znXIRHhoVwxJys2eNTB/LdD5xjIy73bp6VczQMCx9+TCdvhTiYR37p1oIUwZQ+MUHo9yw3O/YENAVUnIoH810FCos9v8qFX+2TGtyzN3+22yKMK6N61f6A==", + "paypal-transmission-time": "2023-03-03T23:52:53Z", + "x-b3-spanid": "75de47b3374e37be" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "MERCHANT.ONBOARDING.COMPLETED", + "name": "MERCHANT.ONBOARDING.COMPLETED", + "body": { + "id": "WH-2WR32451HC0233532-67976317FL4543714", + "create_time": "2016-08-01T21:41:28Z", + "resource_type": "merchant-onboarding", + "event_type": "MERCHANT.ONBOARDING.COMPLETED", + "resource": { + "merchant_id": "ELAMYJUN78D6G", + "partner_client_id": "AdZ3f8z-zIFM6t6Yilxvk3q4Iwx4ITRBjGR07q9wPJWms0z3G4ohajp7LdvpaLCeVLBmp5aVQ717YIw0", + "links": [ + { + "href": "https://uri.paypal.com/v1/customer/partners/QX4A9KG89BHLN/merchant-integrations/Y7MGFXVS8VHYU", + "rel": "self", + "method": "GET", + "description": "Get the merchant status information of merchants onboarded by this partner" + } + ] + } + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e4bdf4*Host=ccg13amqunphttpdeliveryd6", + "client_pid": "167487", + "correlation-id": "f41634d47ba6b", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "7f2c2c60-ba1e-11ed-ad84-09d4771a2e1a", + "paypal-transmission-sig": "QLnHj4AabUr3S/3yGP0/otWAJddFGhocASxIF57FsdST2O6517uCWpDR9JOw7HIwngITPGz4bYhxjI8+P9RLEiOUA/pR/Enj4eDkMD4qgM4Mo57apPtPcjdj5h7y1CmuIMbJjTC8weXW26YHkZAs5GooZnPRBR5xm8cRqWzwmuCVAdhu1WU3J6W8WrJvX3mCRDZn8oyoJuIXc0XK87imeeqWmYBh9mLqG2Au9MeYOD+eMilQ69t2eOonB9kWqsmoz5kgDxTCHxj/44JMaX4L0wwBbBJaN18nOEznVehrkwd6lmrA0a4ElCWjqlP2csjZdyZyKLjeQDDZ5hbyKpKhKw==", + "paypal-transmission-time": "2023-03-03T23:52:46Z", + "x-b3-spanid": "a1f179b009e5afdd" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "PAYMENT.SALE.COMPLETED", + "name": "PAYMENT.SALE.COMPLETED", + "body": { + "id": "WH-2WR32451HC0233532-67976317FL4543714", + "event_version": "1.0", + "create_time": "2014-10-23T17:23:52Z", + "resource_type": "sale", + "event_type": "PAYMENT.SALE.COMPLETED", + "summary": "A successful sale payment was made for $ 0.48 USD", + "resource": { + "id": "80021663DE681814L", + "create_time": "2014-10-23T17:22:56Z", + "update_time": "2014-10-23T17:23:04Z", + "amount": { + "total": "0.48", + "currency": "USD" + }, + "payment_mode": "ECHECK", + "state": "completed", + "protection_eligibility": "ELIGIBLE", + "protection_eligibility_type": "ITEM_NOT_RECEIVED_ELIGIBLE,UNAUTHORIZED_PAYMENT_ELIGIBLE", + "clearing_time": "2014-10-30T07:00:00Z", + "parent_payment": "PAY-1PA12106FU478450MKRETS4A", + "links": [ + { + "href": "https://api.paypal.com/v1/payments/sale/80021663DE681814L", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/payments/sale/80021663DE681814L/refund", + "rel": "refund", + "method": "POST" + }, + { + "href": "https://api.paypal.com/v1/payments/payment/PAY-1PA12106FU478450MKRETS4A", + "rel": "parent_payment", + "method": "GET" + } + ] + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-2WR32451HC0233532-67976317FL4543714", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-2WR32451HC0233532-67976317FL4543714/resend", + "rel": "resend", + "method": "POST" + } + ] + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e5b092*Host=ccg13amqunphttpdeliveryd65", + "client_pid": "19218", + "correlation-id": "90ca05ca559c5", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "8dc4e5f0-ba1e-11ed-981f-630f4c7537a9", + "paypal-transmission-sig": "SuRXs4DIW2/Tnt9DbbinkNGIpnjT6OnLrmCOAZNzSy6CHnBK+D8D5GX8Mtkww8Wh5MbYTqVXfsck0s8x6zxSi6jvsW4DG80PK/OAp4u9waJclS76swwWZgGXv/DS0/mPePkPj3bhQwDuhdIqv7CU08EVD1QirMRUuKfLaFOqp6kE7RfZQhGx3orkU6wrYVdIJ/dy3nVHJ03esMQBh46eKiTM19ZiYXEp2mriDMXOKuiTZBZM3qIZCXU8ggqhGF41B56qZlRaR5UeKUF/eTDBNeBNUvPh3rp0biol1vz4i0Ujg5UCtuH/cfqnGA7rFs7Sju5DkIOdRzutxXRxcpPPTA==", + "paypal-transmission-time": "2023-03-03T23:53:10Z", + "x-b3-spanid": "1ae9c8fed03065c5" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "PAYMENTS.PAYMENT.CREATED", + "name": "PAYMENTS.PAYMENT.CREATED", + "body": { + "id": "WH-6W4482673W002281V-61985753LP2332451", + "event_type": "PAYMENTS.PAYMENT.CREATED", + "resource_type": "payment", + "summary": "Checkout payment is created and approved by buyer", + "create_time": "2015-05-11T21:45:15Z", + "resource": { + "id": "PAY-13V79659LS5126423KVISFPI", + "intent": "sale", + "update_time": "2019-01-25T00:12:08Z", + "payer": { + "payment_method": "paypal", + "status": "UNVERIFIED", + "payer_info": { + "email": "customer@example.com", + "first_name": "John", + "last_name": "Doe", + "payer_id": "MKPXKVHEHVCYU", + "shipping_address": { + "recipient_name": "John Doe", + "line1": "2211 N. 1st Street", + "city": "San Jose", + "state": "CA", + "postal_code": "95131", + "country_code": "US" + }, + "country_code": "US" + } + }, + "transactions": [ + { + "amount": { + "total": "4.13", + "currency": "EUR", + "details": { + "subtotal": "4.00", + "tax": "0.13", + "handling_fee": "0.00" + } + }, + "payee": { + "merchant_id": "2WVGFDAU8TB78", + "email": "merchant@example.com" + }, + "description": "This is a PayPal transaction", + "item_list": { + "shipping_address": { + "recipient_name": "John Doe", + "line1": "2211 N. 1st Street", + "city": "San Jose", + "state": "CA", + "postal_code": "95131", + "country_code": "US" + } + }, + "related_resources": [] + } + ], + "create_time": "2019-01-25T00:11:05Z", + "links": [ + { + "href": "https://api.paypal.com/v1/payments/PAY-13V79659LS5126423KVISFPI", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/payments/PAY-13V79659LS5126423KVISFPI/execute", + "rel": "execute", + "method": "POST" + }, + { + "href": "https://www.paypal.com/cgi-bin/webscr?cmd=_express-checkout&token=EC-6BY44209VL795183H", + "rel": "approval_url", + "method": "REDIRECT" + } + ], + "state": "created" + }, + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-6W4482673W002281V-61985753LP2332451", + "rel": "self", + "method": "GET", + "encType": "application/json" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-6W4482673W002281V-61985753LP2332451/resend", + "rel": "resend", + "method": "POST", + "encType": "application/json" + } + ], + "event_version": "1.0" + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e5be3e*Host=ccg14amqunphttpdeliveryd57", + "client_pid": "762903", + "correlation-id": "7fe32506e782e", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "9190e210-ba1e-11ed-bff1-7d56760e7310", + "paypal-transmission-sig": "P9mnBYeDanD0MPvHL6pCa5bGNkFBRZ6qp8ziPsNUZippw9NgpBg1M5ZKeGf8fCcSHe12MIBFTeKB6ZzaErB6wE2kpbFX5XvmMV+DkgyNBDxZo6S2DYaWmQ/VQVKGyzW1MAhJSMrWBxvawK9NAlSZgZgbz8/IlbyEtAbHBo22pfRUveGIOSQU1GBAqRcMNsyPYBkg3R4JoCzFjKEK7bMhOqj0RqecaZlq/olU3+7ekOuU2359b2yx4Ayih8m74UE/nGtMHgrEDlmsbnEQcx4qIMIohps8odwt60WMEOGxBN1Pgx4nstHzAq/hF272l7paxr73KkeRBQ7Y6npWO7iYeQ==", + "paypal-transmission-time": "2023-03-03T23:53:17Z", + "x-b3-spanid": "81f79d7bad2c8162" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "TAXES.REPORTS.DELIVERED", + "name": "TAXES.REPORTS.DELIVERED", + "body": { + "id": "WH-COC11055RA711503B-4YM959094A144403T", + "event_type": "TAXES.REPORTS.DELIVERED", + "resource_type": "TAX-REPORT", + "resource": { + "id": "11EB-45D4-5A0062D0-90A2-64639F99100A", + "tenant_name": "VENMO", + "fiscal_year_ending_date": "2020-12-31", + "total_segment": 1, + "mode": "INITIAL", + "status": "GENERATION_COMPLETED", + "configuration": { + "report_config_group_id": "1006", + "report_config_id": "1009", + "major_type": "1099K", + "minor_type": "FEDERAL", + "version_number": "1.0", + "report_name": "SUMMARY", + "tenant_name": "VENMO" + }, + "segments": [ + { + "id": "11EB-45D4-6298B412-8DF2-2C2C7CA71900", + "sequence": 1, + "name": "95530618_1099K_2020_1688337239197326490_1608744006975.pdf", + "source_type": "DMS", + "links": [ + { + "href": "/v2/content/documents/CMP-044-4b23279f-58e4-4dd8-bf5e-8c267ea3ed0f/files/1/download", + "rel": "segment", + "method": "GET" + } + ] + } + ], + "recipients": [ + { + "user": { + "id": "11EB-45D4-5A0062D1-B71C-C0DCE3E4E435", + "external_id": "1688337239197326490", + "tenant_name": "VENMO", + "status": "DELIVERY_COMPLETED", + "segments": [ + { + "id": "11EB-45D4-6298B412-8DF2-2C2C7CA71900", + "status": "DELIVERY_COMPLETED" + }, + { + "id": "11EB-45D4-6298B412-8DF2-2C2C7CA71700", + "status": "DELIVERY_COMPLETED" + } + ] + } + } + ], + "links": [ + { + "href": "/v1/taxes/reports/11EB-45D4-5A0062D0-90A2-64639F99100A", + "rel": "self", + "method": "GET" + } + ] + } + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e5b7ea*Host=ccg14amqunphttpdeliveryd8", + "client_pid": "764546", + "correlation-id": "47d06fdd8ac92", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "9802f7f0-ba1e-11ed-bc3e-fb8dfbb8a217", + "paypal-transmission-sig": "DsTwmth/uGwsaGqvpuhpeOwMI5CNJZMVHgDIhjj4jOcJWYMGI6d2Q7PtfXOXYn8tV7X5niJIq+VSt+i8jCHZiPTU53JQYAKX6A+T8HvqMKHl6mavnHy6y58hRhEVmYonpx2nqteAT2A6MQSPrOo0n0U7dVf77zxx+VDCMV3lqiGOaj4RSWOnzFOPKgnGXffFE2szoIYHVOok91Hp9s33+oKiNRZKiMPhu5ACzKd8lNvnQhMUIHEwndqJrhPXNpBGs0g8HjA4a7/cKQebAmN5seGMe2LPYUZD0/ProXC4xDVq6fXywGnan1uXzjbEsJQxIQedbhAON3VVpvvvUTIxMw==", + "paypal-transmission-time": "2023-03-03T23:53:28Z", + "x-b3-spanid": "cbe311a1b6310de9" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "paypal", + "providerLabel": "Paypal", + "eventType": "VAULT.CREDIT-CARD.CREATED", + "name": "VAULT.CREDIT-CARD.CREATED", + "body": { + "id": "WH-2WR32451HC0233532-67976317FL4543714", + "create_time": "2015-07-20T21:41:28Z", + "resource_type": "vault-credit-card", + "event_type": "VAULT.CREDIT-CARD.CREATED", + "summary": "A credit card was created", + "resource": { + "id ": "CARD - 2 XR99321JH288614BK7SF2ZY ", + "state ": "ok ", + "type ": "amex ", + "number ": "xxxxxxxxxxx4009 ", + "expire_month ": "7 ", + "expire_year ": "2027 ", + "first_name ": "Cool ", + "last_name ": "Buyer ", + "billing_address ": { + "line1 ": "2211 N First Street ", + "city ": "San Jose ", + "state ": "CA ", + "postal_code ": "95131 ", + "country_code ": "US " + }, + "valid_until ": "2019 - 09 - 22 T00: 00: 00 Z ", + "create_time ": "2016 - 09 - 22 T22: 38: 31 Z ", + "update_time ": "2016 - 09 - 22 T22: 38: 31 Z ", + "links ": [ + { + "href ": "https: //api.paypal.com/v1/vault/credit-card/CARD-2XR99321JH288614BK7SF2ZY", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/vault/credit-card/CARD-2XR99321JH288614BK7SF2ZY", + "rel": "delete", + "method": "DELETE" + }, + { + "href": "https://api.paypal.com/v1/vault/credit-card/CARD-2XR99321JH288614BK7SF2ZY", + "rel": "patch", + "method": "PATCH" + } + ] + } + }, + "extraHeaders": { + "cal_poolstack": "amqunphttpdeliveryd:UNPHTDLVRYHA*CalThreadId=0*TopLevelTxnStartTime=186a9e2972c*Host=ccg14amqunphttpdeliveryd58", + "client_pid": "764671", + "correlation-id": "0fcc2887789f9", + "paypal-auth-algo": "SHA256withRSA", + "paypal-auth-version": "v2", + "paypal-cert-url": "https://api.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-38317689", + "paypal-transmission-id": "1ac27450-ba1e-11ed-a8c3-d774a01be652", + "paypal-transmission-sig": "LIoxCCBxrbnePbTRsdba4DZDxEWsWOcM48f2wH0g9mnjCxQS4F8LQLzsJuHy109JTtBz1+9Vn33IjFmJeyxCNbFlG5mc7O+PkMg76Bi2ERYHzkEycB9+J+QS/ttX2Jhvs1EEepqOiJapFadWt+y8d3C6uSBAVG5pnjMeuLgbyXXh2FeqoXeA5GmZotQETBrwLDfjDdUue+sB/OnAxIQgVBeQ0pO+nGrfHPsIV4WzJCxQc0F+tQRskWG/5+8dCXamQQOJkjuDW9RZhOpWpV3vIuczjSdh2I+1wfg6uMjHkpN4piejzm4da/Mta+cdeceZO31BVHgWQJ5+nX0vebODGw==", + "paypal-transmission-time": "2023-03-03T23:49:57Z", + "x-b3-spanid": "a0a43c9ef64b55e" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:paypal/1.0", + "snapshotDate": "2026-07" + } + }, + { + "provider": "pipedrive", + "providerLabel": "Pipedrive", + "eventType": "added.activity", + "name": "added.activity", + "body": { + "v": 1, + "matches_filters": { + "current": [] + }, + "meta": { + "action": "added", + "change_source": "app", + "company_id": 11149162, + "host": "hookwrite.pipedrive.com", + "id": 2, + "is_bulk_update": false, + "matches_filters": { + "current": [] + }, + "object": "activity", + "permitted_user_ids": [14542087], + "pipedrive_service_name": false, + "timestamp": 1680841422, + "timestamp_micro": 1680841422482110, + "prepublish_timestamp": 1680841422652, + "trans_pending": false, + "user_id": 14542087, + "v": 1, + "activity_notifications_language": null, + "send_activity_notifications": false, + "webhook_id": "174632" + }, + "current": { + "last_notification_time": null, + "location_street_number": null, + "type_name": "Call", + "reference_id": null, + "location_route": null, + "public_description": "", + "notification_language_id": null, + "subject": "Call", + "updates_story_id": null, + "type": "call", + "project_id": null, + "deal_title": null, + "original_start_time": null, + "id": 2, + "deal_id": null, + "busy_flag": false, + "person_id": 3, + "owner_name": "Hook Write", + "attendees": null, + "person_name": "Anthony Edwards", + "project_title": null, + "rec_rule_extension": null, + "done": false, + "created_by_user_id": 14542087, + "location_sublocality": null, + "rec_rule": null, + "location_admin_area_level_2": null, + "user_id": 14542087, + "location_admin_area_level_1": null, + "org_id": 2, + "conference_meeting_client": null, + "note": null, + "due_time": "", + "rec_master_activity_id": null, + "location_country": null, + "active_flag": true, + "duration": "", + "location_postal_code": null, + "update_time": "2023-04-07 04:23:42", + "update_user_id": null, + "lead_title": null, + "source_timezone": null, + "person_dropbox_bcc": "hookwrite@pipedrivemail.com", + "conference_meeting_id": null, + "org_name": "NoCo Solutions", + "location_locality": null, + "assigned_to_user_id": 14542087, + "lead_id": null, + "is_recurring": null, + "participants": [ + { + "primary_flag": true, + "person_id": 3 + } + ], + "location_subpremise": null, + "company_id": 11149162, + "due_date": "2023-04-07", + "lead": null, + "reference_type": null, + "last_notification_user_id": null, + "calendar_sync_include_context": null, + "marked_as_done_time": "", + "location_formatted_address": null, + "series": null, + "conference_meeting_url": null, + "location": null, + "deal_dropbox_bcc": null, + "add_time": "2023-04-07 04:23:42" + }, + "previous": null, + "event": "added.activity", + "retry": 0 + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:pipedrive/1", + "snapshotDate": "2026-07" + } + }, + { + "provider": "pipedrive", + "providerLabel": "Pipedrive", + "eventType": "added.deal", + "name": "added.deal", + "body": { + "v": 1, + "matches_filters": { + "current": [] + }, + "meta": { + "action": "added", + "change_source": "app", + "company_id": 11149162, + "host": "hookwrite.pipedrive.com", + "id": 1, + "is_bulk_update": false, + "matches_filters": { + "current": [] + }, + "object": "deal", + "permitted_user_ids": [14542087], + "pipedrive_service_name": false, + "timestamp": 1680840600, + "timestamp_micro": 1680840600460452, + "prepublish_timestamp": 1680840600800, + "trans_pending": false, + "user_id": 14542087, + "v": 1, + "webhook_id": "174632" + }, + "current": { + "email_messages_count": 0, + "cc_email": "hookwrite+deal1@pipedrivemail.com", + "products_count": 0, + "next_activity_date": null, + "next_activity_type": null, + "next_activity_duration": null, + "id": 1, + "person_id": 3, + "creator_user_id": 14542087, + "expected_close_date": "2023-05-30", + "owner_name": "Hook Write", + "participants_count": 0, + "stage_id": 1, + "probability": null, + "undone_activities_count": 0, + "renewal_type": "one_time", + "active": true, + "person_name": "Anthony Edwards", + "last_activity_date": null, + "close_time": null, + "org_hidden": false, + "next_activity_id": null, + "weighted_value_currency": "XAF", + "stage_order_nr": 0, + "next_activity_subject": null, + "rotten_time": null, + "user_id": 14542087, + "visible_to": "3", + "org_id": 2, + "notes_count": 0, + "next_activity_time": null, + "formatted_value": "FCFA 2,000,000", + "status": "open", + "formatted_weighted_value": "FCFA 2,000,000", + "first_won_time": null, + "last_outgoing_mail_time": null, + "title": "NoCo Solutions deal", + "last_activity_id": null, + "update_time": "2023-04-07 04:10:00", + "activities_count": 0, + "pipeline_id": 1, + "lost_time": null, + "currency": "XAF", + "weighted_value": 2000000, + "org_name": "NoCo Solutions", + "value": 2000000, + "person_hidden": false, + "next_activity_note": null, + "files_count": 0, + "last_incoming_mail_time": null, + "label": null, + "lost_reason": null, + "deleted": false, + "won_time": null, + "followers_count": 0, + "stage_change_time": null, + "add_time": "2023-04-07 04:10:00", + "done_activities_count": 0 + }, + "previous": null, + "event": "added.deal", + "retry": 0 + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:pipedrive/1", + "snapshotDate": "2026-07" + } + }, + { + "provider": "pipedrive", + "providerLabel": "Pipedrive", + "eventType": "added.organization", + "name": "added.organization", + "body": { + "v": 1, + "matches_filters": { + "current": [] + }, + "meta": { + "action": "added", + "change_source": "app", + "company_id": 11149162, + "host": "hookwrite.pipedrive.com", + "id": 2, + "is_bulk_update": false, + "matches_filters": { + "current": [] + }, + "object": "organization", + "permitted_user_ids": [14542087], + "pipedrive_service_name": false, + "timestamp": 1680840510, + "timestamp_micro": 1680840510932610, + "prepublish_timestamp": 1680840511087, + "trans_pending": false, + "user_id": 14542087, + "v": 1, + "webhook_id": "174632" + }, + "current": { + "address_route": null, + "related_closed_deals_count": 0, + "email_messages_count": 0, + "cc_email": "hookwrite@pipedrivemail.com", + "owner_id": 14542087, + "open_deals_count": 0, + "people_count": 0, + "next_activity_date": null, + "id": 2, + "address_admin_area_level_2": null, + "address_admin_area_level_1": null, + "owner_name": "Hook Write", + "address_formatted_address": null, + "related_won_deals_count": 0, + "undone_activities_count": 0, + "address_subpremise": null, + "last_activity_date": null, + "next_activity_id": null, + "country_code": null, + "visible_to": "3", + "notes_count": 0, + "name": "NoCo Solutions", + "address_locality": null, + "lost_deals_count": 0, + "next_activity_time": null, + "delete_time": null, + "active_flag": true, + "picture_id": null, + "last_activity_id": null, + "update_time": "2023-04-07 04:08:30", + "category_id": null, + "activities_count": 0, + "won_deals_count": 0, + "address_street_number": null, + "address": null, + "edit_name": true, + "files_count": 0, + "company_id": 11149162, + "address_postal_code": null, + "address_country": null, + "first_char": "n", + "closed_deals_count": 0, + "label": null, + "related_lost_deals_count": 0, + "related_open_deals_count": 0, + "followers_count": 0, + "address_sublocality": null, + "add_time": "2023-04-07 04:08:30", + "done_activities_count": 0 + }, + "previous": null, + "event": "added.organization", + "retry": 0 + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:pipedrive/1", + "snapshotDate": "2026-07" + } + }, + { + "provider": "pipedrive", + "providerLabel": "Pipedrive", + "eventType": "added.person", + "name": "added.person", + "body": { + "v": 1, + "matches_filters": { + "current": [] + }, + "meta": { + "action": "added", + "change_source": "app", + "company_id": 11149162, + "host": "hookwrite.pipedrive.com", + "id": 3, + "is_bulk_update": false, + "matches_filters": { + "current": [] + }, + "object": "person", + "permitted_user_ids": [14542087], + "pipedrive_service_name": false, + "timestamp": 1680840511, + "timestamp_micro": 1680840511329055, + "prepublish_timestamp": 1680840512203, + "trans_pending": false, + "user_id": 14542087, + "v": 1, + "webhook_id": "174632" + }, + "current": { + "related_closed_deals_count": 0, + "delete_time": null, + "email_messages_count": 0, + "cc_email": "hookwrite@pipedrivemail.com", + "owner_id": 14542087, + "open_deals_count": 0, + "last_outgoing_mail_time": null, + "active_flag": true, + "picture_id": null, + "last_activity_id": null, + "next_activity_date": null, + "update_time": "2023-04-07 04:08:31", + "participant_open_deals_count": 0, + "activities_count": 0, + "id": 3, + "org_name": "NoCo Solutions", + "first_name": "Anthony", + "email": [ + { + "label": "work", + "value": "anthon@noco.com", + "primary": true + } + ], + "won_deals_count": 0, + "owner_name": "Hook Write", + "files_count": 0, + "company_id": 11149162, + "related_won_deals_count": 0, + "last_incoming_mail_time": null, + "first_char": "a", + "undone_activities_count": 0, + "closed_deals_count": 0, + "last_name": "Edwards", + "last_activity_date": null, + "label": 5, + "next_activity_id": null, + "related_lost_deals_count": 0, + "related_open_deals_count": 0, + "phone": [ + { + "label": "work", + "value": "+1434993473", + "primary": true + } + ], + "visible_to": "3", + "org_id": 2, + "notes_count": 0, + "followers_count": 0, + "name": "Anthony Edwards", + "participant_closed_deals_count": 0, + "primary_email": "anthon@noco.com", + "lost_deals_count": 0, + "next_activity_time": null, + "add_time": "2023-04-07 04:08:31", + "done_activities_count": 0 + }, + "previous": null, + "event": "added.person", + "retry": 0 + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:pipedrive/1", + "snapshotDate": "2026-07" + } + }, + { + "provider": "pipedrive", + "providerLabel": "Pipedrive", + "eventType": "added.product", + "name": "added.product", + "body": { + "v": 1, + "matches_filters": { + "current": [] + }, + "meta": { + "action": "added", + "change_source": "app", + "company_id": 11149162, + "host": "hookwrite.pipedrive.com", + "id": 1, + "is_bulk_update": false, + "matches_filters": { + "current": [] + }, + "object": "product", + "permitted_user_ids": [14542087], + "pipedrive_service_name": false, + "timestamp": 1680841164, + "timestamp_micro": 1680841164749820, + "prepublish_timestamp": 1680841164880, + "trans_pending": false, + "user_id": 14542087, + "v": 1, + "webhook_id": "174632" + }, + "current": { + "code": "00023", + "files_count": null, + "owner_id": 14542087, + "first_char": "s", + "selectable": true, + "description": null, + "tax": 0, + "active_flag": true, + "unit": "233", + "update_time": "2023-04-07 04:19:24", + "visible_to": "3", + "followers_count": 0, + "name": "Survey consultancy", + "product_variations": [], + "id": 1, + "prices": [ + { + "cost": 0, + "price": 300000, + "product_id": 1, + "overhead_cost": 0, + "currency": "XAF", + "id": 1, + "price_formatted": "XAF300,000" + } + ], + "category": "15", + "add_time": "2023-04-07 04:19:24" + }, + "previous": null, + "event": "added.product", + "retry": 0 + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:pipedrive/1", + "snapshotDate": "2026-07" + } + }, + { + "provider": "pipedrive", + "providerLabel": "Pipedrive", + "eventType": "deleted.activity", + "name": "deleted.activity", + "body": { + "v": 1, + "matches_filters": { + "current": [] + }, + "meta": { + "action": "deleted", + "change_source": "api", + "company_id": 11149162, + "host": "hookwrite.pipedrive.com", + "id": 2, + "is_bulk_update": true, + "matches_filters": { + "current": [] + }, + "object": "activity", + "permitted_user_ids": [14542087], + "pipedrive_service_name": false, + "timestamp": 1680841432, + "timestamp_micro": 1680841432612516, + "prepublish_timestamp": 1680841432685, + "trans_pending": false, + "user_id": 14542087, + "v": 1, + "webhook_id": "174632" + }, + "current": null, + "previous": { + "last_notification_time": null, + "location_street_number": null, + "type_name": "Call", + "reference_id": null, + "location_route": null, + "public_description": "", + "notification_language_id": null, + "subject": "Call", + "type": "call", + "project_id": null, + "deal_title": null, + "original_start_time": null, + "id": 2, + "deal_id": null, + "busy_flag": false, + "person_id": 3, + "owner_name": "Hook Write", + "attendees": null, + "person_name": "Anthony Edwards", + "project_title": null, + "rec_rule_extension": null, + "done": false, + "created_by_user_id": 14542087, + "location_sublocality": null, + "rec_rule": null, + "location_admin_area_level_2": null, + "user_id": 14542087, + "location_admin_area_level_1": null, + "org_id": 2, + "conference_meeting_client": null, + "note": null, + "due_time": "", + "rec_master_activity_id": null, + "location_country": null, + "active_flag": true, + "duration": "", + "location_postal_code": null, + "update_time": "2023-04-07 04:23:42", + "update_user_id": null, + "lead_title": null, + "source_timezone": null, + "person_dropbox_bcc": "hookwrite@pipedrivemail.com", + "conference_meeting_id": null, + "org_name": "NoCo Solutions", + "location_locality": null, + "assigned_to_user_id": 14542087, + "lead_id": null, + "is_recurring": null, + "participants": [ + { + "primary_flag": true, + "person_id": 3 + } + ], + "location_subpremise": null, + "company_id": 11149162, + "due_date": "2023-04-07", + "lead": null, + "reference_type": null, + "last_notification_user_id": null, + "calendar_sync_include_context": null, + "marked_as_done_time": "", + "location_formatted_address": null, + "series": null, + "conference_meeting_url": null, + "location": null, + "deal_dropbox_bcc": null, + "add_time": "2023-04-07 04:23:42" + }, + "event": "deleted.activity", + "retry": 0 + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:pipedrive/1", + "snapshotDate": "2026-07" + } + }, + { + "provider": "pipedrive", + "providerLabel": "Pipedrive", + "eventType": "deleted.person", + "name": "deleted.person", + "body": { + "v": 1, + "matches_filters": { + "current": [] + }, + "meta": { + "action": "deleted", + "change_source": "api", + "company_id": 11149162, + "host": "hookwrite.pipedrive.com", + "id": 2, + "is_bulk_update": true, + "matches_filters": { + "current": [] + }, + "object": "person", + "permitted_user_ids": [14542087], + "pipedrive_service_name": false, + "timestamp": 1680841339, + "timestamp_micro": 1680841339437193, + "prepublish_timestamp": 1680841339479, + "trans_pending": false, + "user_id": 14542087, + "v": 1, + "webhook_id": "174632" + }, + "current": null, + "previous": { + "related_closed_deals_count": 0, + "delete_time": null, + "email_messages_count": 0, + "cc_email": "hookwrite@pipedrivemail.com", + "owner_id": 14542087, + "open_deals_count": 0, + "last_outgoing_mail_time": null, + "active_flag": true, + "picture_id": null, + "last_activity_id": null, + "next_activity_date": null, + "update_time": "2023-04-07 04:09:19", + "participant_open_deals_count": 0, + "activities_count": 0, + "id": 2, + "org_name": "Demi", + "first_name": "Oluwa", + "email": [ + { + "label": "work", + "value": "magnify@mail.com", + "primary": true + } + ], + "won_deals_count": 0, + "files_count": 0, + "company_id": 11149162, + "related_won_deals_count": 0, + "last_incoming_mail_time": null, + "first_char": "o", + "undone_activities_count": 0, + "closed_deals_count": 0, + "last_name": "Tomi", + "picture_128_url": null, + "last_activity_date": null, + "label": null, + "next_activity_id": null, + "related_lost_deals_count": 0, + "related_open_deals_count": 0, + "phone": [ + { + "label": "work", + "value": "+125433437", + "primary": true + } + ], + "visible_to": "3", + "org_id": 1, + "notes_count": 0, + "followers_count": 1, + "name": "Oluwa Tomi", + "participant_closed_deals_count": 0, + "lost_deals_count": 0, + "next_activity_time": null, + "add_time": "2023-04-06 19:24:38", + "done_activities_count": 0 + }, + "event": "deleted.person", + "retry": 0 + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:pipedrive/1", + "snapshotDate": "2026-07" + } + }, + { + "provider": "pipedrive", + "providerLabel": "Pipedrive", + "eventType": "updated.activity", + "name": "updated.activity", + "body": { + "v": 1, + "matches_filters": { + "current": [] + }, + "meta": { + "action": "updated", + "change_source": "app", + "company_id": 11149162, + "host": "hookwrite.pipedrive.com", + "id": 1, + "is_bulk_update": false, + "matches_filters": { + "current": [] + }, + "object": "activity", + "permitted_user_ids": [14542087], + "pipedrive_service_name": false, + "timestamp": 1680841419, + "timestamp_micro": 1680841419576234, + "prepublish_timestamp": 1680841419764, + "trans_pending": false, + "user_id": 14542087, + "v": 1, + "activity_notifications_language": null, + "send_activity_notifications": false, + "webhook_id": "174632" + }, + "current": { + "last_notification_time": null, + "location_street_number": null, + "type_name": "Call", + "reference_id": null, + "location_route": null, + "public_description": "", + "notification_language_id": null, + "subject": "Call", + "type": "call", + "project_id": null, + "deal_title": null, + "original_start_time": null, + "id": 1, + "deal_id": null, + "busy_flag": false, + "person_id": 3, + "owner_name": "Hook Write", + "attendees": null, + "person_name": "Anthony Edwards", + "project_title": null, + "rec_rule_extension": null, + "done": true, + "created_by_user_id": 14542087, + "location_sublocality": null, + "rec_rule": null, + "location_admin_area_level_2": null, + "user_id": 14542087, + "location_admin_area_level_1": null, + "org_id": 2, + "conference_meeting_client": null, + "note": null, + "due_time": "", + "rec_master_activity_id": null, + "location_country": null, + "active_flag": true, + "duration": "", + "location_postal_code": null, + "update_time": "2023-04-07 04:23:39", + "update_user_id": 14542087, + "lead_title": null, + "source_timezone": null, + "person_dropbox_bcc": "hookwrite@pipedrivemail.com", + "conference_meeting_id": null, + "org_name": "NoCo Solutions", + "location_locality": null, + "assigned_to_user_id": 14542087, + "lead_id": null, + "is_recurring": null, + "participants": [ + { + "primary_flag": true, + "person_id": 3 + } + ], + "location_subpremise": null, + "company_id": 11149162, + "due_date": "2023-04-07", + "lead": null, + "reference_type": null, + "last_notification_user_id": null, + "calendar_sync_include_context": null, + "marked_as_done_time": "2023-04-07 04:23:39", + "location_formatted_address": null, + "series": null, + "conference_meeting_url": null, + "location": null, + "deal_dropbox_bcc": null, + "add_time": "2023-04-07 04:23:25" + }, + "previous": { + "last_notification_time": null, + "local_sorting_due_date": null, + "location_street_number": null, + "type_name": "Call", + "reference_id": null, + "location_route": null, + "public_description": "", + "notification_language_id": null, + "subject": "Call", + "type": "call", + "project_id": null, + "deal_title": null, + "original_start_time": null, + "activity_note": null, + "id": 1, + "deal_id": null, + "busy_flag": false, + "person_id": 3, + "owner_name": "Hook Write", + "attendees": null, + "person_name": "Anthony Edwards", + "project_title": null, + "rec_rule_extension": null, + "done": false, + "created_by_user_id": 14542087, + "location_sublocality": null, + "rec_rule": null, + "activityNote": null, + "location_admin_area_level_2": null, + "user_id": 14542087, + "location_admin_area_level_1": null, + "org_id": 2, + "conference_meeting_client": null, + "note": null, + "due_time": "", + "rec_master_activity_id": null, + "location_country": null, + "active_flag": true, + "duration": "", + "location_postal_code": null, + "update_time": "2023-04-07 04:23:25", + "service_reference": [], + "update_user_id": null, + "lead_title": null, + "source_timezone": null, + "person_dropbox_bcc": "hookwrite@pipedrivemail.com", + "conference_meeting_id": null, + "org_name": "NoCo Solutions", + "location_locality": null, + "assigned_to_user_id": 14542087, + "lead_id": null, + "is_recurring": null, + "participants": [ + { + "primary_flag": true, + "person_id": 3 + } + ], + "location_subpremise": null, + "company_id": 11149162, + "org": null, + "due_date": "2023-04-07", + "lead": null, + "reference_type": null, + "last_notification_user_id": null, + "calendar_sync_include_context": null, + "marked_as_done_time": "", + "location_formatted_address": null, + "series": null, + "conference_meeting_url": null, + "location": null, + "deal_dropbox_bcc": null, + "user": { + "created": null, + "last_login": null, + "has_pic": null, + "active_flag": true, + "locale": null, + "pic_hash": null, + "update_time": "2023-04-06 18:05:40", + "role_id": null, + "name": "Hook Write", + "timezone_name": null, + "modified": null, + "id": 14542087, + "default_currency": null, + "add_time": "2023-04-06 18:05:40", + "email": null + }, + "add_time": "2023-04-07 04:23:25" + }, + "event": "updated.activity", + "retry": 0 + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:pipedrive/1", + "snapshotDate": "2026-07" + } + }, + { + "provider": "pipedrive", + "providerLabel": "Pipedrive", + "eventType": "updated.deal", + "name": "updated.deal", + "body": { + "v": 1, + "matches_filters": { + "current": [] + }, + "meta": { + "action": "updated", + "change_source": "app", + "company_id": 11149162, + "host": "hookwrite.pipedrive.com", + "id": 1, + "is_bulk_update": false, + "matches_filters": { + "current": [] + }, + "object": "deal", + "permitted_user_ids": [14542087], + "pipedrive_service_name": false, + "timestamp": 1680840600, + "timestamp_micro": 1680840600882126, + "prepublish_timestamp": 1680840601081, + "trans_pending": false, + "user_id": 14542087, + "v": 1, + "webhook_id": "174632" + }, + "current": { + "email_messages_count": 0, + "cc_email": "hookwrite+deal1@pipedrivemail.com", + "products_count": 0, + "next_activity_date": null, + "next_activity_type": null, + "next_activity_duration": null, + "id": 1, + "person_id": 3, + "creator_user_id": 14542087, + "expected_close_date": "2023-05-30", + "owner_name": "Hook Write", + "participants_count": 1, + "stage_id": 1, + "probability": null, + "undone_activities_count": 0, + "renewal_type": "one_time", + "active": true, + "person_name": "Anthony Edwards", + "last_activity_date": null, + "close_time": null, + "org_hidden": false, + "next_activity_id": null, + "weighted_value_currency": "XAF", + "stage_order_nr": 0, + "next_activity_subject": null, + "rotten_time": null, + "user_id": 14542087, + "visible_to": "3", + "org_id": 2, + "notes_count": 0, + "next_activity_time": null, + "formatted_value": "FCFA 2,000,000", + "status": "open", + "formatted_weighted_value": "FCFA 2,000,000", + "first_won_time": null, + "last_outgoing_mail_time": null, + "title": "NoCo Solutions deal", + "last_activity_id": null, + "update_time": "2023-04-07 04:10:00", + "last_activity": null, + "next_activity": null, + "activities_count": 0, + "pipeline_id": 1, + "lost_time": null, + "currency": "XAF", + "weighted_value": 2000000, + "org_name": "NoCo Solutions", + "value": 2000000, + "person_hidden": false, + "next_activity_note": null, + "files_count": 0, + "last_incoming_mail_time": null, + "label": null, + "lost_reason": null, + "deleted": false, + "won_time": null, + "followers_count": 1, + "stage_change_time": null, + "add_time": "2023-04-07 04:10:00", + "done_activities_count": 0 + }, + "previous": { + "email_messages_count": 0, + "cc_email": "hookwrite+deal1@pipedrivemail.com", + "products_count": 0, + "next_activity_date": null, + "next_activity_type": null, + "next_activity_duration": null, + "id": 1, + "person_id": 3, + "creator_user_id": 14542087, + "expected_close_date": "2023-05-30", + "owner_name": "Hook Write", + "participants_count": 0, + "stage_id": 1, + "probability": null, + "undone_activities_count": 0, + "renewal_type": "one_time", + "active": true, + "person_name": "Anthony Edwards", + "last_activity_date": null, + "close_time": null, + "org_hidden": false, + "next_activity_id": null, + "weighted_value_currency": "XAF", + "stage_order_nr": 0, + "next_activity_subject": null, + "rotten_time": null, + "user_id": 14542087, + "visible_to": "3", + "org_id": 2, + "notes_count": 0, + "next_activity_time": null, + "formatted_value": "FCFA 2,000,000", + "status": "open", + "formatted_weighted_value": "FCFA 2,000,000", + "first_won_time": null, + "last_outgoing_mail_time": null, + "title": "NoCo Solutions deal", + "last_activity_id": null, + "update_time": "2023-04-07 04:10:00", + "last_activity": null, + "next_activity": null, + "activities_count": 0, + "pipeline_id": 1, + "lost_time": null, + "currency": "XAF", + "weighted_value": 2000000, + "org_name": "NoCo Solutions", + "value": 2000000, + "person_hidden": false, + "next_activity_note": null, + "files_count": 0, + "last_incoming_mail_time": null, + "label": null, + "lost_reason": null, + "deleted": false, + "won_time": null, + "followers_count": 1, + "stage_change_time": null, + "add_time": "2023-04-07 04:10:00", + "done_activities_count": 0 + }, + "event": "updated.deal", + "retry": 0 + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:pipedrive/1", + "snapshotDate": "2026-07" + } + }, + { + "provider": "pipedrive", + "providerLabel": "Pipedrive", + "eventType": "updated.organization", + "name": "updated.organization", + "body": { + "v": 1, + "matches_filters": { + "current": [] + }, + "meta": { + "action": "updated", + "change_source": "api", + "company_id": 11149162, + "host": "hookwrite.pipedrive.com", + "id": 2, + "is_bulk_update": true, + "matches_filters": { + "current": [] + }, + "object": "organization", + "permitted_user_ids": [14542087], + "pipedrive_service_name": false, + "timestamp": 1680841432, + "timestamp_micro": 1680841432637031, + "prepublish_timestamp": 1680841432700, + "trans_pending": false, + "user_id": 14542087, + "v": 1, + "webhook_id": "174632" + }, + "current": { + "address_route": null, + "related_closed_deals_count": 0, + "email_messages_count": 0, + "cc_email": "hookwrite@pipedrivemail.com", + "owner_id": 14542087, + "open_deals_count": 1, + "people_count": 1, + "next_activity_date": null, + "id": 2, + "address_admin_area_level_2": null, + "address_admin_area_level_1": null, + "owner_name": "Hook Write", + "address_formatted_address": null, + "related_won_deals_count": 0, + "undone_activities_count": 0, + "address_subpremise": null, + "last_activity_date": "2023-04-07", + "next_activity_id": null, + "country_code": null, + "visible_to": "3", + "notes_count": 0, + "name": "NoCo Solutions", + "address_locality": null, + "lost_deals_count": 0, + "next_activity_time": null, + "delete_time": null, + "active_flag": true, + "picture_id": null, + "last_activity_id": 1, + "update_time": "2023-04-07 04:23:52", + "category_id": null, + "activities_count": 1, + "won_deals_count": 0, + "address_street_number": null, + "address": null, + "edit_name": true, + "files_count": 0, + "company_id": 11149162, + "address_postal_code": null, + "address_country": null, + "first_char": "n", + "closed_deals_count": 0, + "label": null, + "related_lost_deals_count": 0, + "related_open_deals_count": 0, + "followers_count": 1, + "address_sublocality": null, + "add_time": "2023-04-07 04:08:30", + "done_activities_count": 1 + }, + "previous": { + "address_route": null, + "related_closed_deals_count": 0, + "email_messages_count": 0, + "cc_email": "hookwrite@pipedrivemail.com", + "owner_id": 14542087, + "open_deals_count": 1, + "people_count": 1, + "next_activity_date": "2023-04-07", + "id": 2, + "address_admin_area_level_2": null, + "address_admin_area_level_1": null, + "owner_name": "Hook Write", + "address_formatted_address": null, + "related_won_deals_count": 0, + "undone_activities_count": 1, + "address_subpremise": null, + "last_activity_date": "2023-04-07", + "next_activity_id": 2, + "country_code": null, + "visible_to": "3", + "notes_count": 0, + "name": "NoCo Solutions", + "address_locality": null, + "lost_deals_count": 0, + "next_activity_time": null, + "delete_time": null, + "active_flag": true, + "picture_id": null, + "last_activity_id": 1, + "update_time": "2023-04-07 04:23:42", + "category_id": null, + "activities_count": 2, + "won_deals_count": 0, + "address_street_number": null, + "address": null, + "edit_name": true, + "files_count": 0, + "company_id": 11149162, + "address_postal_code": null, + "address_country": null, + "first_char": "n", + "closed_deals_count": 0, + "label": null, + "related_lost_deals_count": 0, + "related_open_deals_count": 0, + "followers_count": 1, + "address_sublocality": null, + "add_time": "2023-04-07 04:08:30", + "done_activities_count": 1 + }, + "event": "updated.organization", + "retry": 0 + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:pipedrive/1", + "snapshotDate": "2026-07" + } + }, + { + "provider": "pipedrive", + "providerLabel": "Pipedrive", + "eventType": "updated.person", + "name": "updated.person", + "body": { + "v": 1, + "matches_filters": { + "current": [] + }, + "meta": { + "action": "updated", + "change_source": "api", + "company_id": 11149162, + "host": "hookwrite.pipedrive.com", + "id": 3, + "is_bulk_update": true, + "matches_filters": { + "current": [] + }, + "object": "person", + "permitted_user_ids": [14542087], + "pipedrive_service_name": false, + "timestamp": 1680841432, + "timestamp_micro": 1680841432652693, + "prepublish_timestamp": 1680841432827, + "trans_pending": false, + "user_id": 14542087, + "v": 1, + "webhook_id": "174632" + }, + "current": { + "related_closed_deals_count": 0, + "delete_time": null, + "email_messages_count": 0, + "cc_email": "hookwrite@pipedrivemail.com", + "owner_id": 14542087, + "open_deals_count": 1, + "last_outgoing_mail_time": null, + "active_flag": true, + "picture_id": null, + "last_activity_id": 1, + "next_activity_date": null, + "update_time": "2023-04-07 04:23:52", + "participant_open_deals_count": 0, + "activities_count": 1, + "id": 3, + "org_name": "NoCo Solutions", + "first_name": "Anthony", + "email": [ + { + "label": "work", + "value": "anthon@noco.com", + "primary": true + } + ], + "won_deals_count": 0, + "owner_name": "Hook Write", + "files_count": 0, + "company_id": 11149162, + "related_won_deals_count": 0, + "last_incoming_mail_time": null, + "first_char": "a", + "undone_activities_count": 0, + "closed_deals_count": 0, + "last_name": "Edwards", + "last_activity_date": "2023-04-07", + "label": 5, + "next_activity_id": null, + "related_lost_deals_count": 0, + "related_open_deals_count": 0, + "phone": [ + { + "label": "work", + "value": "+1434993473", + "primary": true + } + ], + "visible_to": "3", + "org_id": 2, + "notes_count": 0, + "followers_count": 1, + "name": "Anthony Edwards", + "participant_closed_deals_count": 0, + "primary_email": "anthon@noco.com", + "lost_deals_count": 0, + "next_activity_time": null, + "add_time": "2023-04-07 04:08:31", + "done_activities_count": 1 + }, + "previous": { + "related_closed_deals_count": 0, + "delete_time": null, + "email_messages_count": 0, + "cc_email": "hookwrite@pipedrivemail.com", + "owner_id": 14542087, + "open_deals_count": 1, + "last_outgoing_mail_time": null, + "active_flag": true, + "picture_id": null, + "last_activity_id": 1, + "next_activity_date": "2023-04-07", + "update_time": "2023-04-07 04:23:42", + "participant_open_deals_count": 0, + "activities_count": 2, + "id": 3, + "org_name": "NoCo Solutions", + "first_name": "Anthony", + "email": [ + { + "label": "work", + "value": "anthon@noco.com", + "primary": true + } + ], + "won_deals_count": 0, + "owner_name": "Hook Write", + "files_count": 0, + "company_id": 11149162, + "related_won_deals_count": 0, + "last_incoming_mail_time": null, + "first_char": "a", + "undone_activities_count": 1, + "closed_deals_count": 0, + "last_name": "Edwards", + "last_activity_date": "2023-04-07", + "label": 5, + "next_activity_id": 2, + "related_lost_deals_count": 0, + "related_open_deals_count": 0, + "phone": [ + { + "label": "work", + "value": "+1434993473", + "primary": true + } + ], + "visible_to": "3", + "org_id": 2, + "notes_count": 0, + "followers_count": 1, + "name": "Anthony Edwards", + "participant_closed_deals_count": 0, + "primary_email": "anthon@noco.com", + "lost_deals_count": 0, + "next_activity_time": null, + "add_time": "2023-04-07 04:08:31", + "done_activities_count": 1 + }, + "event": "updated.person", + "retry": 0 + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:pipedrive/1", + "snapshotDate": "2026-07" + } + }, + { + "provider": "pipedrive", + "providerLabel": "Pipedrive", + "eventType": "updated.product", + "name": "updated.product", + "body": { + "v": 1, + "matches_filters": { + "current": [] + }, + "meta": { + "action": "updated", + "change_source": "api", + "company_id": 11149162, + "host": "hookwrite.pipedrive.com", + "id": 1, + "is_bulk_update": false, + "matches_filters": { + "current": [] + }, + "object": "product", + "permitted_user_ids": [14542087], + "pipedrive_service_name": false, + "timestamp": 1680841165, + "timestamp_micro": 1680841165016475, + "prepublish_timestamp": 1680841165047, + "trans_pending": false, + "user_id": 14542087, + "v": 1, + "webhook_id": "174632" + }, + "current": { + "code": "00023", + "files_count": null, + "company_id": 11149162, + "owner_id": 14542087, + "first_char": "s", + "selectable": true, + "description": null, + "tax": 0, + "active_flag": true, + "unit": "233", + "update_time": "2023-04-07 04:19:25", + "visible_to": "3", + "followers_count": 1, + "name": "Survey consultancy", + "product_variations": [], + "id": 1, + "prices": [ + { + "cost": 0, + "price": 300000, + "product_id": 1, + "overhead_cost": 0, + "currency": "XAF", + "id": 1 + } + ], + "category": "15", + "add_time": "2023-04-07 04:19:24" + }, + "previous": { + "code": "00023", + "files_count": null, + "owner_id": 14542087, + "first_char": "s", + "selectable": true, + "description": null, + "tax": 0, + "active_flag": true, + "unit": "233", + "update_time": "2023-04-07 04:19:24", + "visible_to": "3", + "followers_count": 0, + "name": "Survey consultancy", + "product_variations": [], + "id": 1, + "prices": [ + { + "cost": 0, + "price": 300000, + "product_id": 1, + "overhead_cost": 0, + "currency": "XAF", + "id": 1 + } + ], + "category": "15", + "add_time": "2023-04-07 04:19:24" + }, + "event": "updated.product", + "retry": 0 + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:pipedrive/1", + "snapshotDate": "2026-07" + } + }, + { + "provider": "scrapfly", + "providerLabel": "Scrapfly", + "eventType": "extraction", + "name": "extraction", + "body": { + "content_type": "application/json", + "data": { + "price": "$19.99", + "product_name": "Widget", + "stock": "In stock" + } + }, + "extraHeaders": { + "x-scrapfly-webhook-env": "LIVE", + "x-scrapfly-webhook-job-id": "571b3e08-4575-4072-9b68-90c9df8ac542", + "x-scrapfly-webhook-name": "samples-capture", + "x-scrapfly-webhook-project": "default", + "x-scrapfly-webhook-resource-type": "extraction" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:scrapfly/latest", + "snapshotDate": "2026-07" + } + }, + { + "provider": "scrapfly", + "providerLabel": "Scrapfly", + "eventType": "scrape", + "name": "scrape", + "body": { + "config": { + "asp": false, + "auto_scroll": false, + "body": null, + "browser_brand": null, + "cache": false, + "cache_clear": false, + "cache_ttl": 86400, + "correlation_id": null, + "cost_budget": null, + "country": null, + "debug": false, + "dns": false, + "enable_backend_profile": false, + "enable_cdp_trace": false, + "env": "LIVE", + "extract": null, + "extraction_model": null, + "extraction_model_custom_schema": null, + "extraction_prompt": null, + "extraction_template": null, + "fingerprint_uuid": null, + "format": "raw", + "geolocation": null, + "headers": {}, + "js": null, + "js_scenario": null, + "lang": null, + "log_eviction_date": "2026-05-22 12:56:27", + "method": "GET", + "origin": "WEBHOOK", + "os": null, + "project": "default", + "project_uuid": "34f3bdea-4991-41c9-8923-3fd8a1e31895", + "proxy_pool": "public_datacenter_pool", + "render_js": false, + "rendering_stage": "", + "rendering_wait": 0, + "request_id": "69f331ae-a6d3-4f03-a8d7-d7c93e05fcde", + "retry": true, + "schedule_name": null, + "screenshot_flags": null, + "screenshot_resolution": null, + "screenshots": null, + "session": null, + "session_sticky_proxy": true, + "ssl": false, + "tags": null, + "timeout": 150000, + "url": "https://web-scraping.dev/products", + "uuid": "01KRNVB97E6313HTXH6XCHQE2G", + "vision_deficiency": null, + "wait_for_selector": null, + "webhook_name": "samples-capture", + "job_uuid": "5eb1468b-029f-46c9-b402-1466607818af" + }, + "context": { + "asp": null, + "cache": { + "entry": null, + "state": "MISS" + }, + "cookies": [], + "cost": { + "details": [ + { + "amount": 1, + "code": "PROXY_DATACENTER_NETWORK", + "description": "Proxy Network (Datacenter)" + } + ], + "total": 1 + }, + "created_at": "2026-05-15 12:56:27.732886", + "debug": null, + "env": "LIVE", + "headers": {}, + "is_xml_http_request": false, + "job": { + "uuid": "5eb1468b-029f-46c9-b402-1466607818af", + "created_at": "2026-05-15 12:56:25.084000", + "origin": "WEB_SCRAPING_API", + "type": "SCRAPE", + "status": "DONE", + "is_finished": true, + "is_success": true, + "webhook_uuid": "ac5fecce-20f0-4351-8410-d93c8be75ddb", + "schedule_uuid": null, + "crawler_uuid": null, + "spider_uuid": null, + "duration": 4.29, + "finished_at": "2026-05-15 12:56:29.377017", + "not_before": null, + "last_status_updated_at": "2026-05-15 12:56:29.377117", + "metadata": {} + }, + "lang": "en", + "optimizations": [], + "os": { + "name": "mac", + "version": "26.3.0" + }, + "project": "default", + "proxy": { + "country": "ph", + "identity": "552f7b60", + "network": "datacenter", + "pool": "public_datacenter_pool" + }, + "redirects": [], + "retry": 0, + "schedule": null, + "session": null, + "spider": null, + "throttler": null, + "uri": { + "base_url": "https://web-scraping.dev", + "fragment": null, + "host": "web-scraping.dev", + "params": null, + "port": 443, + "query": null, + "root_domain": "web-scraping.dev", + "scheme": "https" + }, + "url": "https://web-scraping.dev/products", + "webhook": { + "consecutive_failed_count": 0, + "disable_reason": null, + "max_concurrency": null, + "name": "samples-capture", + "queue_limit": 500, + "queued_scrape_order": 3, + "webhook_uuid": "ac5fecce-20f0-4351-8410-d93c8be75ddb" + }, + "bandwidth_consumed": 0 + }, + "insights": null, + "result": { + "ai_snapshot": null, + "browser_data": { + "attachments": [], + "javascript_evaluation_result": null, + "js_scenario": null, + "local_storage_data": {}, + "local_storage_deletions": {}, + "private_token_data": null, + "session_storage_data": {}, + "session_storage_deletions": {}, + "websockets": [], + "xhr_call": [] + }, + "content": "\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n\n\nweb-scraping.dev product page 1\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n \n \n
\n \n
\n
\n all\n apparel\n consumables\n household\n
\n \n
\n
\n
\n
\n \n \n
\n
\n \"thumbnail\n
\n
\n

Box of Chocolate Candy

\n
Indulge your sweet tooth with our Box of Chocolate Candy. Each box contains an assortment of rich, flavorful chocolates with a smooth, creamy filling. Choose from a variety of flavors including zesty orange and sweet cherry. Whether you're looking for the perfect gift or just want to treat yourself, our Box of Chocolate Candy is sure to satisfy.
\n
\n
\n
24.99
\n
\n
\n\n \n \n
\n
\n \"thumbnail\n
\n
\n

Dark Red Energy Potion

\n
Unleash the power within with our 'Dark Red Potion', an energy drink as intense as the games you play. Its deep red color and bold cherry cola flavor are as inviting as they are invigorating. Bring out the best in your gaming performance, and unlock your full potential.
\n
\n
\n
4.99
\n
\n
\n\n \n \n
\n
\n \"thumbnail\n
\n
\n

Teal Energy Potion

\n
Experience a surge of vitality with our 'Teal Potion', an exceptional energy drink designed for the gaming community. With its intriguing teal color and a flavor that keeps you asking for more, this potion is your best companion during those long gaming nights. Every sip is an adventure - let the quest begin!
\n
\n
\n
4.99
\n
\n
\n\n \n \n
\n
\n \"thumbnail\n
\n
\n

Red Energy Potion

\n
Elevate your game with our 'Red Potion', an extraordinary energy drink that's as enticing as it is effective. This fiery red potion delivers an explosive berry flavor and an energy kick that keeps you at the top of your game. Are you ready to level up?
\n
\n
\n
4.99
\n
\n
\n\n \n \n
\n
\n \"thumbnail\n
\n
\n

Blue Energy Potion

\n
Ignite your gaming sessions with our 'Blue Energy Potion', a premium energy drink crafted for dedicated gamers. Inspired by the classic video game potions, this energy drink provides a much-needed boost to keep you focused and energized. It's more than just an energy drink - it's an ode to the gaming culture, packaged in an aesthetically pleasing potion-like bottle that'll make you feel like you're in your favorite game world. Drink up and game on!
\n
\n
\n
4.99
\n
\n
\n\n \n
\n
page 1 of total 28 results in 6 pages
\n
\n <\n \n \n 1\n \n \n \n 2\n \n \n \n 3\n \n \n \n 4\n \n \n \n 5\n \n \n \n \n >\n
\n
\n
\n\n
\n \n\n
\n \n
\n \n\n\n \n", + "content_encoding": "utf-8", + "content_format": "raw", + "content_type": "text/html; charset=utf-8", + "cookies": [], + "data": null, + "dns": null, + "duration": 1.64, + "error": null, + "extracted_data": null, + "format": "text", + "iframes": [], + "log_url": "https://scrapfly.io/dashboard/monitoring/log/01KRNVB97E6313HTXH6XCHQE2G", + "reason": "OK", + "request_headers": { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "accept-encoding": "gzip, deflate, br, zstd", + "accept-language": "en-PH,en;q=0.9,en-US;q=0.8", + "priority": "u=0, i", + "sec-ch-ua": "\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"", + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": "\"macOS\"", + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "sec-fetch-site": "none", + "sec-fetch-user": "?1", + "upgrade-insecure-requests": "1", + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36" + }, + "response_headers": { + "content-length": "17153", + "content-type": "text/html; charset=utf-8", + "date": "Fri, 15 May 2026 12:56:28 GMT", + "permissions-policy": "fullscreen=(self), autoplay=*, geolocation=(), camera=(), ch-ua=*, ch-ua-arch=*, ch-ua-bitness=*, ch-ua-full-version=*, ch-ua-full-version-list=*, ch-ua-mobile=*, ch-ua-model=*, ch-ua-platform=*, ch-ua-platform-version=*, ch-ua-wow64=*, ch-ua-form-factor=*, ch-viewport-width=*, ch-viewport-height=*, ch-dpr=*, ch-width=*, ch-prefers-reduced-motion=*, ch-prefers-color-scheme=*, ch-downlink=*, ch-ect=*, ch-rtt=*, ch-save-data=*, ch-device-memory=*", + "referrer-policy": "strict-origin-when-cross-origin", + "server": "uvicorn", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "x-content-type-options": "nosniff", + "x-xss-protection": "1; mode=block" + }, + "screenshots": {}, + "size": 0, + "ssl": null, + "status": "DONE", + "status_code": 200, + "success": true, + "url": "https://web-scraping.dev/products" + }, + "uuid": "01KRNVB97E6313HTXH6XCHQE2G" + }, + "extraHeaders": { + "x-scrapfly-log-url": "https://scrapfly.io/dashboard/monitoring/log/01KRNVB97E6313HTXH6XCHQE2G", + "x-scrapfly-log-uuid": "01KRNVB97E6313HTXH6XCHQE2G", + "x-scrapfly-webhook-env": "LIVE", + "x-scrapfly-webhook-job-id": "5eb1468b-029f-46c9-b402-1466607818af", + "x-scrapfly-webhook-name": "samples-capture", + "x-scrapfly-webhook-project": "default", + "x-scrapfly-webhook-resource-type": "scrape" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:scrapfly/latest", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "app_purchases_one_time/update", + "name": "app_purchases_one_time/update", + "body": { + "app_purchase_one_time": { + "admin_graphql_api_id": "gid://shopify/AppPurchaseOneTime/1017262349", + "name": "Webhook Test", + "status": "PENDING", + "admin_graphql_api_shop_id": "gid://shopify/Shop/548380009", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "app_purchases_one_time/update", + "x-shopify-triggered-at": "2024-08-07T22:48:08.320603914Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "app_subscriptions/approaching_capped_amount", + "name": "app_subscriptions/approaching_capped_amount", + "body": { + "app_subscription": { + "admin_graphql_api_id": "gid://shopify/AppSubscription/1029266948", + "name": "Webhook Test", + "balance_used": 0, + "capped_amount": "20.0", + "currency_code": "USD", + "admin_graphql_api_shop_id": "gid://shopify/Shop/548380009", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "app_subscriptions/approaching_capped_amount", + "x-shopify-triggered-at": "2024-08-07T22:48:17.296500484Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "app_subscriptions/update", + "name": "app_subscriptions/update", + "body": { + "app_subscription": { + "admin_graphql_api_id": "gid://shopify/AppSubscription/1029266952", + "name": "Webhook Test", + "status": "PENDING", + "admin_graphql_api_shop_id": "gid://shopify/Shop/548380009", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "currency": "USD", + "capped_amount": "20.0" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "app_subscriptions/update", + "x-shopify-triggered-at": "2024-08-07T22:48:22.376601190Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "app/uninstalled", + "name": "app/uninstalled", + "body": { + "id": 548380009, + "name": "Super Toys", + "email": "super@supertoys.com", + "domain": null, + "province": "Tennessee", + "country": "US", + "address1": "190 MacLaren Street", + "zip": "37178", + "city": "Houston", + "source": null, + "phone": "3213213210", + "latitude": null, + "longitude": null, + "primary_locale": "en", + "address2": null, + "created_at": null, + "updated_at": null, + "country_code": "US", + "country_name": "United States", + "currency": "USD", + "customer_email": "super@supertoys.com", + "timezone": "(GMT-05:00) Eastern Time (US & Canada)", + "iana_timezone": null, + "shop_owner": "John Smith", + "money_format": "${{amount}}", + "money_with_currency_format": "${{amount}} USD", + "weight_unit": "kg", + "province_code": "TN", + "taxes_included": null, + "auto_configure_tax_inclusivity": null, + "tax_shipping": null, + "county_taxes": null, + "plan_display_name": "Shopify Plus", + "plan_name": "enterprise", + "has_discounts": false, + "has_gift_cards": true, + "myshopify_domain": null, + "google_apps_domain": null, + "google_apps_login_enabled": null, + "money_in_emails_format": "${{amount}}", + "money_with_currency_in_emails_format": "${{amount}} USD", + "eligible_for_payments": true, + "requires_extra_payments_agreement": false, + "password_enabled": null, + "has_storefront": true, + "finances": true, + "primary_location_id": 655441491, + "checkout_api_supported": true, + "multi_location_enabled": true, + "setup_required": false, + "pre_launch_enabled": false, + "enabled_presentment_currencies": ["USD"], + "marketing_sms_consent_enabled_at_checkout": false, + "transactional_sms_disabled": false + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "app/uninstalled", + "x-shopify-triggered-at": "2024-08-07T22:48:27.480992003Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "attributed_sessions/first", + "name": "attributed_sessions/first", + "body": {}, + "extraHeaders": { + "transfer-encoding": "chunked", + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "attributed_sessions/first", + "x-shopify-triggered-at": "2024-08-07T22:48:32.635829834Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "attributed_sessions/last", + "name": "attributed_sessions/last", + "body": {}, + "extraHeaders": { + "transfer-encoding": "chunked", + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "attributed_sessions/last", + "x-shopify-triggered-at": "2024-08-07T22:48:37.645452488Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "audit_events/admin_api_activity", + "name": "audit_events/admin_api_activity", + "body": { + "events": [ + { + "time": 1634678724, + "event": { + "context": { + "context_type": "network_request", + "context_identifier": "13493be9-24f2-4eea-b71d-a8acd7af7917", + "context_metadata": { + "client_ip": "192.168.64.1", + "content_type": "json", + "response_time_ms": 116.47000000812113, + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36", + "api_request_failed": false, + "response_code": 200 + } + }, + "action": "api_request", + "actor": { + "actor_type": "app", + "actor_identifier": 4, + "actor_metadata": { + "app_name": "Private app", + "api_version_requested": "unstable", + "api_version_served": "unstable", + "ecosystem_category": "private" + }, + "on_behalf_of": { + "user_email": "dev@example.com" + } + }, + "subject": { + "subject_type": "shop", + "subject_identifier": "shop1.myshopify.io", + "subject_metadata": {} + }, + "timestamp": "2021-10-19T21:25:24Z", + "additional_metadata": { + "request_type": "GraphQL", + "mutation_names": [], + "query": "query orders {\n orders(first: $first) {\n edges {\n node {\n id\n }\n }\n }\n}", + "variables": { + "first": 10 + }, + "error_codes": [] + } + } + } + ] + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "audit_events/admin_api_activity", + "x-shopify-triggered-at": "2024-08-07T22:48:42.842783143Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "bulk_operations/finish", + "name": "bulk_operations/finish", + "body": { + "admin_graphql_api_id": "gid://shopify/BulkOperation/147595010", + "completed_at": "2024-07-29T11:59:46-04:00", + "created_at": "2024-07-29T11:59:46-04:00", + "error_code": null, + "status": "completed", + "type": "query" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "bulk_operations/finish", + "x-shopify-triggered-at": "2024-08-07T22:48:48.382830812Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "carts/create", + "name": "carts/create", + "body": { + "id": "eeafa272cebfd4b22385bc4b645e762c", + "token": "eeafa272cebfd4b22385bc4b645e762c", + "line_items": [ + { + "id": 704912205188288500, + "properties": {}, + "quantity": 3, + "variant_id": 704912205188288500, + "key": "704912205188288575:33f11f7a1ec7d93b826de33bb54de37b", + "discounted_price": "19.99", + "discounts": [], + "gift_card": false, + "grams": 200, + "line_price": "59.97", + "original_line_price": "59.97", + "original_price": "19.99", + "price": "19.99", + "product_id": 788032119674292900, + "sku": "example-shirt-s", + "taxable": true, + "title": "Example T-Shirt - Small", + "total_discount": "0.00", + "vendor": "Acme", + "discounted_price_set": { + "shop_money": { + "amount": "19.99", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "19.99", + "currency_code": "USD" + } + }, + "line_price_set": { + "shop_money": { + "amount": "59.97", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "59.97", + "currency_code": "USD" + } + }, + "original_line_price_set": { + "shop_money": { + "amount": "59.97", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "59.97", + "currency_code": "USD" + } + }, + "price_set": { + "shop_money": { + "amount": "19.99", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "19.99", + "currency_code": "USD" + } + }, + "total_discount_set": { + "shop_money": { + "amount": "0.0", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.0", + "currency_code": "USD" + } + } + } + ], + "note": null, + "updated_at": "2022-01-01T00:00:00.000Z", + "created_at": "2022-01-01T00:00:00.000Z" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "carts/create", + "x-shopify-triggered-at": "2024-08-07T22:48:53.450036423Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "carts/update", + "name": "carts/update", + "body": { + "id": "eeafa272cebfd4b22385bc4b645e762c", + "token": "eeafa272cebfd4b22385bc4b645e762c", + "line_items": [ + { + "id": 704912205188288500, + "properties": {}, + "quantity": 3, + "variant_id": 704912205188288500, + "key": "704912205188288575:33f11f7a1ec7d93b826de33bb54de37b", + "discounted_price": "19.99", + "discounts": [], + "gift_card": false, + "grams": 200, + "line_price": "59.97", + "original_line_price": "59.97", + "original_price": "19.99", + "price": "19.99", + "product_id": 788032119674292900, + "sku": "example-shirt-s", + "taxable": true, + "title": "Example T-Shirt - Small", + "total_discount": "0.00", + "vendor": "Acme", + "discounted_price_set": { + "shop_money": { + "amount": "19.99", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "19.99", + "currency_code": "USD" + } + }, + "line_price_set": { + "shop_money": { + "amount": "59.97", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "59.97", + "currency_code": "USD" + } + }, + "original_line_price_set": { + "shop_money": { + "amount": "59.97", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "59.97", + "currency_code": "USD" + } + }, + "price_set": { + "shop_money": { + "amount": "19.99", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "19.99", + "currency_code": "USD" + } + }, + "total_discount_set": { + "shop_money": { + "amount": "0.0", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.0", + "currency_code": "USD" + } + } + } + ], + "note": null, + "updated_at": "2022-01-01T00:00:00.000Z", + "created_at": "2022-01-01T00:00:00.000Z" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "carts/update", + "x-shopify-triggered-at": "2024-08-07T22:48:58.525199459Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "channels/delete", + "name": "channels/delete", + "body": { + "id": "123456789" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "channels/delete", + "x-shopify-triggered-at": "2024-08-07T22:49:03.887622994Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "checkouts/create", + "name": "checkouts/create", + "body": { + "id": 981820079255243500, + "token": "123123123", + "cart_token": "eeafa272cebfd4b22385bc4b645e762c", + "email": "example@email.com", + "gateway": null, + "buyer_accepts_marketing": false, + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "landing_site": null, + "note": null, + "note_attributes": [], + "referring_site": null, + "shipping_lines": [], + "taxes_included": false, + "total_weight": 1133, + "currency": "USD", + "completed_at": null, + "closed_at": null, + "user_id": null, + "location_id": null, + "source_identifier": null, + "source_url": null, + "device_id": null, + "phone": null, + "customer_locale": null, + "line_items": [ + { + "applied_discounts": [], + "discount_allocations": [], + "key": "0fede25d9590a4b164cd8391fc839d37", + "destination_location_id": 1015975136, + "fulfillment_service": "manual", + "gift_card": false, + "grams": 567, + "origin_location_id": 1015975135, + "presentment_title": "IPod Nano - 8GB", + "presentment_variant_title": "Pink", + "product_id": 632910392, + "properties": null, + "quantity": 1, + "requires_shipping": true, + "sku": "IPOD2008PINK", + "tax_lines": [ + { + "price": "11.94", + "position": 1, + "rate": 0.06, + "title": "Tax", + "source": "Shopify", + "zone": "province", + "jurisdiction_id": null, + "jurisdiction_type": null, + "jurisdiction_source": null, + "reporting_taxable_amount": null, + "reporting_non_taxable_amount": null, + "reporting_exempt_amount": null, + "reporting_jurisdiction_name": null, + "reporting_jurisdiction_type": null, + "reporting_jurisdiction_code": null, + "tax_api_client_id": null, + "tax_calculation_price": "11.94", + "tax_registration_id": null, + "compare_at": 0.06, + "channel_liable": false + } + ], + "taxable": true, + "title": "IPod Nano - 8GB", + "variant_id": 808950810, + "variant_title": "Pink", + "variant_price": "199.00", + "vendor": "Apple", + "user_id": null, + "unit_price_measurement": { + "measured_type": null, + "quantity_value": null, + "quantity_unit": null, + "reference_value": null, + "reference_unit": null + }, + "rank": null, + "compare_at_price": null, + "line_price": "199.00", + "price": "199.00" + }, + { + "applied_discounts": [], + "discount_allocations": [], + "key": "0fede25d9590a4b164cd8391fc839d37", + "destination_location_id": 1015975136, + "fulfillment_service": "manual", + "gift_card": false, + "grams": 567, + "origin_location_id": 1015975135, + "presentment_title": "IPod Nano - 8GB", + "presentment_variant_title": "Pink", + "product_id": 632910392, + "properties": null, + "quantity": 1, + "requires_shipping": true, + "sku": "IPOD2008PINK", + "tax_lines": [ + { + "price": "11.94", + "position": 1, + "rate": 0.06, + "title": "Tax", + "source": "Shopify", + "zone": "province", + "jurisdiction_id": null, + "jurisdiction_type": null, + "jurisdiction_source": null, + "reporting_taxable_amount": null, + "reporting_non_taxable_amount": null, + "reporting_exempt_amount": null, + "reporting_jurisdiction_name": null, + "reporting_jurisdiction_type": null, + "reporting_jurisdiction_code": null, + "tax_api_client_id": null, + "tax_calculation_price": "11.94", + "tax_registration_id": null, + "compare_at": 0.06, + "channel_liable": false + } + ], + "taxable": true, + "title": "IPod Nano - 8GB", + "variant_id": 808950810, + "variant_title": "Pink", + "variant_price": "199.00", + "vendor": "Apple", + "user_id": null, + "unit_price_measurement": { + "measured_type": null, + "quantity_value": null, + "quantity_unit": null, + "reference_value": null, + "reference_unit": null + }, + "rank": null, + "compare_at_price": null, + "line_price": "199.00", + "price": "199.00" + } + ], + "name": "#981820079255243537", + "source": null, + "abandoned_checkout_url": "https://checkout.local/548380009/checkouts/123123123/recover?key=example-secret-token", + "discount_codes": [], + "tax_lines": [ + { + "price": "23.88", + "rate": 0.06, + "title": "Tax", + "channel_liable": false + } + ], + "source_name": "web", + "presentment_currency": "USD", + "buyer_accepts_sms_marketing": false, + "sms_marketing_phone": null, + "total_discounts": "0.00", + "total_line_items_price": "398.00", + "total_price": "421.88", + "total_tax": "23.88", + "subtotal_price": "398.00", + "total_duties": null, + "reservation_token": null, + "billing_address": { + "first_name": "Bob", + "address1": "123 Billing Street", + "phone": "555-555-BILL", + "city": "Billtown", + "zip": "K2P0B0", + "province": "Kentucky", + "country": "United States", + "last_name": "Biller", + "address2": null, + "company": "My Company", + "latitude": null, + "longitude": null, + "name": "Bob Biller", + "country_code": "US", + "province_code": "KY" + }, + "shipping_address": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "K2P0S0", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "customer": { + "id": 603851970716743400, + "email": "john@example.com", + "created_at": null, + "updated_at": null, + "first_name": "John", + "last_name": "Smith", + "orders_count": 0, + "state": "disabled", + "total_spent": "0.00", + "last_order_id": null, + "note": null, + "verified_email": true, + "multipass_identifier": null, + "tax_exempt": false, + "tags": "", + "last_order_name": null, + "currency": "USD", + "phone": null, + "tax_exemptions": [], + "email_marketing_consent": { + "state": "not_subscribed", + "opt_in_level": null, + "consent_updated_at": null + }, + "sms_marketing_consent": null, + "admin_graphql_api_id": "gid://shopify/Customer/603851970716743426", + "default_address": { + "id": null, + "customer_id": 603851970716743400, + "first_name": null, + "last_name": null, + "company": null, + "address1": "123 Elm St.", + "address2": null, + "city": "Ottawa", + "province": "Ontario", + "country": "Canada", + "zip": "K2H7A8", + "phone": "123-123-1234", + "name": "", + "province_code": "ON", + "country_code": "CA", + "country_name": "Canada", + "default": true + } + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "checkouts/create", + "x-shopify-triggered-at": "2024-08-07T22:49:09.417931715Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "checkouts/delete", + "name": "checkouts/delete", + "body": { + "id": 981820079255243500, + "presentment_currency": "USD", + "buyer_accepts_sms_marketing": false, + "sms_marketing_phone": null, + "total_discounts": "0.00", + "total_line_items_price": "398.00", + "total_price": "421.88", + "total_tax": "23.88", + "subtotal_price": "398.00", + "cart_token": "eeafa272cebfd4b22385bc4b645e762c", + "total_duties": null, + "reservation_token": null + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "checkouts/delete", + "x-shopify-triggered-at": "2024-08-07T22:49:14.518371058Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "checkouts/update", + "name": "checkouts/update", + "body": { + "id": 981820079255243500, + "token": "123123123", + "cart_token": "eeafa272cebfd4b22385bc4b645e762c", + "email": "example@email.com", + "gateway": null, + "buyer_accepts_marketing": false, + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "landing_site": null, + "note": null, + "note_attributes": [], + "referring_site": null, + "shipping_lines": [], + "taxes_included": false, + "total_weight": 1133, + "currency": "USD", + "completed_at": null, + "closed_at": null, + "user_id": null, + "location_id": null, + "source_identifier": null, + "source_url": null, + "device_id": null, + "phone": null, + "customer_locale": null, + "line_items": [ + { + "applied_discounts": [], + "discount_allocations": [], + "key": "04146c0fca55b1ad72ae9ed35248b684", + "destination_location_id": 1015975152, + "fulfillment_service": "manual", + "gift_card": false, + "grams": 567, + "origin_location_id": 1015975151, + "presentment_title": "IPod Nano - 8GB", + "presentment_variant_title": "Pink", + "product_id": 632910392, + "properties": null, + "quantity": 1, + "requires_shipping": true, + "sku": "IPOD2008PINK", + "tax_lines": [ + { + "price": "11.94", + "position": 1, + "rate": 0.06, + "title": "Tax", + "source": "Shopify", + "zone": "province", + "jurisdiction_id": null, + "jurisdiction_type": null, + "jurisdiction_source": null, + "reporting_taxable_amount": null, + "reporting_non_taxable_amount": null, + "reporting_exempt_amount": null, + "reporting_jurisdiction_name": null, + "reporting_jurisdiction_type": null, + "reporting_jurisdiction_code": null, + "tax_api_client_id": null, + "tax_calculation_price": "11.94", + "tax_registration_id": null, + "compare_at": 0.06, + "channel_liable": false + } + ], + "taxable": true, + "title": "IPod Nano - 8GB", + "variant_id": 808950810, + "variant_title": "Pink", + "variant_price": "199.00", + "vendor": "Apple", + "user_id": null, + "unit_price_measurement": { + "measured_type": null, + "quantity_value": null, + "quantity_unit": null, + "reference_value": null, + "reference_unit": null + }, + "rank": null, + "compare_at_price": null, + "line_price": "199.00", + "price": "199.00" + }, + { + "applied_discounts": [], + "discount_allocations": [], + "key": "04146c0fca55b1ad72ae9ed35248b684", + "destination_location_id": 1015975152, + "fulfillment_service": "manual", + "gift_card": false, + "grams": 567, + "origin_location_id": 1015975151, + "presentment_title": "IPod Nano - 8GB", + "presentment_variant_title": "Pink", + "product_id": 632910392, + "properties": null, + "quantity": 1, + "requires_shipping": true, + "sku": "IPOD2008PINK", + "tax_lines": [ + { + "price": "11.94", + "position": 1, + "rate": 0.06, + "title": "Tax", + "source": "Shopify", + "zone": "province", + "jurisdiction_id": null, + "jurisdiction_type": null, + "jurisdiction_source": null, + "reporting_taxable_amount": null, + "reporting_non_taxable_amount": null, + "reporting_exempt_amount": null, + "reporting_jurisdiction_name": null, + "reporting_jurisdiction_type": null, + "reporting_jurisdiction_code": null, + "tax_api_client_id": null, + "tax_calculation_price": "11.94", + "tax_registration_id": null, + "compare_at": 0.06, + "channel_liable": false + } + ], + "taxable": true, + "title": "IPod Nano - 8GB", + "variant_id": 808950810, + "variant_title": "Pink", + "variant_price": "199.00", + "vendor": "Apple", + "user_id": null, + "unit_price_measurement": { + "measured_type": null, + "quantity_value": null, + "quantity_unit": null, + "reference_value": null, + "reference_unit": null + }, + "rank": null, + "compare_at_price": null, + "line_price": "199.00", + "price": "199.00" + } + ], + "name": "#981820079255243537", + "source": null, + "abandoned_checkout_url": "https://checkout.local/548380009/checkouts/123123123/recover?key=example-secret-token", + "discount_codes": [], + "tax_lines": [ + { + "price": "23.88", + "rate": 0.06, + "title": "Tax", + "channel_liable": false + } + ], + "source_name": "web", + "presentment_currency": "USD", + "buyer_accepts_sms_marketing": false, + "sms_marketing_phone": null, + "total_discounts": "0.00", + "total_line_items_price": "398.00", + "total_price": "421.88", + "total_tax": "23.88", + "subtotal_price": "398.00", + "total_duties": null, + "reservation_token": null, + "billing_address": { + "first_name": "Bob", + "address1": "123 Billing Street", + "phone": "555-555-BILL", + "city": "Billtown", + "zip": "K2P0B0", + "province": "Kentucky", + "country": "United States", + "last_name": "Biller", + "address2": null, + "company": "My Company", + "latitude": null, + "longitude": null, + "name": "Bob Biller", + "country_code": "US", + "province_code": "KY" + }, + "shipping_address": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "K2P0S0", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "customer": { + "id": 603851970716743400, + "email": "john@example.com", + "created_at": null, + "updated_at": null, + "first_name": "John", + "last_name": "Smith", + "orders_count": 0, + "state": "disabled", + "total_spent": "0.00", + "last_order_id": null, + "note": null, + "verified_email": true, + "multipass_identifier": null, + "tax_exempt": false, + "tags": "", + "last_order_name": null, + "currency": "USD", + "phone": null, + "tax_exemptions": [], + "email_marketing_consent": { + "state": "not_subscribed", + "opt_in_level": null, + "consent_updated_at": null + }, + "sms_marketing_consent": null, + "admin_graphql_api_id": "gid://shopify/Customer/603851970716743426", + "default_address": { + "id": null, + "customer_id": 603851970716743400, + "first_name": null, + "last_name": null, + "company": null, + "address1": "123 Elm St.", + "address2": null, + "city": "Ottawa", + "province": "Ontario", + "country": "Canada", + "zip": "K2H7A8", + "phone": "123-123-1234", + "name": "", + "province_code": "ON", + "country_code": "CA", + "country_name": "Canada", + "default": true + } + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "checkouts/update", + "x-shopify-triggered-at": "2024-08-07T22:49:23.634640521Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "collection_listings/add", + "name": "collection_listings/add", + "body": { + "collection_listing": { + "collection_id": 408372092144951400, + "updated_at": null, + "body_html": "Some HTML", + "default_product_image": null, + "handle": "mynewcollection", + "image": null, + "title": "My New Collection", + "sort_order": null, + "published_at": "2021-12-31T19:00:00-05:00" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "collection_listings/add", + "x-shopify-triggered-at": "2024-08-07T22:49:44.862206549Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "collection_listings/remove", + "name": "collection_listings/remove", + "body": { + "collection_listing": { + "collection_id": 408372092144951400 + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "collection_listings/remove", + "x-shopify-triggered-at": "2024-08-07T22:49:50.146358330Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "collection_listings/update", + "name": "collection_listings/update", + "body": { + "collection_listing": { + "collection_id": 408372092144951400, + "updated_at": null, + "body_html": "Some HTML", + "default_product_image": null, + "handle": "mynewcollection", + "image": null, + "title": "My New Collection", + "sort_order": null, + "published_at": "2021-12-31T19:00:00-05:00" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "collection_listings/update", + "x-shopify-triggered-at": "2024-08-07T22:49:55.707110165Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "collection_publications/create", + "name": "collection_publications/create", + "body": { + "id": null, + "publication_id": null, + "published_at": "2021-12-31T19:00:00-05:00", + "published": true, + "created_at": null, + "updated_at": null, + "collection_id": 408372092144951400 + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "collection_publications/create", + "x-shopify-triggered-at": "2024-08-07T22:50:00.831787789Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "collection_publications/delete", + "name": "collection_publications/delete", + "body": { + "id": null + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "collection_publications/delete", + "x-shopify-triggered-at": "2024-08-07T22:50:06.705687879Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "collection_publications/update", + "name": "collection_publications/update", + "body": { + "id": null, + "publication_id": null, + "published_at": "2021-12-31T19:00:00-05:00", + "published": true, + "created_at": null, + "updated_at": null, + "collection_id": 408372092144951400 + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "collection_publications/update", + "x-shopify-triggered-at": "2024-08-07T22:50:12.004948018Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "collections/create", + "name": "collections/create", + "body": { + "id": 408372092144951400, + "handle": "mynewcollection", + "title": "My New Collection", + "updated_at": "2021-12-31T19:00:00-05:00", + "body_html": "Some HTML", + "published_at": "2021-12-31T16:00:00-05:00", + "sort_order": null, + "template_suffix": null, + "published_scope": "web", + "admin_graphql_api_id": "gid://shopify/Collection/408372092144951419" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "collections/create", + "x-shopify-triggered-at": "2024-08-07T22:49:29.263300969Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "collections/delete", + "name": "collections/delete", + "body": { + "id": 408372092144951400, + "published_scope": "web", + "admin_graphql_api_id": "gid://shopify/Collection/408372092144951419" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "collections/delete", + "x-shopify-triggered-at": "2024-08-07T22:49:34.453393728Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "collections/update", + "name": "collections/update", + "body": { + "id": 408372092144951400, + "handle": "mynewcollection", + "title": "My New Collection", + "updated_at": "2021-12-31T19:00:00-05:00", + "body_html": "Some HTML", + "published_at": "2021-12-31T16:00:00-05:00", + "sort_order": null, + "template_suffix": null, + "published_scope": "web", + "admin_graphql_api_id": "gid://shopify/Collection/408372092144951419" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "collections/update", + "x-shopify-triggered-at": "2024-08-07T22:49:39.400114567Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "companies/create", + "name": "companies/create", + "body": { + "name": "Example Company", + "note": "This is an example company", + "external_id": "123456789", + "main_contact_admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951652", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "customer_since": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "companies/create", + "x-shopify-triggered-at": "2024-08-07T22:50:17.121540889Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "companies/delete", + "name": "companies/delete", + "body": { + "name": "Example Company", + "note": "This is an example company", + "external_id": "123456789", + "main_contact_admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951652", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "customer_since": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "companies/delete", + "x-shopify-triggered-at": "2024-08-07T22:50:22.239198640Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "companies/update", + "name": "companies/update", + "body": { + "name": "Example Company", + "note": "This is an example company", + "external_id": "123456789", + "main_contact_admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951652", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "customer_since": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "companies/update", + "x-shopify-triggered-at": "2024-08-07T22:50:27.321014059Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "company_contact_roles/assign", + "name": "company_contact_roles/assign", + "body": { + "company_contact": { + "customer_admin_graphql_api_id": "gid://shopify/Customer/12123842227812391", + "title": "Buyer", + "locale": "en", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951419", + "company": { + "name": "Example Company", + "note": "This is an example company", + "external_id": "123456789", + "main_contact_admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951652", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "customer_since": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + } + }, + "company_location": { + "name": "Montreal", + "external_id": "123456789", + "phone": "555-555-5555", + "locale": "en", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "note": "Head Office Location", + "buyer_experience_configuration": null, + "admin_graphql_api_id": "gid://shopify/CompanyLocation/408372092144951419", + "tax_exemptions": ["CA_BC_CONTRACTOR_EXEMPTION", "CA_BC_RESELLER_EXEMPTION"], + "company": { + "name": "Example Company", + "note": "This is an example company", + "external_id": "123456789", + "main_contact_admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951652", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "customer_since": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + }, + "billing_address": { + "address1": "175 Sherbrooke Street West", + "city": "Montreal", + "province": "Quebec", + "country": "Canada", + "zip": "H3A 0G4", + "recipient": "Adam Felix", + "first_name": null, + "last_name": null, + "address2": null, + "phone": "+49738001239", + "zone_code": "QC", + "country_code": "CA", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/CompanyAddress/141016871799219115", + "company_admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + }, + "shipping_address": { + "address1": "175 Sherbrooke Street West", + "city": "Montreal", + "province": "Quebec", + "country": "Canada", + "zip": "H3A 0G4", + "recipient": "Adam Felix", + "first_name": null, + "last_name": null, + "address2": null, + "phone": "+49738001239", + "zone_code": "QC", + "country_code": "CA", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/CompanyAddress/141016871799219115", + "company_admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + }, + "tax_registration": { + "tax_id": "1214214141" + } + }, + "company_contact_role": { + "name": "Location Admin" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "company_contact_roles/assign", + "x-shopify-triggered-at": "2024-08-07T22:50:48.208059762Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "company_contact_roles/revoke", + "name": "company_contact_roles/revoke", + "body": { + "company_contact": { + "customer_admin_graphql_api_id": "gid://shopify/Customer/12123842227812391", + "title": "Buyer", + "locale": "en", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951419", + "company": { + "name": "Example Company", + "note": "This is an example company", + "external_id": "123456789", + "main_contact_admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951652", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "customer_since": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + } + }, + "company_location": { + "name": "Montreal", + "external_id": "123456789", + "phone": "555-555-5555", + "locale": "en", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "note": "Head Office Location", + "buyer_experience_configuration": null, + "admin_graphql_api_id": "gid://shopify/CompanyLocation/408372092144951419", + "tax_exemptions": ["CA_BC_CONTRACTOR_EXEMPTION", "CA_BC_RESELLER_EXEMPTION"], + "company": { + "name": "Example Company", + "note": "This is an example company", + "external_id": "123456789", + "main_contact_admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951652", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "customer_since": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + }, + "billing_address": { + "address1": "175 Sherbrooke Street West", + "city": "Montreal", + "province": "Quebec", + "country": "Canada", + "zip": "H3A 0G4", + "recipient": "Adam Felix", + "first_name": null, + "last_name": null, + "address2": null, + "phone": "+49738001239", + "zone_code": "QC", + "country_code": "CA", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/CompanyAddress/141016871799219115", + "company_admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + }, + "shipping_address": { + "address1": "175 Sherbrooke Street West", + "city": "Montreal", + "province": "Quebec", + "country": "Canada", + "zip": "H3A 0G4", + "recipient": "Adam Felix", + "first_name": null, + "last_name": null, + "address2": null, + "phone": "+49738001239", + "zone_code": "QC", + "country_code": "CA", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/CompanyAddress/141016871799219115", + "company_admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + }, + "tax_registration": { + "tax_id": "1214214141" + } + }, + "company_contact_role": { + "name": "Location Admin" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "company_contact_roles/revoke", + "x-shopify-triggered-at": "2024-08-07T22:50:53.245467153Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "company_contacts/create", + "name": "company_contacts/create", + "body": { + "customer_admin_graphql_api_id": "gid://shopify/Customer/12123842227812391", + "title": "Buyer", + "locale": "en", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951419", + "company": { + "name": "Example Company", + "note": "This is an example company", + "external_id": "123456789", + "main_contact_admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951652", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "customer_since": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "company_contacts/create", + "x-shopify-triggered-at": "2024-08-07T22:50:32.566005428Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "company_contacts/delete", + "name": "company_contacts/delete", + "body": { + "customer_admin_graphql_api_id": "gid://shopify/Customer/12123842227812391", + "title": "Buyer", + "locale": "en", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951419", + "company": { + "name": "Example Company", + "note": "This is an example company", + "external_id": "123456789", + "main_contact_admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951652", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "customer_since": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "company_contacts/delete", + "x-shopify-triggered-at": "2024-08-07T22:50:38.009787956Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "company_contacts/update", + "name": "company_contacts/update", + "body": { + "customer_admin_graphql_api_id": "gid://shopify/Customer/12123842227812391", + "title": "Buyer", + "locale": "en", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951419", + "company": { + "name": "Example Company", + "note": "This is an example company", + "external_id": "123456789", + "main_contact_admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951652", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "customer_since": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "company_contacts/update", + "x-shopify-triggered-at": "2024-08-07T22:50:43.200340708Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "company_locations/create", + "name": "company_locations/create", + "body": { + "name": "Montreal", + "external_id": "123456789", + "phone": "555-555-5555", + "locale": "en", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "note": "Head Office Location", + "buyer_experience_configuration": null, + "admin_graphql_api_id": "gid://shopify/CompanyLocation/408372092144951419", + "tax_exemptions": ["CA_BC_CONTRACTOR_EXEMPTION", "CA_BC_RESELLER_EXEMPTION"], + "company": { + "name": "Example Company", + "note": "This is an example company", + "external_id": "123456789", + "main_contact_admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951652", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "customer_since": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + }, + "billing_address": { + "address1": "175 Sherbrooke Street West", + "city": "Montreal", + "province": "Quebec", + "country": "Canada", + "zip": "H3A 0G4", + "recipient": "Adam Felix", + "first_name": null, + "last_name": null, + "address2": null, + "phone": "+49738001239", + "zone_code": "QC", + "country_code": "CA", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/CompanyAddress/141016871799219115", + "company_admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + }, + "shipping_address": { + "address1": "175 Sherbrooke Street West", + "city": "Montreal", + "province": "Quebec", + "country": "Canada", + "zip": "H3A 0G4", + "recipient": "Adam Felix", + "first_name": null, + "last_name": null, + "address2": null, + "phone": "+49738001239", + "zone_code": "QC", + "country_code": "CA", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/CompanyAddress/141016871799219115", + "company_admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + }, + "tax_registration": { + "tax_id": "1214214141" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "company_locations/create", + "x-shopify-triggered-at": "2024-08-07T22:50:58.308549192Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "company_locations/delete", + "name": "company_locations/delete", + "body": { + "name": "Montreal", + "external_id": "123456789", + "phone": "555-555-5555", + "locale": "en", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "note": "Head Office Location", + "buyer_experience_configuration": null, + "admin_graphql_api_id": "gid://shopify/CompanyLocation/408372092144951419", + "tax_exemptions": ["CA_BC_CONTRACTOR_EXEMPTION", "CA_BC_RESELLER_EXEMPTION"], + "company": { + "name": "Example Company", + "note": "This is an example company", + "external_id": "123456789", + "main_contact_admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951652", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "customer_since": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + }, + "billing_address": { + "address1": "175 Sherbrooke Street West", + "city": "Montreal", + "province": "Quebec", + "country": "Canada", + "zip": "H3A 0G4", + "recipient": "Adam Felix", + "first_name": null, + "last_name": null, + "address2": null, + "phone": "+49738001239", + "zone_code": "QC", + "country_code": "CA", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/CompanyAddress/141016871799219115", + "company_admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + }, + "shipping_address": { + "address1": "175 Sherbrooke Street West", + "city": "Montreal", + "province": "Quebec", + "country": "Canada", + "zip": "H3A 0G4", + "recipient": "Adam Felix", + "first_name": null, + "last_name": null, + "address2": null, + "phone": "+49738001239", + "zone_code": "QC", + "country_code": "CA", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/CompanyAddress/141016871799219115", + "company_admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + }, + "tax_registration": { + "tax_id": "1214214141" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "company_locations/delete", + "x-shopify-triggered-at": "2024-08-07T22:51:03.577723567Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "company_locations/update", + "name": "company_locations/update", + "body": { + "name": "Montreal", + "external_id": "123456789", + "phone": "555-555-5555", + "locale": "en", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "note": "Head Office Location", + "buyer_experience_configuration": null, + "admin_graphql_api_id": "gid://shopify/CompanyLocation/408372092144951419", + "tax_exemptions": ["CA_BC_CONTRACTOR_EXEMPTION", "CA_BC_RESELLER_EXEMPTION"], + "company": { + "name": "Example Company", + "note": "This is an example company", + "external_id": "123456789", + "main_contact_admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951652", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "customer_since": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + }, + "billing_address": { + "address1": "175 Sherbrooke Street West", + "city": "Montreal", + "province": "Quebec", + "country": "Canada", + "zip": "H3A 0G4", + "recipient": "Adam Felix", + "first_name": null, + "last_name": null, + "address2": null, + "phone": "+49738001239", + "zone_code": "QC", + "country_code": "CA", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/CompanyAddress/141016871799219115", + "company_admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + }, + "shipping_address": { + "address1": "175 Sherbrooke Street West", + "city": "Montreal", + "province": "Quebec", + "country": "Canada", + "zip": "H3A 0G4", + "recipient": "Adam Felix", + "first_name": null, + "last_name": null, + "address2": null, + "phone": "+49738001239", + "zone_code": "QC", + "country_code": "CA", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/CompanyAddress/141016871799219115", + "company_admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + }, + "tax_registration": { + "tax_id": "1214214141" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "company_locations/update", + "x-shopify-triggered-at": "2024-08-07T22:51:08.839449181Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "customer_account_settings/update", + "name": "customer_account_settings/update", + "body": { + "url": null, + "customer_accounts_version": "classic", + "login_required_at_checkout": true, + "login_links_visible_on_storefront_and_checkout": true + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "customer_account_settings/update", + "x-shopify-triggered-at": "2024-08-07T22:51:57.130836490Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "customer_groups/create", + "name": "customer_groups/create", + "body": { + "id": 239443597569284770, + "name": "Bob Customers", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "query": "email:bob*" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "customer_groups/create", + "x-shopify-triggered-at": "2024-08-07T22:52:02.519965885Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "customer_groups/delete", + "name": "customer_groups/delete", + "body": { + "id": 239443597569284770 + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "customer_groups/delete", + "x-shopify-triggered-at": "2024-08-07T22:52:08.313033131Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "customer_groups/update", + "name": "customer_groups/update", + "body": { + "id": 239443597569284770, + "name": "Bob Customers", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "query": "email:bob*" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "customer_groups/update", + "x-shopify-triggered-at": "2024-08-07T22:52:13.505274530Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "customer_payment_methods/create", + "name": "customer_payment_methods/create", + "body": {}, + "extraHeaders": { + "transfer-encoding": "chunked", + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "customer_payment_methods/create", + "x-shopify-triggered-at": "2024-08-07T22:52:18.980219438Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "customer_payment_methods/revoke", + "name": "customer_payment_methods/revoke", + "body": {}, + "extraHeaders": { + "transfer-encoding": "chunked", + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "customer_payment_methods/revoke", + "x-shopify-triggered-at": "2024-08-07T22:52:24.088407291Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "customer_payment_methods/update", + "name": "customer_payment_methods/update", + "body": {}, + "extraHeaders": { + "transfer-encoding": "chunked", + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "customer_payment_methods/update", + "x-shopify-triggered-at": "2024-08-07T22:52:29.279171485Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "customers_email_marketing_consent/update", + "name": "customers_email_marketing_consent/update", + "body": { + "customer_id": 706405506930370000, + "email_address": null, + "email_marketing_consent": { + "state": null, + "opt_in_level": null, + "consent_updated_at": null + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "customers_email_marketing_consent/update", + "x-shopify-triggered-at": "2024-08-07T22:51:30.617878896Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "customers_marketing_consent/update", + "name": "customers_marketing_consent/update", + "body": { + "id": 706405506930370000, + "phone": null, + "sms_marketing_consent": { + "state": null, + "opt_in_level": null, + "consent_updated_at": null, + "consent_collected_from": "other" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "customers_marketing_consent/update", + "x-shopify-triggered-at": "2024-08-07T22:51:41.050833512Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "customers/create", + "name": "customers/create", + "body": { + "id": 706405506930370000, + "email": "bob@biller.com", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "first_name": "Bob", + "last_name": "Biller", + "orders_count": 0, + "state": "disabled", + "total_spent": "0.00", + "last_order_id": null, + "note": "This customer loves ice cream", + "verified_email": true, + "multipass_identifier": null, + "tax_exempt": false, + "tags": "", + "last_order_name": null, + "currency": "USD", + "phone": null, + "addresses": [], + "tax_exemptions": [], + "email_marketing_consent": null, + "sms_marketing_consent": null, + "admin_graphql_api_id": "gid://shopify/Customer/706405506930370084" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "customers/create", + "x-shopify-triggered-at": "2024-08-07T22:51:14.136738016Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "customers/delete", + "name": "customers/delete", + "body": { + "id": 706405506930370000, + "phone": null, + "addresses": [], + "tax_exemptions": [], + "email_marketing_consent": null, + "sms_marketing_consent": null, + "admin_graphql_api_id": "gid://shopify/Customer/706405506930370084" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "customers/delete", + "x-shopify-triggered-at": "2024-08-07T22:51:19.507506882Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "customers/disable", + "name": "customers/disable", + "body": { + "id": 706405506930370000, + "email": "bob@biller.com", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "first_name": "Bob", + "last_name": "Biller", + "orders_count": 0, + "state": "disabled", + "total_spent": "0.00", + "last_order_id": null, + "note": "This customer loves ice cream", + "verified_email": true, + "multipass_identifier": null, + "tax_exempt": false, + "tags": "", + "last_order_name": null, + "currency": "USD", + "phone": null, + "addresses": [], + "tax_exemptions": [], + "email_marketing_consent": null, + "sms_marketing_consent": null, + "admin_graphql_api_id": "gid://shopify/Customer/706405506930370084" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "customers/disable", + "x-shopify-triggered-at": "2024-08-07T22:51:25.462040521Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "customers/enable", + "name": "customers/enable", + "body": { + "id": 706405506930370000, + "email": "bob@biller.com", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "first_name": "Bob", + "last_name": "Biller", + "orders_count": 0, + "state": "disabled", + "total_spent": "0.00", + "last_order_id": null, + "note": "This customer loves ice cream", + "verified_email": true, + "multipass_identifier": null, + "tax_exempt": false, + "tags": "", + "last_order_name": null, + "currency": "USD", + "phone": null, + "addresses": [], + "tax_exemptions": [], + "email_marketing_consent": null, + "sms_marketing_consent": null, + "admin_graphql_api_id": "gid://shopify/Customer/706405506930370084" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "customers/enable", + "x-shopify-triggered-at": "2024-08-07T22:51:35.892845057Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "customers/merge", + "name": "customers/merge", + "body": { + "admin_graphql_api_customer_kept_id": "gid://shopify/Customer/1", + "admin_graphql_api_customer_deleted_id": "gid://shopify/Customer/2", + "admin_graphql_api_job_id": null, + "status": "failed", + "errors": [ + { + "customer_ids": [1], + "field": "merge_in_progress", + "message": "John Doe is currently being merged." + } + ] + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "customers/merge", + "x-shopify-triggered-at": "2024-08-07T22:51:46.447952714Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "customers/update", + "name": "customers/update", + "body": { + "id": 706405506930370000, + "email": "bob@biller.com", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "first_name": "Bob", + "last_name": "Biller", + "orders_count": 0, + "state": "disabled", + "total_spent": "0.00", + "last_order_id": null, + "note": "This customer loves ice cream", + "verified_email": true, + "multipass_identifier": null, + "tax_exempt": false, + "tags": "", + "last_order_name": null, + "currency": "USD", + "phone": null, + "addresses": [], + "tax_exemptions": [], + "email_marketing_consent": null, + "sms_marketing_consent": null, + "admin_graphql_api_id": "gid://shopify/Customer/706405506930370084" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "customers/update", + "x-shopify-triggered-at": "2024-08-07T22:51:51.872901866Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "discounts/create", + "name": "discounts/create", + "body": { + "admin_graphql_api_id": "gid://shopify/DiscountAutomaticNode/1", + "title": "Automatic free shipping", + "status": "ACTIVE", + "created_at": "2016-08-29T08:00:00-04:00", + "updated_at": "2016-08-29T08:00:00-04:00" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "discounts/create", + "x-shopify-triggered-at": "2024-08-07T22:52:34.528828230Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "discounts/delete", + "name": "discounts/delete", + "body": { + "admin_graphql_api_id": "gid://shopify/DiscountAutomaticNode/1", + "deleted_at": "2018-08-29T08:00:00-04:00" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "discounts/delete", + "x-shopify-triggered-at": "2024-08-07T22:52:39.895676196Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "discounts/redeemcode_added", + "name": "discounts/redeemcode_added", + "body": { + "admin_graphql_api_id": "gid://shopify/DiscountCodeNode/1", + "redeem_code": { + "id": "gid://shopify/DiscountRedeemCode/1", + "code": "code1" + }, + "updated_at": "2018-08-29T12:00:00.000Z" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "discounts/redeemcode_added", + "x-shopify-triggered-at": "2024-08-07T22:52:45.089981481Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "discounts/redeemcode_removed", + "name": "discounts/redeemcode_removed", + "body": { + "admin_graphql_api_id": "gid://shopify/DiscountCodeNode/1", + "redeem_code": { + "id": "gid://shopify/DiscountRedeemCode/1", + "code": "code1" + }, + "updated_at": "2018-08-29T12:00:00.000Z" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "discounts/redeemcode_removed", + "x-shopify-triggered-at": "2024-08-07T22:52:50.969357631Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "discounts/update", + "name": "discounts/update", + "body": { + "admin_graphql_api_id": "gid://shopify/DiscountAutomaticNode/1", + "title": "Automatic free shipping updated", + "status": "ACTIVE", + "created_at": "2016-08-29T08:00:00-04:00", + "updated_at": "2016-08-29T08:00:00-04:00" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "discounts/update", + "x-shopify-triggered-at": "2024-08-07T22:52:56.323408044Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "disputes/create", + "name": "disputes/create", + "body": { + "id": 285332461850802050, + "order_id": 820982911946154500, + "type": "chargeback", + "amount": "11.50", + "currency": "CAD", + "reason": "fraudulent", + "network_reason_code": "4837", + "status": "under_review", + "evidence_due_by": "2021-12-30T19:00:00-05:00", + "evidence_sent_on": null, + "finalized_on": null, + "initiated_at": "2021-12-31T19:00:00-05:00" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "disputes/create", + "x-shopify-triggered-at": "2024-08-07T22:53:01.347294875Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "disputes/update", + "name": "disputes/update", + "body": { + "id": 285332461850802050, + "order_id": 820982911946154500, + "type": "chargeback", + "amount": "11.50", + "currency": "CAD", + "reason": "fraudulent", + "network_reason_code": "4837", + "status": "under_review", + "evidence_due_by": "2021-12-30T19:00:00-05:00", + "evidence_sent_on": null, + "finalized_on": null, + "initiated_at": "2021-12-31T19:00:00-05:00" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "disputes/update", + "x-shopify-triggered-at": "2024-08-07T22:53:06.813323331Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "domains/create", + "name": "domains/create", + "body": { + "id": 690933842, + "host": "jsmith.myshopify.com", + "ssl_enabled": true, + "localization": { + "country": null, + "default_locale": "en", + "alternate_locales": [] + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "domains/create", + "x-shopify-triggered-at": "2024-08-07T22:53:11.795486444Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "domains/destroy", + "name": "domains/destroy", + "body": { + "id": 690933842, + "host": "jsmith.myshopify.com", + "ssl_enabled": true, + "localization": { + "country": null, + "default_locale": "en", + "alternate_locales": [] + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "domains/destroy", + "x-shopify-triggered-at": "2024-08-07T22:53:16.966800935Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "domains/update", + "name": "domains/update", + "body": { + "id": 690933842, + "host": "jsmith.myshopify.com", + "ssl_enabled": true, + "localization": { + "country": null, + "default_locale": "en", + "alternate_locales": [] + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "domains/update", + "x-shopify-triggered-at": "2024-08-07T22:53:22.021064442Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "draft_orders/create", + "name": "draft_orders/create", + "body": { + "id": 890612572568261600, + "note": null, + "email": "jon@doe.ca", + "taxes_included": false, + "currency": "USD", + "invoice_sent_at": null, + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "tax_exempt": false, + "completed_at": null, + "name": "#D128", + "status": "open", + "line_items": [ + { + "id": 1353766, + "variant_id": 49148385, + "product_id": 632910392, + "title": "IPod Nano - 8GB", + "variant_title": "Red", + "sku": "IPOD2008RED", + "vendor": "Apple", + "quantity": 4, + "requires_shipping": true, + "taxable": true, + "gift_card": false, + "fulfillment_service": "manual", + "grams": 567, + "tax_lines": [], + "applied_discount": null, + "name": "IPod Nano - 8GB - Red", + "properties": [], + "custom": false, + "price": "199.00", + "admin_graphql_api_id": "gid://shopify/DraftOrderLineItem/1353766" + }, + { + "id": 2158714, + "variant_id": 457924702, + "product_id": 632910392, + "title": "IPod Nano - 8GB", + "variant_title": "Black", + "sku": "IPOD2008BLACK", + "vendor": "Apple", + "quantity": 7, + "requires_shipping": true, + "taxable": true, + "gift_card": false, + "fulfillment_service": "manual", + "grams": 567, + "tax_lines": [], + "applied_discount": null, + "name": "IPod Nano - 8GB - Black", + "properties": [], + "custom": false, + "price": "199.00", + "admin_graphql_api_id": "gid://shopify/DraftOrderLineItem/2158714" + }, + { + "id": 1453233, + "variant_id": 808950810, + "product_id": 632910392, + "title": "IPod Nano - 8GB", + "variant_title": "Pink", + "sku": "IPOD2008PINK", + "vendor": "Apple", + "quantity": 9, + "requires_shipping": true, + "taxable": true, + "gift_card": false, + "fulfillment_service": "manual", + "grams": 567, + "tax_lines": [], + "applied_discount": null, + "name": "IPod Nano - 8GB - Pink", + "properties": [], + "custom": false, + "price": "199.00", + "admin_graphql_api_id": "gid://shopify/DraftOrderLineItem/1453233" + } + ], + "shipping_address": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40150", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "billing_address": { + "first_name": "Bob", + "address1": "123 Billing Street", + "phone": "555-555-BILL", + "city": "Billtown", + "zip": "K2P0B0", + "province": "Kentucky", + "country": "United States", + "last_name": "Biller", + "address2": null, + "company": "My Company", + "latitude": null, + "longitude": null, + "name": "Bob Biller", + "country_code": "US", + "province_code": "KY" + }, + "invoice_url": "https://jsmith.myshopify.com/548380009/invoices/abcd1234abcd1234abcd1234abcd1234", + "applied_discount": { + "description": "ABC 123", + "value": "3.0", + "title": "ABC 123", + "amount": "2.00", + "value_type": "percentage" + }, + "order_id": null, + "shipping_line": { + "title": "ABC 123", + "custom": true, + "handle": null, + "price": "5.00" + }, + "tax_lines": [ + { + "rate": 0.017665192468618723, + "title": "State tax", + "price": "5.00" + }, + { + "rate": 0.017665192468618723, + "title": "State tax", + "price": "3.00" + }, + { + "rate": 0.017665192468618723, + "title": "State tax", + "price": "3.00" + } + ], + "tags": "", + "note_attributes": [], + "total_price": "407.00", + "subtotal_price": "412.00", + "total_tax": "449.00", + "payment_terms": { + "id": 706405506930370000, + "payment_terms_name": "Net 7", + "payment_terms_type": "net", + "due_in_days": 7, + "created_at": "2021-01-01T00:00:00-05:00", + "updated_at": "2021-01-01T00:00:01-05:00", + "payment_schedules": [ + { + "id": 606405506930370000, + "created_at": "2021-01-01T00:00:00-05:00", + "updated_at": "2021-01-01T00:00:01-05:00", + "payment_terms_id": 706405506930370000, + "reference_id": null, + "reference_type": null, + "issued_at": "2021-01-01T00:00:00-05:00", + "due_at": "2021-01-02T00:00:00-05:00", + "completed_at": "2021-01-02T00:00:00-05:00", + "amount": "10.00", + "currency": "USD" + } + ], + "can_pay_early": true + }, + "admin_graphql_api_id": "gid://shopify/DraftOrder/890612572568261625", + "customer": { + "id": 706405506930370000, + "email": "john@doe.ca", + "created_at": null, + "updated_at": null, + "first_name": "John", + "last_name": "Smith", + "orders_count": 0, + "state": "disabled", + "total_spent": "0.00", + "last_order_id": null, + "note": null, + "verified_email": true, + "multipass_identifier": null, + "tax_exempt": false, + "tags": "", + "last_order_name": null, + "currency": "USD", + "phone": null, + "tax_exemptions": [], + "email_marketing_consent": { + "state": "not_subscribed", + "opt_in_level": null, + "consent_updated_at": null + }, + "sms_marketing_consent": null, + "admin_graphql_api_id": "gid://shopify/Customer/706405506930370084", + "default_address": { + "id": null, + "customer_id": 706405506930370000, + "first_name": null, + "last_name": null, + "company": null, + "address1": "123 Elm St.", + "address2": null, + "city": "Ottawa", + "province": "Ontario", + "country": "Canada", + "zip": "K2H7A8", + "phone": "123-123-1234", + "name": "", + "province_code": "ON", + "country_code": "CA", + "country_name": "Canada", + "default": true + } + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "draft_orders/create", + "x-shopify-triggered-at": "2024-08-07T22:53:27.296234376Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "draft_orders/delete", + "name": "draft_orders/delete", + "body": { + "id": 890612572568261600 + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "draft_orders/delete", + "x-shopify-triggered-at": "2024-08-07T22:53:32.503281901Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "draft_orders/update", + "name": "draft_orders/update", + "body": { + "id": 890612572568261600, + "note": null, + "email": "jon@doe.ca", + "taxes_included": false, + "currency": "USD", + "invoice_sent_at": null, + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "tax_exempt": false, + "completed_at": null, + "name": "#D123", + "status": "open", + "line_items": [ + { + "id": 1596065, + "variant_id": 49148385, + "product_id": 632910392, + "title": "IPod Nano - 8GB", + "variant_title": "Red", + "sku": "IPOD2008RED", + "vendor": "Apple", + "quantity": 1, + "requires_shipping": true, + "taxable": true, + "gift_card": false, + "fulfillment_service": "manual", + "grams": 567, + "tax_lines": [], + "applied_discount": null, + "name": "IPod Nano - 8GB - Red", + "properties": [], + "custom": false, + "price": "199.00", + "admin_graphql_api_id": "gid://shopify/DraftOrderLineItem/1596065" + }, + { + "id": 1491517, + "variant_id": 457924702, + "product_id": 632910392, + "title": "IPod Nano - 8GB", + "variant_title": "Black", + "sku": "IPOD2008BLACK", + "vendor": "Apple", + "quantity": 3, + "requires_shipping": true, + "taxable": true, + "gift_card": false, + "fulfillment_service": "manual", + "grams": 567, + "tax_lines": [], + "applied_discount": null, + "name": "IPod Nano - 8GB - Black", + "properties": [], + "custom": false, + "price": "199.00", + "admin_graphql_api_id": "gid://shopify/DraftOrderLineItem/1491517" + }, + { + "id": 1101873, + "variant_id": 808950810, + "product_id": 632910392, + "title": "IPod Nano - 8GB", + "variant_title": "Pink", + "sku": "IPOD2008PINK", + "vendor": "Apple", + "quantity": 3, + "requires_shipping": true, + "taxable": true, + "gift_card": false, + "fulfillment_service": "manual", + "grams": 567, + "tax_lines": [], + "applied_discount": null, + "name": "IPod Nano - 8GB - Pink", + "properties": [], + "custom": false, + "price": "199.00", + "admin_graphql_api_id": "gid://shopify/DraftOrderLineItem/1101873" + } + ], + "shipping_address": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40150", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "billing_address": { + "first_name": "Bob", + "address1": "123 Billing Street", + "phone": "555-555-BILL", + "city": "Billtown", + "zip": "K2P0B0", + "province": "Kentucky", + "country": "United States", + "last_name": "Biller", + "address2": null, + "company": "My Company", + "latitude": null, + "longitude": null, + "name": "Bob Biller", + "country_code": "US", + "province_code": "KY" + }, + "invoice_url": "https://jsmith.myshopify.com/548380009/invoices/abcd1234abcd1234abcd1234abcd1234", + "applied_discount": { + "description": "ABC 123", + "value": "6.0", + "title": "ABC 123", + "amount": "1.00", + "value_type": "percentage" + }, + "order_id": null, + "shipping_line": { + "title": "ABC 123", + "custom": true, + "handle": null, + "price": "1.00" + }, + "tax_lines": [ + { + "rate": 0.045697444018947916, + "title": "State tax", + "price": "3.00" + }, + { + "rate": 0.045697444018947916, + "title": "State tax", + "price": "3.00" + }, + { + "rate": 0.045697444018947916, + "title": "State tax", + "price": "2.00" + } + ], + "tags": "", + "note_attributes": [], + "total_price": "445.00", + "subtotal_price": "408.00", + "total_tax": "406.00", + "payment_terms": { + "id": 706405506930370000, + "payment_terms_name": "Net 7", + "payment_terms_type": "net", + "due_in_days": 7, + "created_at": "2021-01-01T00:00:00-05:00", + "updated_at": "2021-01-01T00:00:01-05:00", + "payment_schedules": [ + { + "id": 606405506930370000, + "created_at": "2021-01-01T00:00:00-05:00", + "updated_at": "2021-01-01T00:00:01-05:00", + "payment_terms_id": 706405506930370000, + "reference_id": null, + "reference_type": null, + "issued_at": "2021-01-01T00:00:00-05:00", + "due_at": "2021-01-02T00:00:00-05:00", + "completed_at": "2021-01-02T00:00:00-05:00", + "amount": "10.00", + "currency": "USD" + } + ], + "can_pay_early": true + }, + "admin_graphql_api_id": "gid://shopify/DraftOrder/890612572568261625", + "customer": { + "id": 706405506930370000, + "email": "john@doe.ca", + "created_at": null, + "updated_at": null, + "first_name": "John", + "last_name": "Smith", + "orders_count": 0, + "state": "disabled", + "total_spent": "0.00", + "last_order_id": null, + "note": null, + "verified_email": true, + "multipass_identifier": null, + "tax_exempt": false, + "tags": "", + "last_order_name": null, + "currency": "USD", + "phone": null, + "tax_exemptions": [], + "email_marketing_consent": { + "state": "not_subscribed", + "opt_in_level": null, + "consent_updated_at": null + }, + "sms_marketing_consent": null, + "admin_graphql_api_id": "gid://shopify/Customer/706405506930370084", + "default_address": { + "id": null, + "customer_id": 706405506930370000, + "first_name": null, + "last_name": null, + "company": null, + "address1": "123 Elm St.", + "address2": null, + "city": "Ottawa", + "province": "Ontario", + "country": "Canada", + "zip": "K2H7A8", + "phone": "123-123-1234", + "name": "", + "province_code": "ON", + "country_code": "CA", + "country_name": "Canada", + "default": true + } + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "draft_orders/update", + "x-shopify-triggered-at": "2024-08-07T22:53:37.729407140Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "fulfillment_events/create", + "name": "fulfillment_events/create", + "body": { + "id": 1234567, + "fulfillment_id": 123456, + "status": "in_transit", + "message": "Item is now in transit", + "happened_at": "2021-12-31T19:00:00-05:00", + "city": null, + "province": null, + "country": "CA", + "zip": null, + "address1": null, + "latitude": null, + "longitude": null, + "shop_id": 548380009, + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "estimated_delivery_at": null, + "order_id": 820982911946154500, + "admin_graphql_api_id": "gid://shopify/FulfillmentEvent/1234567" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "fulfillment_events/create", + "x-shopify-triggered-at": "2024-08-07T22:54:12.513618517Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "fulfillment_events/delete", + "name": "fulfillment_events/delete", + "body": { + "id": 1234567, + "fulfillment_id": 123456, + "status": "in_transit", + "message": "Item is now in transit", + "happened_at": "2021-12-31T19:00:00-05:00", + "city": null, + "province": null, + "country": "CA", + "zip": null, + "address1": null, + "latitude": null, + "longitude": null, + "shop_id": 548380009, + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "estimated_delivery_at": null, + "order_id": 820982911946154500, + "admin_graphql_api_id": "gid://shopify/FulfillmentEvent/1234567" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "fulfillment_events/delete", + "x-shopify-triggered-at": "2024-08-07T22:54:17.632607837Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "fulfillment_orders/cancellation_request_accepted", + "name": "fulfillment_orders/cancellation_request_accepted", + "body": { + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "closed" + }, + "message": "Order has not been shipped yet." + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "fulfillment_orders/cancellation_request_accepted", + "x-shopify-triggered-at": "2024-08-07T22:54:22.947401466Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "fulfillment_orders/cancellation_request_rejected", + "name": "fulfillment_orders/cancellation_request_rejected", + "body": { + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "in_progress", + "request_status": "cancellation_rejected" + }, + "message": "Order has already been shipped." + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "fulfillment_orders/cancellation_request_rejected", + "x-shopify-triggered-at": "2024-08-07T22:54:28.194322517Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "fulfillment_orders/cancellation_request_submitted", + "name": "fulfillment_orders/cancellation_request_submitted", + "body": { + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "in_progress", + "request_status": "cancellation_request" + }, + "fulfillment_order_merchant_request": { + "id": "gid://shopify/FulfillmentOrderMerchantRequest/1", + "message": "Customer cancelled their order" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "fulfillment_orders/cancellation_request_submitted", + "x-shopify-triggered-at": "2024-08-07T22:54:33.291162653Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "fulfillment_orders/cancelled", + "name": "fulfillment_orders/cancelled", + "body": { + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "cancelled" + }, + "replacement_fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/2", + "status": "open" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "fulfillment_orders/cancelled", + "x-shopify-triggered-at": "2024-08-07T22:54:38.378505209Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "fulfillment_orders/fulfillment_request_accepted", + "name": "fulfillment_orders/fulfillment_request_accepted", + "body": { + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "in_progress", + "request_status": "accepted" + }, + "message": "We will ship the item tomorrow." + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "fulfillment_orders/fulfillment_request_accepted", + "x-shopify-triggered-at": "2024-08-07T22:54:43.513563899Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "fulfillment_orders/fulfillment_request_rejected", + "name": "fulfillment_orders/fulfillment_request_rejected", + "body": { + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "open", + "request_status": "rejected" + }, + "message": "Can't fulfill due to no inventory on product." + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "fulfillment_orders/fulfillment_request_rejected", + "x-shopify-triggered-at": "2024-08-07T22:54:48.679476577Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "fulfillment_orders/fulfillment_request_submitted", + "name": "fulfillment_orders/fulfillment_request_submitted", + "body": { + "original_fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "open", + "request_status": "unsubmitted" + }, + "submitted_fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "open", + "request_status": "unsubmitted" + }, + "fulfillment_order_merchant_request": { + "id": "gid://shopify/FulfillmentOrderMerchantRequest/1", + "message": "Fragile" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "fulfillment_orders/fulfillment_request_submitted", + "x-shopify-triggered-at": "2024-08-07T22:54:53.730711129Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "fulfillment_orders/fulfillment_service_failed_to_complete", + "name": "fulfillment_orders/fulfillment_service_failed_to_complete", + "body": { + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "closed" + }, + "message": "We broke the last item." + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "fulfillment_orders/fulfillment_service_failed_to_complete", + "x-shopify-triggered-at": "2024-08-07T22:54:59.251945239Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "fulfillment_orders/hold_released", + "name": "fulfillment_orders/hold_released", + "body": { + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "open" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "fulfillment_orders/hold_released", + "x-shopify-triggered-at": "2024-08-07T22:55:04.432527192Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "fulfillment_orders/line_items_prepared_for_local_delivery", + "name": "fulfillment_orders/line_items_prepared_for_local_delivery", + "body": { + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "open", + "preparable": true, + "delivery_method": { + "method_type": "local" + } + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "fulfillment_orders/line_items_prepared_for_local_delivery", + "x-shopify-triggered-at": "2024-08-07T22:55:09.748104157Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "fulfillment_orders/line_items_prepared_for_pickup", + "name": "fulfillment_orders/line_items_prepared_for_pickup", + "body": { + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "open", + "preparable": true, + "delivery_method": { + "method_type": "pickup" + } + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "fulfillment_orders/line_items_prepared_for_pickup", + "x-shopify-triggered-at": "2024-08-07T22:55:15.149205926Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "fulfillment_orders/merged", + "name": "fulfillment_orders/merged", + "body": { + "merge_intents": [ + { + "fulfillment_order_id": 1, + "fulfillment_order_line_items": [ + { + "id": 1, + "quantity": 1 + } + ] + }, + { + "fulfillment_order_id": 2, + "fulfillment_order_line_items": [ + { + "id": 2, + "quantity": 1 + } + ] + } + ], + "fulfillment_order_merges": { + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "open" + } + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "fulfillment_orders/merged", + "x-shopify-triggered-at": "2024-08-07T22:55:20.701324075Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "fulfillment_orders/moved", + "name": "fulfillment_orders/moved", + "body": { + "original_fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "closed", + "assigned_location_id": "gid://shopify/Location/0" + }, + "moved_fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/2", + "status": "open", + "assigned_location_id": "gid://shopify/Location/1" + }, + "destination_location_id": "gid://shopify/Location/1", + "fulfillment_order_line_items_requested": [ + { + "id": "gid://shopify/FulfillmentOrderLineItem/1", + "quantity": 1 + } + ], + "source_location": { + "id": "gid://shopify/Location/0" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "fulfillment_orders/moved", + "x-shopify-triggered-at": "2024-08-07T22:55:25.839190078Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "fulfillment_orders/order_routing_complete", + "name": "fulfillment_orders/order_routing_complete", + "body": { + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "open" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "fulfillment_orders/order_routing_complete", + "x-shopify-triggered-at": "2024-08-07T22:55:31.300557451Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "fulfillment_orders/placed_on_hold", + "name": "fulfillment_orders/placed_on_hold", + "body": { + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "on_hold", + "fulfillment_holds": [ + { + "id": "gid://shopify/FulfillmentHold/1", + "held_by_requesting_app": false, + "reason": "other", + "reason_notes": "example" + } + ] + }, + "remaining_fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/2", + "status": "open" + }, + "held_fulfillment_order_line_items": [ + { + "id": "gid://shopify/FulfillmentOrderLineItem/3", + "quantity": 4 + } + ] + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "fulfillment_orders/placed_on_hold", + "x-shopify-triggered-at": "2024-08-07T22:55:36.572885269Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "fulfillment_orders/rescheduled", + "name": "fulfillment_orders/rescheduled", + "body": { + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "scheduled", + "fulfill_at": "2021-12-31T19:00:00-05:00" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "fulfillment_orders/rescheduled", + "x-shopify-triggered-at": "2024-08-07T22:55:46.021237736Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "fulfillment_orders/scheduled_fulfillment_order_ready", + "name": "fulfillment_orders/scheduled_fulfillment_order_ready", + "body": { + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "open" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "fulfillment_orders/scheduled_fulfillment_order_ready", + "x-shopify-triggered-at": "2024-08-07T22:55:51.240125874Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "fulfillment_orders/split", + "name": "fulfillment_orders/split", + "body": { + "split_line_items": [ + { + "id": "gid://shopify/FulfillmentOrderLineItem/1", + "quantity": 1 + } + ], + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "open" + }, + "remaining_fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/2", + "status": "open" + }, + "replacement_fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/3", + "status": "open" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "fulfillment_orders/split", + "x-shopify-triggered-at": "2024-08-07T22:55:56.429089585Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "fulfillments/create", + "name": "fulfillments/create", + "body": { + "id": 123456, + "order_id": 820982911946154500, + "status": "pending", + "created_at": "2021-12-31T19:00:00-05:00", + "service": null, + "updated_at": "2021-12-31T19:00:00-05:00", + "tracking_company": "UPS", + "shipment_status": null, + "location_id": null, + "origin_address": null, + "email": "jon@example.com", + "destination": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40003", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "line_items": [ + { + "id": 866550311766439000, + "variant_id": 808950810, + "title": "IPod Nano - 8GB", + "quantity": 1, + "sku": "IPOD2008PINK", + "variant_title": null, + "vendor": null, + "fulfillment_service": "manual", + "product_id": 632910392, + "requires_shipping": true, + "taxable": true, + "gift_card": false, + "name": "IPod Nano - 8GB", + "variant_inventory_management": "shopify", + "properties": [], + "product_exists": true, + "fulfillable_quantity": 1, + "grams": 567, + "price": "199.00", + "total_discount": "0.00", + "fulfillment_status": null, + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "discount_allocations": [], + "duties": [], + "admin_graphql_api_id": "gid://shopify/LineItem/866550311766439020", + "tax_lines": [] + }, + { + "id": 141249953214522980, + "variant_id": 808950810, + "title": "IPod Nano - 8GB", + "quantity": 1, + "sku": "IPOD2008PINK", + "variant_title": null, + "vendor": null, + "fulfillment_service": "manual", + "product_id": 632910392, + "requires_shipping": true, + "taxable": true, + "gift_card": false, + "name": "IPod Nano - 8GB", + "variant_inventory_management": "shopify", + "properties": [], + "product_exists": true, + "fulfillable_quantity": 1, + "grams": 567, + "price": "199.00", + "total_discount": "5.00", + "fulfillment_status": null, + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "total_discount_set": { + "shop_money": { + "amount": "5.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "5.00", + "currency_code": "USD" + } + }, + "discount_allocations": [ + { + "amount": "5.00", + "discount_application_index": 0, + "amount_set": { + "shop_money": { + "amount": "5.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "5.00", + "currency_code": "USD" + } + } + }, + { + "amount": "5.00", + "discount_application_index": 2, + "amount_set": { + "shop_money": { + "amount": "5.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "5.00", + "currency_code": "USD" + } + } + } + ], + "duties": [], + "admin_graphql_api_id": "gid://shopify/LineItem/141249953214522974", + "tax_lines": [] + } + ], + "tracking_number": "1z827wk74630", + "tracking_numbers": ["1z827wk74630"], + "tracking_url": "https://www.ups.com/WebTracking?loc=en_US&requester=ST&trackNums=1z827wk74630", + "tracking_urls": [ + "https://www.ups.com/WebTracking?loc=en_US&requester=ST&trackNums=1z827wk74630" + ], + "receipt": {}, + "name": "#9999.1", + "admin_graphql_api_id": "gid://shopify/Fulfillment/123456" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "fulfillments/create", + "x-shopify-triggered-at": "2024-08-07T22:54:01.001600265Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "fulfillments/update", + "name": "fulfillments/update", + "body": { + "id": 123456, + "order_id": 820982911946154500, + "status": "pending", + "created_at": "2021-12-31T19:00:00-05:00", + "service": null, + "updated_at": "2021-12-31T19:00:00-05:00", + "tracking_company": "UPS", + "shipment_status": null, + "location_id": null, + "origin_address": null, + "email": "jon@example.com", + "destination": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40003", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "line_items": [ + { + "id": 866550311766439000, + "variant_id": 808950810, + "title": "IPod Nano - 8GB", + "quantity": 1, + "sku": "IPOD2008PINK", + "variant_title": null, + "vendor": null, + "fulfillment_service": "manual", + "product_id": 632910392, + "requires_shipping": true, + "taxable": true, + "gift_card": false, + "name": "IPod Nano - 8GB", + "variant_inventory_management": "shopify", + "properties": [], + "product_exists": true, + "fulfillable_quantity": 1, + "grams": 567, + "price": "199.00", + "total_discount": "0.00", + "fulfillment_status": null, + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "discount_allocations": [], + "duties": [], + "admin_graphql_api_id": "gid://shopify/LineItem/866550311766439020", + "tax_lines": [] + }, + { + "id": 141249953214522980, + "variant_id": 808950810, + "title": "IPod Nano - 8GB", + "quantity": 1, + "sku": "IPOD2008PINK", + "variant_title": null, + "vendor": null, + "fulfillment_service": "manual", + "product_id": 632910392, + "requires_shipping": true, + "taxable": true, + "gift_card": false, + "name": "IPod Nano - 8GB", + "variant_inventory_management": "shopify", + "properties": [], + "product_exists": true, + "fulfillable_quantity": 1, + "grams": 567, + "price": "199.00", + "total_discount": "5.00", + "fulfillment_status": null, + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "total_discount_set": { + "shop_money": { + "amount": "5.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "5.00", + "currency_code": "USD" + } + }, + "discount_allocations": [ + { + "amount": "5.00", + "discount_application_index": 0, + "amount_set": { + "shop_money": { + "amount": "5.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "5.00", + "currency_code": "USD" + } + } + }, + { + "amount": "5.00", + "discount_application_index": 2, + "amount_set": { + "shop_money": { + "amount": "5.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "5.00", + "currency_code": "USD" + } + } + } + ], + "duties": [], + "admin_graphql_api_id": "gid://shopify/LineItem/141249953214522974", + "tax_lines": [] + } + ], + "tracking_number": "1z827wk74630", + "tracking_numbers": ["1z827wk74630"], + "tracking_url": "https://www.ups.com/WebTracking?loc=en_US&requester=ST&trackNums=1z827wk74630", + "tracking_urls": [ + "https://www.ups.com/WebTracking?loc=en_US&requester=ST&trackNums=1z827wk74630" + ], + "receipt": {}, + "name": "#9999.1", + "admin_graphql_api_id": "gid://shopify/Fulfillment/123456" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "fulfillments/update", + "x-shopify-triggered-at": "2024-08-07T22:54:06.433972696Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "inventory_items/create", + "name": "inventory_items/create", + "body": { + "id": 271878346596884000, + "sku": "example-sku", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "requires_shipping": true, + "cost": null, + "country_code_of_origin": null, + "province_code_of_origin": null, + "harmonized_system_code": null, + "tracked": true, + "country_harmonized_system_codes": [], + "admin_graphql_api_id": "gid://shopify/InventoryItem/271878346596884015" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "inventory_items/create", + "x-shopify-triggered-at": "2024-08-07T22:56:01.805811346Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "inventory_items/delete", + "name": "inventory_items/delete", + "body": { + "id": 271878346596884000, + "country_code_of_origin": null, + "province_code_of_origin": null, + "harmonized_system_code": null, + "country_harmonized_system_codes": [], + "admin_graphql_api_id": "gid://shopify/InventoryItem/271878346596884015" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "inventory_items/delete", + "x-shopify-triggered-at": "2024-08-07T22:56:07.575581829Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "inventory_items/update", + "name": "inventory_items/update", + "body": { + "id": 271878346596884000, + "sku": "example-sku", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "requires_shipping": true, + "cost": null, + "country_code_of_origin": null, + "province_code_of_origin": null, + "harmonized_system_code": null, + "tracked": true, + "country_harmonized_system_codes": [], + "admin_graphql_api_id": "gid://shopify/InventoryItem/271878346596884015" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "inventory_items/update", + "x-shopify-triggered-at": "2024-08-07T22:56:13.034845666Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "inventory_levels/connect", + "name": "inventory_levels/connect", + "body": { + "inventory_item_id": 271878346596884000, + "location_id": 24826418, + "available": null, + "updated_at": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/InventoryLevel/24826418?inventory_item_id=271878346596884015" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "inventory_levels/connect", + "x-shopify-triggered-at": "2024-08-07T22:56:18.293174977Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "inventory_levels/disconnect", + "name": "inventory_levels/disconnect", + "body": { + "inventory_item_id": 271878346596884000, + "location_id": 24826418 + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "inventory_levels/disconnect", + "x-shopify-triggered-at": "2024-08-07T22:56:23.647670650Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "inventory_levels/update", + "name": "inventory_levels/update", + "body": { + "inventory_item_id": 271878346596884000, + "location_id": 24826418, + "available": null, + "updated_at": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/InventoryLevel/24826418?inventory_item_id=271878346596884015" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "inventory_levels/update", + "x-shopify-triggered-at": "2024-08-07T22:56:28.834278316Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "locales/create", + "name": "locales/create", + "body": { + "locale": "fr-CA", + "published": true + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "locales/create", + "x-shopify-triggered-at": "2024-08-07T22:56:33.940054652Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "locales/update", + "name": "locales/update", + "body": { + "locale": "fr-CA", + "published": true + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "locales/update", + "x-shopify-triggered-at": "2024-08-07T22:56:43.082934480Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "locations/activate", + "name": "locations/activate", + "body": { + "id": 866550311766439000, + "name": "Example Shop", + "address1": "34 Example Street", + "address2": "Next to example", + "city": "ottawa", + "zip": "k1n5t5", + "province": "ontario", + "country": "CA", + "phone": "555-555-5555", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "country_code": "CA", + "country_name": "Canada", + "province_code": "ON", + "legacy": false, + "active": true, + "admin_graphql_api_id": "gid://shopify/Location/866550311766439020" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "locations/activate", + "x-shopify-triggered-at": "2024-08-07T22:56:48.637120681Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "locations/create", + "name": "locations/create", + "body": { + "id": 866550311766439000, + "name": "Example Shop", + "address1": "34 Example Street", + "address2": "Next to example", + "city": "ottawa", + "zip": "k1n5t5", + "province": "ontario", + "country": "CA", + "phone": "555-555-5555", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "country_code": "CA", + "country_name": "Canada", + "province_code": "ON", + "legacy": false, + "active": true, + "admin_graphql_api_id": "gid://shopify/Location/866550311766439020" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "locations/create", + "x-shopify-triggered-at": "2024-08-07T22:56:54.033291393Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "locations/deactivate", + "name": "locations/deactivate", + "body": { + "id": 866550311766439000, + "name": "Example Shop", + "address1": "34 Example Street", + "address2": "Next to example", + "city": "ottawa", + "zip": "k1n5t5", + "province": "ontario", + "country": "CA", + "phone": "555-555-5555", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "country_code": "CA", + "country_name": "Canada", + "province_code": "ON", + "legacy": false, + "active": true, + "admin_graphql_api_id": "gid://shopify/Location/866550311766439020" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "locations/deactivate", + "x-shopify-triggered-at": "2024-08-07T22:56:59.219600432Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "locations/delete", + "name": "locations/delete", + "body": { + "id": 866550311766439000 + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "locations/delete", + "x-shopify-triggered-at": "2024-08-07T22:57:04.832897130Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "locations/update", + "name": "locations/update", + "body": { + "id": 866550311766439000, + "name": "Example Shop", + "address1": "34 Example Street", + "address2": "Next to example", + "city": "ottawa", + "zip": "k1n5t5", + "province": "ontario", + "country": "CA", + "phone": "555-555-5555", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "country_code": "CA", + "country_name": "Canada", + "province_code": "ON", + "legacy": false, + "active": true, + "admin_graphql_api_id": "gid://shopify/Location/866550311766439020" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "locations/update", + "x-shopify-triggered-at": "2024-08-07T22:57:11.479946918Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "markets/create", + "name": "markets/create", + "body": { + "id": 188558248, + "name": "United States", + "enabled": true, + "regions": [ + { + "country_code": "US" + } + ] + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "markets/create", + "x-shopify-triggered-at": "2024-08-07T22:57:20.292701775Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "markets/delete", + "name": "markets/delete", + "body": { + "id": 188558248 + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "markets/delete", + "x-shopify-triggered-at": "2024-08-07T22:57:25.470119112Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "markets/update", + "name": "markets/update", + "body": { + "id": 188558248, + "name": "United States", + "enabled": true, + "regions": [ + { + "country_code": "US" + } + ] + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "markets/update", + "x-shopify-triggered-at": "2024-08-07T22:57:30.583400965Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "metaobjects/create", + "name": "metaobjects/create", + "body": { + "type": "example-type", + "handle": "example-metaobject", + "created_at": "1975-10-31T00:55:12-05:00", + "updated_at": "1985-07-13T01:55:12-04:00", + "display_name": "Example Metaobject", + "id": "gid://shopify/Metaobject/123", + "definition_id": "gid://shopify/MetaobjectDefinition/222", + "fields": { + "example-key": "example-value" + }, + "created_by_staff_id": "gid://shopify/StaffMember/444", + "created_by_app_id": "gid://shopify/ApiClient/333", + "capabilities": { + "publishable": { + "status": "draft" + } + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "metaobjects/create", + "x-shopify-triggered-at": "2024-08-07T22:57:36.305844942Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "metaobjects/delete", + "name": "metaobjects/delete", + "body": { + "id": "gid://shopify/Metaobject/123", + "type": "example-type", + "handle": "example-metaobject", + "created_by_app_id": "gid://shopify/ApiClient/333" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "metaobjects/delete", + "x-shopify-triggered-at": "2024-08-07T22:57:41.651693761Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "metaobjects/update", + "name": "metaobjects/update", + "body": { + "type": "example-type", + "handle": "example-metaobject", + "created_at": "1975-10-31T00:55:12-05:00", + "updated_at": "1985-07-13T01:55:12-04:00", + "display_name": "Example Metaobject", + "id": "gid://shopify/Metaobject/123", + "definition_id": "gid://shopify/MetaobjectDefinition/222", + "fields": { + "example-key": "example-value" + }, + "created_by_staff_id": "gid://shopify/StaffMember/444", + "created_by_app_id": "gid://shopify/ApiClient/333", + "capabilities": { + "publishable": { + "status": "draft" + } + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "metaobjects/update", + "x-shopify-triggered-at": "2024-08-07T22:57:47.264735263Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "order_transactions/create", + "name": "order_transactions/create", + "body": { + "id": 120560818172775260, + "order_id": 820982911946154500, + "kind": "authorization", + "gateway": "visa", + "status": "success", + "message": null, + "created_at": "2021-12-31T19:00:00-05:00", + "test": false, + "authorization": "1001", + "location_id": null, + "user_id": null, + "parent_id": null, + "processed_at": null, + "device_id": null, + "error_code": null, + "source_name": "web", + "payment_details": { + "credit_card_bin": null, + "avs_result_code": null, + "cvv_result_code": null, + "credit_card_number": "•••• •••• •••• 1234", + "credit_card_company": "Visa", + "buyer_action_info": null, + "credit_card_name": null, + "credit_card_wallet": null, + "credit_card_expiration_month": null, + "credit_card_expiration_year": null, + "payment_method_name": "visa" + }, + "receipt": {}, + "amount": "403.00", + "currency": "USD", + "payment_id": "#9999.1", + "total_unsettled_set": { + "presentment_money": { + "amount": "403.0", + "currency": "USD" + }, + "shop_money": { + "amount": "403.0", + "currency": "USD" + } + }, + "manual_payment_gateway": true, + "admin_graphql_api_id": "gid://shopify/OrderTransaction/120560818172775265" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "order_transactions/create", + "x-shopify-triggered-at": "2024-08-07T22:58:48.853958698Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "orders/cancelled", + "name": "orders/cancelled", + "body": { + "id": 820982911946154500, + "admin_graphql_api_id": "gid://shopify/Order/820982911946154508", + "app_id": null, + "browser_ip": null, + "buyer_accepts_marketing": true, + "cancel_reason": "customer", + "cancelled_at": "2021-12-31T19:00:00-05:00", + "cart_token": null, + "checkout_id": null, + "checkout_token": null, + "client_details": null, + "closed_at": null, + "confirmation_number": null, + "confirmed": false, + "contact_email": "jon@example.com", + "created_at": "2021-12-31T19:00:00-05:00", + "currency": "USD", + "current_subtotal_price": "398.00", + "current_subtotal_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "current_total_additional_fees_set": null, + "current_total_discounts": "0.00", + "current_total_discounts_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "current_total_duties_set": null, + "current_total_price": "398.00", + "current_total_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "current_total_tax": "0.00", + "current_total_tax_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "customer_locale": "en", + "device_id": null, + "discount_codes": [], + "email": "jon@example.com", + "estimated_taxes": false, + "financial_status": "voided", + "fulfillment_status": "pending", + "landing_site": null, + "landing_site_ref": null, + "location_id": null, + "merchant_of_record_app_id": null, + "name": "#9999", + "note": null, + "note_attributes": [], + "number": 234, + "order_number": 1234, + "order_status_url": "https://jsmith.myshopify.com/548380009/orders/123456abcd/authenticate?key=abcdefg", + "original_total_additional_fees_set": null, + "original_total_duties_set": null, + "payment_gateway_names": ["visa", "bogus"], + "phone": null, + "po_number": null, + "presentment_currency": "USD", + "processed_at": "2021-12-31T19:00:00-05:00", + "reference": null, + "referring_site": null, + "source_identifier": null, + "source_name": "web", + "source_url": null, + "subtotal_price": "388.00", + "subtotal_price_set": { + "shop_money": { + "amount": "388.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "388.00", + "currency_code": "USD" + } + }, + "tags": "tag1, tag2", + "tax_exempt": false, + "tax_lines": [], + "taxes_included": false, + "test": true, + "token": "123456abcd", + "total_discounts": "20.00", + "total_discounts_set": { + "shop_money": { + "amount": "20.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "20.00", + "currency_code": "USD" + } + }, + "total_line_items_price": "398.00", + "total_line_items_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "total_outstanding": "398.00", + "total_price": "388.00", + "total_price_set": { + "shop_money": { + "amount": "388.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "388.00", + "currency_code": "USD" + } + }, + "total_shipping_price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "total_tax": "0.00", + "total_tax_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "total_tip_received": "0.00", + "total_weight": 0, + "updated_at": "2021-12-31T19:00:00-05:00", + "user_id": null, + "billing_address": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40003", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "customer": { + "id": 115310627314723950, + "email": "john@example.com", + "created_at": null, + "updated_at": null, + "first_name": "John", + "last_name": "Smith", + "state": "disabled", + "note": null, + "verified_email": true, + "multipass_identifier": null, + "tax_exempt": false, + "phone": null, + "email_marketing_consent": { + "state": "not_subscribed", + "opt_in_level": null, + "consent_updated_at": null + }, + "sms_marketing_consent": null, + "tags": "", + "currency": "USD", + "tax_exemptions": [], + "admin_graphql_api_id": "gid://shopify/Customer/115310627314723954", + "default_address": { + "id": 715243470612851200, + "customer_id": 115310627314723950, + "first_name": null, + "last_name": null, + "company": null, + "address1": "123 Elm St.", + "address2": null, + "city": "Ottawa", + "province": "Ontario", + "country": "Canada", + "zip": "K2H7A8", + "phone": "123-123-1234", + "name": "", + "province_code": "ON", + "country_code": "CA", + "country_name": "Canada", + "default": true + } + }, + "discount_applications": [], + "fulfillments": [], + "line_items": [ + { + "id": 866550311766439000, + "admin_graphql_api_id": "gid://shopify/LineItem/866550311766439020", + "attributed_staffs": [ + { + "id": "gid://shopify/StaffMember/902541635", + "quantity": 1 + } + ], + "current_quantity": 1, + "fulfillable_quantity": 1, + "fulfillment_service": "manual", + "fulfillment_status": null, + "gift_card": false, + "grams": 567, + "name": "IPod Nano - 8GB", + "price": "199.00", + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "product_exists": true, + "product_id": 632910392, + "properties": [], + "quantity": 1, + "requires_shipping": true, + "sku": "IPOD2008PINK", + "taxable": true, + "title": "IPod Nano - 8GB", + "total_discount": "0.00", + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "variant_id": 808950810, + "variant_inventory_management": "shopify", + "variant_title": null, + "vendor": null, + "tax_lines": [], + "duties": [], + "discount_allocations": [] + }, + { + "id": 141249953214522980, + "admin_graphql_api_id": "gid://shopify/LineItem/141249953214522974", + "attributed_staffs": [], + "current_quantity": 1, + "fulfillable_quantity": 1, + "fulfillment_service": "manual", + "fulfillment_status": null, + "gift_card": false, + "grams": 567, + "name": "IPod Nano - 8GB", + "price": "199.00", + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "product_exists": true, + "product_id": 632910392, + "properties": [], + "quantity": 1, + "requires_shipping": true, + "sku": "IPOD2008PINK", + "taxable": true, + "title": "IPod Nano - 8GB", + "total_discount": "0.00", + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "variant_id": 808950810, + "variant_inventory_management": "shopify", + "variant_title": null, + "vendor": null, + "tax_lines": [], + "duties": [], + "discount_allocations": [] + } + ], + "payment_terms": null, + "refunds": [], + "shipping_address": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40003", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "shipping_lines": [ + { + "id": 271878346596884000, + "carrier_identifier": null, + "code": null, + "discounted_price": "10.00", + "discounted_price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "is_removed": false, + "phone": null, + "price": "10.00", + "price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "requested_fulfillment_service_id": null, + "source": "shopify", + "title": "Generic Shipping", + "tax_lines": [], + "discount_allocations": [] + } + ] + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "orders/cancelled", + "x-shopify-triggered-at": "2024-08-07T22:57:52.338800668Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "orders/create", + "name": "orders/create", + "body": { + "id": 820982911946154500, + "admin_graphql_api_id": "gid://shopify/Order/820982911946154508", + "app_id": null, + "browser_ip": null, + "buyer_accepts_marketing": true, + "cancel_reason": "customer", + "cancelled_at": "2021-12-31T19:00:00-05:00", + "cart_token": null, + "checkout_id": null, + "checkout_token": null, + "client_details": null, + "closed_at": null, + "confirmation_number": null, + "confirmed": false, + "contact_email": "jon@example.com", + "created_at": "2021-12-31T19:00:00-05:00", + "currency": "USD", + "current_subtotal_price": "398.00", + "current_subtotal_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "current_total_additional_fees_set": null, + "current_total_discounts": "0.00", + "current_total_discounts_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "current_total_duties_set": null, + "current_total_price": "398.00", + "current_total_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "current_total_tax": "0.00", + "current_total_tax_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "customer_locale": "en", + "device_id": null, + "discount_codes": [], + "email": "jon@example.com", + "estimated_taxes": false, + "financial_status": "voided", + "fulfillment_status": "pending", + "landing_site": null, + "landing_site_ref": null, + "location_id": null, + "merchant_of_record_app_id": null, + "name": "#9999", + "note": null, + "note_attributes": [], + "number": 234, + "order_number": 1234, + "order_status_url": "https://jsmith.myshopify.com/548380009/orders/123456abcd/authenticate?key=abcdefg", + "original_total_additional_fees_set": null, + "original_total_duties_set": null, + "payment_gateway_names": ["visa", "bogus"], + "phone": null, + "po_number": null, + "presentment_currency": "USD", + "processed_at": "2021-12-31T19:00:00-05:00", + "reference": null, + "referring_site": null, + "source_identifier": null, + "source_name": "web", + "source_url": null, + "subtotal_price": "388.00", + "subtotal_price_set": { + "shop_money": { + "amount": "388.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "388.00", + "currency_code": "USD" + } + }, + "tags": "tag1, tag2", + "tax_exempt": false, + "tax_lines": [], + "taxes_included": false, + "test": true, + "token": "123456abcd", + "total_discounts": "20.00", + "total_discounts_set": { + "shop_money": { + "amount": "20.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "20.00", + "currency_code": "USD" + } + }, + "total_line_items_price": "398.00", + "total_line_items_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "total_outstanding": "398.00", + "total_price": "388.00", + "total_price_set": { + "shop_money": { + "amount": "388.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "388.00", + "currency_code": "USD" + } + }, + "total_shipping_price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "total_tax": "0.00", + "total_tax_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "total_tip_received": "0.00", + "total_weight": 0, + "updated_at": "2021-12-31T19:00:00-05:00", + "user_id": null, + "billing_address": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40003", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "customer": { + "id": 115310627314723950, + "email": "john@example.com", + "created_at": null, + "updated_at": null, + "first_name": "John", + "last_name": "Smith", + "state": "disabled", + "note": null, + "verified_email": true, + "multipass_identifier": null, + "tax_exempt": false, + "phone": null, + "email_marketing_consent": { + "state": "not_subscribed", + "opt_in_level": null, + "consent_updated_at": null + }, + "sms_marketing_consent": null, + "tags": "", + "currency": "USD", + "tax_exemptions": [], + "admin_graphql_api_id": "gid://shopify/Customer/115310627314723954", + "default_address": { + "id": 715243470612851200, + "customer_id": 115310627314723950, + "first_name": null, + "last_name": null, + "company": null, + "address1": "123 Elm St.", + "address2": null, + "city": "Ottawa", + "province": "Ontario", + "country": "Canada", + "zip": "K2H7A8", + "phone": "123-123-1234", + "name": "", + "province_code": "ON", + "country_code": "CA", + "country_name": "Canada", + "default": true + } + }, + "discount_applications": [], + "fulfillments": [], + "line_items": [ + { + "id": 866550311766439000, + "admin_graphql_api_id": "gid://shopify/LineItem/866550311766439020", + "attributed_staffs": [ + { + "id": "gid://shopify/StaffMember/902541635", + "quantity": 1 + } + ], + "current_quantity": 1, + "fulfillable_quantity": 1, + "fulfillment_service": "manual", + "fulfillment_status": null, + "gift_card": false, + "grams": 567, + "name": "IPod Nano - 8GB", + "price": "199.00", + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "product_exists": true, + "product_id": 632910392, + "properties": [], + "quantity": 1, + "requires_shipping": true, + "sku": "IPOD2008PINK", + "taxable": true, + "title": "IPod Nano - 8GB", + "total_discount": "0.00", + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "variant_id": 808950810, + "variant_inventory_management": "shopify", + "variant_title": null, + "vendor": null, + "tax_lines": [], + "duties": [], + "discount_allocations": [] + }, + { + "id": 141249953214522980, + "admin_graphql_api_id": "gid://shopify/LineItem/141249953214522974", + "attributed_staffs": [], + "current_quantity": 1, + "fulfillable_quantity": 1, + "fulfillment_service": "manual", + "fulfillment_status": null, + "gift_card": false, + "grams": 567, + "name": "IPod Nano - 8GB", + "price": "199.00", + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "product_exists": true, + "product_id": 632910392, + "properties": [], + "quantity": 1, + "requires_shipping": true, + "sku": "IPOD2008PINK", + "taxable": true, + "title": "IPod Nano - 8GB", + "total_discount": "0.00", + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "variant_id": 808950810, + "variant_inventory_management": "shopify", + "variant_title": null, + "vendor": null, + "tax_lines": [], + "duties": [], + "discount_allocations": [] + } + ], + "payment_terms": null, + "refunds": [], + "shipping_address": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40003", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "shipping_lines": [ + { + "id": 271878346596884000, + "carrier_identifier": null, + "code": null, + "discounted_price": "10.00", + "discounted_price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "is_removed": false, + "phone": null, + "price": "10.00", + "price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "requested_fulfillment_service_id": null, + "source": "shopify", + "title": "Generic Shipping", + "tax_lines": [], + "discount_allocations": [] + } + ] + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "orders/create", + "x-shopify-triggered-at": "2024-08-07T22:57:57.290670248Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "orders/delete", + "name": "orders/delete", + "body": { + "id": 820982911946154500 + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "orders/delete", + "x-shopify-triggered-at": "2024-08-07T22:58:02.728860151Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "orders/edited", + "name": "orders/edited", + "body": { + "order_edit": { + "id": 78912328793123780, + "app_id": null, + "created_at": "2021-12-31T19:00:00-05:00", + "committed_at": "2021-12-31T19:00:00-05:00", + "notify_customer": false, + "order_id": 820982911946154500, + "staff_note": "", + "user_id": null, + "line_items": { + "additions": [ + { + "id": 78643924236718240, + "delta": 1 + } + ], + "removals": [ + { + "id": 866550311766439000, + "delta": 1 + } + ] + }, + "discounts": { + "line_item": { + "additions": [], + "removals": [] + } + }, + "shipping_lines": { + "additions": [], + "removals": [] + } + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "orders/edited", + "x-shopify-triggered-at": "2024-08-07T22:58:08.064630177Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "orders/fulfilled", + "name": "orders/fulfilled", + "body": { + "id": 820982911946154500, + "admin_graphql_api_id": "gid://shopify/Order/820982911946154508", + "app_id": null, + "browser_ip": null, + "buyer_accepts_marketing": true, + "cancel_reason": "customer", + "cancelled_at": "2021-12-31T19:00:00-05:00", + "cart_token": null, + "checkout_id": null, + "checkout_token": null, + "client_details": null, + "closed_at": null, + "confirmation_number": null, + "confirmed": false, + "contact_email": "jon@example.com", + "created_at": "2021-12-31T19:00:00-05:00", + "currency": "USD", + "current_subtotal_price": "398.00", + "current_subtotal_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "current_total_additional_fees_set": null, + "current_total_discounts": "0.00", + "current_total_discounts_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "current_total_duties_set": null, + "current_total_price": "398.00", + "current_total_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "current_total_tax": "0.00", + "current_total_tax_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "customer_locale": "en", + "device_id": null, + "discount_codes": [], + "email": "jon@example.com", + "estimated_taxes": false, + "financial_status": "voided", + "fulfillment_status": "pending", + "landing_site": null, + "landing_site_ref": null, + "location_id": null, + "merchant_of_record_app_id": null, + "name": "#9999", + "note": null, + "note_attributes": [], + "number": 234, + "order_number": 1234, + "order_status_url": "https://jsmith.myshopify.com/548380009/orders/123456abcd/authenticate?key=abcdefg", + "original_total_additional_fees_set": null, + "original_total_duties_set": null, + "payment_gateway_names": ["visa", "bogus"], + "phone": null, + "po_number": null, + "presentment_currency": "USD", + "processed_at": "2021-12-31T19:00:00-05:00", + "reference": null, + "referring_site": null, + "source_identifier": null, + "source_name": "web", + "source_url": null, + "subtotal_price": "388.00", + "subtotal_price_set": { + "shop_money": { + "amount": "388.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "388.00", + "currency_code": "USD" + } + }, + "tags": "tag1, tag2", + "tax_exempt": false, + "tax_lines": [], + "taxes_included": false, + "test": true, + "token": "123456abcd", + "total_discounts": "20.00", + "total_discounts_set": { + "shop_money": { + "amount": "20.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "20.00", + "currency_code": "USD" + } + }, + "total_line_items_price": "398.00", + "total_line_items_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "total_outstanding": "398.00", + "total_price": "388.00", + "total_price_set": { + "shop_money": { + "amount": "388.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "388.00", + "currency_code": "USD" + } + }, + "total_shipping_price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "total_tax": "0.00", + "total_tax_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "total_tip_received": "0.00", + "total_weight": 0, + "updated_at": "2021-12-31T19:00:00-05:00", + "user_id": null, + "billing_address": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40003", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "customer": { + "id": 115310627314723950, + "email": "john@example.com", + "created_at": null, + "updated_at": null, + "first_name": "John", + "last_name": "Smith", + "state": "disabled", + "note": null, + "verified_email": true, + "multipass_identifier": null, + "tax_exempt": false, + "phone": null, + "email_marketing_consent": { + "state": "not_subscribed", + "opt_in_level": null, + "consent_updated_at": null + }, + "sms_marketing_consent": null, + "tags": "", + "currency": "USD", + "tax_exemptions": [], + "admin_graphql_api_id": "gid://shopify/Customer/115310627314723954", + "default_address": { + "id": 715243470612851200, + "customer_id": 115310627314723950, + "first_name": null, + "last_name": null, + "company": null, + "address1": "123 Elm St.", + "address2": null, + "city": "Ottawa", + "province": "Ontario", + "country": "Canada", + "zip": "K2H7A8", + "phone": "123-123-1234", + "name": "", + "province_code": "ON", + "country_code": "CA", + "country_name": "Canada", + "default": true + } + }, + "discount_applications": [], + "fulfillments": [], + "line_items": [ + { + "id": 866550311766439000, + "admin_graphql_api_id": "gid://shopify/LineItem/866550311766439020", + "attributed_staffs": [ + { + "id": "gid://shopify/StaffMember/902541635", + "quantity": 1 + } + ], + "current_quantity": 1, + "fulfillable_quantity": 1, + "fulfillment_service": "manual", + "fulfillment_status": null, + "gift_card": false, + "grams": 567, + "name": "IPod Nano - 8GB", + "price": "199.00", + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "product_exists": true, + "product_id": 632910392, + "properties": [], + "quantity": 1, + "requires_shipping": true, + "sku": "IPOD2008PINK", + "taxable": true, + "title": "IPod Nano - 8GB", + "total_discount": "0.00", + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "variant_id": 808950810, + "variant_inventory_management": "shopify", + "variant_title": null, + "vendor": null, + "tax_lines": [], + "duties": [], + "discount_allocations": [] + }, + { + "id": 141249953214522980, + "admin_graphql_api_id": "gid://shopify/LineItem/141249953214522974", + "attributed_staffs": [], + "current_quantity": 1, + "fulfillable_quantity": 1, + "fulfillment_service": "manual", + "fulfillment_status": null, + "gift_card": false, + "grams": 567, + "name": "IPod Nano - 8GB", + "price": "199.00", + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "product_exists": true, + "product_id": 632910392, + "properties": [], + "quantity": 1, + "requires_shipping": true, + "sku": "IPOD2008PINK", + "taxable": true, + "title": "IPod Nano - 8GB", + "total_discount": "0.00", + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "variant_id": 808950810, + "variant_inventory_management": "shopify", + "variant_title": null, + "vendor": null, + "tax_lines": [], + "duties": [], + "discount_allocations": [] + } + ], + "payment_terms": null, + "refunds": [], + "shipping_address": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40003", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "shipping_lines": [ + { + "id": 271878346596884000, + "carrier_identifier": null, + "code": null, + "discounted_price": "10.00", + "discounted_price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "is_removed": false, + "phone": null, + "price": "10.00", + "price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "requested_fulfillment_service_id": null, + "source": "shopify", + "title": "Generic Shipping", + "tax_lines": [], + "discount_allocations": [] + } + ] + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "orders/fulfilled", + "x-shopify-triggered-at": "2024-08-07T22:58:13.384877484Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "orders/paid", + "name": "orders/paid", + "body": { + "id": 820982911946154500, + "admin_graphql_api_id": "gid://shopify/Order/820982911946154508", + "app_id": null, + "browser_ip": null, + "buyer_accepts_marketing": true, + "cancel_reason": "customer", + "cancelled_at": "2021-12-31T19:00:00-05:00", + "cart_token": null, + "checkout_id": null, + "checkout_token": null, + "client_details": null, + "closed_at": null, + "confirmation_number": null, + "confirmed": false, + "contact_email": "jon@example.com", + "created_at": "2021-12-31T19:00:00-05:00", + "currency": "USD", + "current_subtotal_price": "398.00", + "current_subtotal_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "current_total_additional_fees_set": null, + "current_total_discounts": "0.00", + "current_total_discounts_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "current_total_duties_set": null, + "current_total_price": "398.00", + "current_total_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "current_total_tax": "0.00", + "current_total_tax_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "customer_locale": "en", + "device_id": null, + "discount_codes": [], + "email": "jon@example.com", + "estimated_taxes": false, + "financial_status": "voided", + "fulfillment_status": "pending", + "landing_site": null, + "landing_site_ref": null, + "location_id": null, + "merchant_of_record_app_id": null, + "name": "#9999", + "note": null, + "note_attributes": [], + "number": 234, + "order_number": 1234, + "order_status_url": "https://jsmith.myshopify.com/548380009/orders/123456abcd/authenticate?key=abcdefg", + "original_total_additional_fees_set": null, + "original_total_duties_set": null, + "payment_gateway_names": ["visa", "bogus"], + "phone": null, + "po_number": null, + "presentment_currency": "USD", + "processed_at": "2021-12-31T19:00:00-05:00", + "reference": null, + "referring_site": null, + "source_identifier": null, + "source_name": "web", + "source_url": null, + "subtotal_price": "388.00", + "subtotal_price_set": { + "shop_money": { + "amount": "388.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "388.00", + "currency_code": "USD" + } + }, + "tags": "tag1, tag2", + "tax_exempt": false, + "tax_lines": [], + "taxes_included": false, + "test": true, + "token": "123456abcd", + "total_discounts": "20.00", + "total_discounts_set": { + "shop_money": { + "amount": "20.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "20.00", + "currency_code": "USD" + } + }, + "total_line_items_price": "398.00", + "total_line_items_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "total_outstanding": "398.00", + "total_price": "388.00", + "total_price_set": { + "shop_money": { + "amount": "388.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "388.00", + "currency_code": "USD" + } + }, + "total_shipping_price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "total_tax": "0.00", + "total_tax_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "total_tip_received": "0.00", + "total_weight": 0, + "updated_at": "2021-12-31T19:00:00-05:00", + "user_id": null, + "billing_address": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40003", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "customer": { + "id": 115310627314723950, + "email": "john@example.com", + "created_at": null, + "updated_at": null, + "first_name": "John", + "last_name": "Smith", + "state": "disabled", + "note": null, + "verified_email": true, + "multipass_identifier": null, + "tax_exempt": false, + "phone": null, + "email_marketing_consent": { + "state": "not_subscribed", + "opt_in_level": null, + "consent_updated_at": null + }, + "sms_marketing_consent": null, + "tags": "", + "currency": "USD", + "tax_exemptions": [], + "admin_graphql_api_id": "gid://shopify/Customer/115310627314723954", + "default_address": { + "id": 715243470612851200, + "customer_id": 115310627314723950, + "first_name": null, + "last_name": null, + "company": null, + "address1": "123 Elm St.", + "address2": null, + "city": "Ottawa", + "province": "Ontario", + "country": "Canada", + "zip": "K2H7A8", + "phone": "123-123-1234", + "name": "", + "province_code": "ON", + "country_code": "CA", + "country_name": "Canada", + "default": true + } + }, + "discount_applications": [], + "fulfillments": [], + "line_items": [ + { + "id": 866550311766439000, + "admin_graphql_api_id": "gid://shopify/LineItem/866550311766439020", + "attributed_staffs": [ + { + "id": "gid://shopify/StaffMember/902541635", + "quantity": 1 + } + ], + "current_quantity": 1, + "fulfillable_quantity": 1, + "fulfillment_service": "manual", + "fulfillment_status": null, + "gift_card": false, + "grams": 567, + "name": "IPod Nano - 8GB", + "price": "199.00", + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "product_exists": true, + "product_id": 632910392, + "properties": [], + "quantity": 1, + "requires_shipping": true, + "sku": "IPOD2008PINK", + "taxable": true, + "title": "IPod Nano - 8GB", + "total_discount": "0.00", + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "variant_id": 808950810, + "variant_inventory_management": "shopify", + "variant_title": null, + "vendor": null, + "tax_lines": [], + "duties": [], + "discount_allocations": [] + }, + { + "id": 141249953214522980, + "admin_graphql_api_id": "gid://shopify/LineItem/141249953214522974", + "attributed_staffs": [], + "current_quantity": 1, + "fulfillable_quantity": 1, + "fulfillment_service": "manual", + "fulfillment_status": null, + "gift_card": false, + "grams": 567, + "name": "IPod Nano - 8GB", + "price": "199.00", + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "product_exists": true, + "product_id": 632910392, + "properties": [], + "quantity": 1, + "requires_shipping": true, + "sku": "IPOD2008PINK", + "taxable": true, + "title": "IPod Nano - 8GB", + "total_discount": "0.00", + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "variant_id": 808950810, + "variant_inventory_management": "shopify", + "variant_title": null, + "vendor": null, + "tax_lines": [], + "duties": [], + "discount_allocations": [] + } + ], + "payment_terms": null, + "refunds": [], + "shipping_address": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40003", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "shipping_lines": [ + { + "id": 271878346596884000, + "carrier_identifier": null, + "code": null, + "discounted_price": "10.00", + "discounted_price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "is_removed": false, + "phone": null, + "price": "10.00", + "price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "requested_fulfillment_service_id": null, + "source": "shopify", + "title": "Generic Shipping", + "tax_lines": [], + "discount_allocations": [] + } + ] + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "orders/paid", + "x-shopify-triggered-at": "2024-08-07T22:58:21.978733396Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "orders/partially_fulfilled", + "name": "orders/partially_fulfilled", + "body": { + "id": 820982911946154500, + "admin_graphql_api_id": "gid://shopify/Order/820982911946154508", + "app_id": null, + "browser_ip": null, + "buyer_accepts_marketing": true, + "cancel_reason": "customer", + "cancelled_at": "2021-12-31T19:00:00-05:00", + "cart_token": null, + "checkout_id": null, + "checkout_token": null, + "client_details": null, + "closed_at": null, + "confirmation_number": null, + "confirmed": false, + "contact_email": "jon@example.com", + "created_at": "2021-12-31T19:00:00-05:00", + "currency": "USD", + "current_subtotal_price": "398.00", + "current_subtotal_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "current_total_additional_fees_set": null, + "current_total_discounts": "0.00", + "current_total_discounts_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "current_total_duties_set": null, + "current_total_price": "398.00", + "current_total_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "current_total_tax": "0.00", + "current_total_tax_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "customer_locale": "en", + "device_id": null, + "discount_codes": [], + "email": "jon@example.com", + "estimated_taxes": false, + "financial_status": "voided", + "fulfillment_status": "pending", + "landing_site": null, + "landing_site_ref": null, + "location_id": null, + "merchant_of_record_app_id": null, + "name": "#9999", + "note": null, + "note_attributes": [], + "number": 234, + "order_number": 1234, + "order_status_url": "https://jsmith.myshopify.com/548380009/orders/123456abcd/authenticate?key=abcdefg", + "original_total_additional_fees_set": null, + "original_total_duties_set": null, + "payment_gateway_names": ["visa", "bogus"], + "phone": null, + "po_number": null, + "presentment_currency": "USD", + "processed_at": "2021-12-31T19:00:00-05:00", + "reference": null, + "referring_site": null, + "source_identifier": null, + "source_name": "web", + "source_url": null, + "subtotal_price": "388.00", + "subtotal_price_set": { + "shop_money": { + "amount": "388.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "388.00", + "currency_code": "USD" + } + }, + "tags": "tag1, tag2", + "tax_exempt": false, + "tax_lines": [], + "taxes_included": false, + "test": true, + "token": "123456abcd", + "total_discounts": "20.00", + "total_discounts_set": { + "shop_money": { + "amount": "20.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "20.00", + "currency_code": "USD" + } + }, + "total_line_items_price": "398.00", + "total_line_items_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "total_outstanding": "398.00", + "total_price": "388.00", + "total_price_set": { + "shop_money": { + "amount": "388.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "388.00", + "currency_code": "USD" + } + }, + "total_shipping_price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "total_tax": "0.00", + "total_tax_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "total_tip_received": "0.00", + "total_weight": 0, + "updated_at": "2021-12-31T19:00:00-05:00", + "user_id": null, + "billing_address": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40003", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "customer": { + "id": 115310627314723950, + "email": "john@example.com", + "created_at": null, + "updated_at": null, + "first_name": "John", + "last_name": "Smith", + "state": "disabled", + "note": null, + "verified_email": true, + "multipass_identifier": null, + "tax_exempt": false, + "phone": null, + "email_marketing_consent": { + "state": "not_subscribed", + "opt_in_level": null, + "consent_updated_at": null + }, + "sms_marketing_consent": null, + "tags": "", + "currency": "USD", + "tax_exemptions": [], + "admin_graphql_api_id": "gid://shopify/Customer/115310627314723954", + "default_address": { + "id": 715243470612851200, + "customer_id": 115310627314723950, + "first_name": null, + "last_name": null, + "company": null, + "address1": "123 Elm St.", + "address2": null, + "city": "Ottawa", + "province": "Ontario", + "country": "Canada", + "zip": "K2H7A8", + "phone": "123-123-1234", + "name": "", + "province_code": "ON", + "country_code": "CA", + "country_name": "Canada", + "default": true + } + }, + "discount_applications": [], + "fulfillments": [], + "line_items": [ + { + "id": 866550311766439000, + "admin_graphql_api_id": "gid://shopify/LineItem/866550311766439020", + "attributed_staffs": [ + { + "id": "gid://shopify/StaffMember/902541635", + "quantity": 1 + } + ], + "current_quantity": 1, + "fulfillable_quantity": 1, + "fulfillment_service": "manual", + "fulfillment_status": null, + "gift_card": false, + "grams": 567, + "name": "IPod Nano - 8GB", + "price": "199.00", + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "product_exists": true, + "product_id": 632910392, + "properties": [], + "quantity": 1, + "requires_shipping": true, + "sku": "IPOD2008PINK", + "taxable": true, + "title": "IPod Nano - 8GB", + "total_discount": "0.00", + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "variant_id": 808950810, + "variant_inventory_management": "shopify", + "variant_title": null, + "vendor": null, + "tax_lines": [], + "duties": [], + "discount_allocations": [] + }, + { + "id": 141249953214522980, + "admin_graphql_api_id": "gid://shopify/LineItem/141249953214522974", + "attributed_staffs": [], + "current_quantity": 1, + "fulfillable_quantity": 1, + "fulfillment_service": "manual", + "fulfillment_status": null, + "gift_card": false, + "grams": 567, + "name": "IPod Nano - 8GB", + "price": "199.00", + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "product_exists": true, + "product_id": 632910392, + "properties": [], + "quantity": 1, + "requires_shipping": true, + "sku": "IPOD2008PINK", + "taxable": true, + "title": "IPod Nano - 8GB", + "total_discount": "0.00", + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "variant_id": 808950810, + "variant_inventory_management": "shopify", + "variant_title": null, + "vendor": null, + "tax_lines": [], + "duties": [], + "discount_allocations": [] + } + ], + "payment_terms": null, + "refunds": [], + "shipping_address": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40003", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "shipping_lines": [ + { + "id": 271878346596884000, + "carrier_identifier": null, + "code": null, + "discounted_price": "10.00", + "discounted_price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "is_removed": false, + "phone": null, + "price": "10.00", + "price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "requested_fulfillment_service_id": null, + "source": "shopify", + "title": "Generic Shipping", + "tax_lines": [], + "discount_allocations": [] + } + ] + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "orders/partially_fulfilled", + "x-shopify-triggered-at": "2024-08-07T22:58:27.151252909Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "orders/risk_assessment_changed", + "name": "orders/risk_assessment_changed", + "body": { + "provider_id": null, + "provider_title": null, + "risk_level": "medium", + "created_at": null + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "orders/risk_assessment_changed", + "x-shopify-triggered-at": "2024-08-07T22:58:32.591487627Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "orders/shopify_protect_eligibility_changed", + "name": "orders/shopify_protect_eligibility_changed", + "body": { + "order_id": 1, + "status": "active", + "eligibility": { + "status": "eligible" + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "orders/shopify_protect_eligibility_changed", + "x-shopify-triggered-at": "2024-08-07T22:58:38.030497101Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "orders/updated", + "name": "orders/updated", + "body": { + "id": 820982911946154500, + "admin_graphql_api_id": "gid://shopify/Order/820982911946154508", + "app_id": null, + "browser_ip": null, + "buyer_accepts_marketing": true, + "cancel_reason": "customer", + "cancelled_at": "2021-12-31T19:00:00-05:00", + "cart_token": null, + "checkout_id": null, + "checkout_token": null, + "client_details": null, + "closed_at": null, + "confirmation_number": null, + "confirmed": false, + "contact_email": "jon@example.com", + "created_at": "2021-12-31T19:00:00-05:00", + "currency": "USD", + "current_subtotal_price": "398.00", + "current_subtotal_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "current_total_additional_fees_set": null, + "current_total_discounts": "0.00", + "current_total_discounts_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "current_total_duties_set": null, + "current_total_price": "398.00", + "current_total_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "current_total_tax": "0.00", + "current_total_tax_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "customer_locale": "en", + "device_id": null, + "discount_codes": [], + "email": "jon@example.com", + "estimated_taxes": false, + "financial_status": "voided", + "fulfillment_status": "pending", + "landing_site": null, + "landing_site_ref": null, + "location_id": null, + "merchant_of_record_app_id": null, + "name": "#9999", + "note": null, + "note_attributes": [], + "number": 234, + "order_number": 1234, + "order_status_url": "https://jsmith.myshopify.com/548380009/orders/123456abcd/authenticate?key=abcdefg", + "original_total_additional_fees_set": null, + "original_total_duties_set": null, + "payment_gateway_names": ["visa", "bogus"], + "phone": null, + "po_number": null, + "presentment_currency": "USD", + "processed_at": "2021-12-31T19:00:00-05:00", + "reference": null, + "referring_site": null, + "source_identifier": null, + "source_name": "web", + "source_url": null, + "subtotal_price": "388.00", + "subtotal_price_set": { + "shop_money": { + "amount": "388.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "388.00", + "currency_code": "USD" + } + }, + "tags": "tag1, tag2", + "tax_exempt": false, + "tax_lines": [], + "taxes_included": false, + "test": true, + "token": "123456abcd", + "total_discounts": "20.00", + "total_discounts_set": { + "shop_money": { + "amount": "20.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "20.00", + "currency_code": "USD" + } + }, + "total_line_items_price": "398.00", + "total_line_items_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "total_outstanding": "398.00", + "total_price": "388.00", + "total_price_set": { + "shop_money": { + "amount": "388.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "388.00", + "currency_code": "USD" + } + }, + "total_shipping_price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "total_tax": "0.00", + "total_tax_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "total_tip_received": "0.00", + "total_weight": 0, + "updated_at": "2021-12-31T19:00:00-05:00", + "user_id": null, + "billing_address": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40003", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "customer": { + "id": 115310627314723950, + "email": "john@example.com", + "created_at": null, + "updated_at": null, + "first_name": "John", + "last_name": "Smith", + "state": "disabled", + "note": null, + "verified_email": true, + "multipass_identifier": null, + "tax_exempt": false, + "phone": null, + "email_marketing_consent": { + "state": "not_subscribed", + "opt_in_level": null, + "consent_updated_at": null + }, + "sms_marketing_consent": null, + "tags": "", + "currency": "USD", + "tax_exemptions": [], + "admin_graphql_api_id": "gid://shopify/Customer/115310627314723954", + "default_address": { + "id": 715243470612851200, + "customer_id": 115310627314723950, + "first_name": null, + "last_name": null, + "company": null, + "address1": "123 Elm St.", + "address2": null, + "city": "Ottawa", + "province": "Ontario", + "country": "Canada", + "zip": "K2H7A8", + "phone": "123-123-1234", + "name": "", + "province_code": "ON", + "country_code": "CA", + "country_name": "Canada", + "default": true + } + }, + "discount_applications": [], + "fulfillments": [], + "line_items": [ + { + "id": 866550311766439000, + "admin_graphql_api_id": "gid://shopify/LineItem/866550311766439020", + "attributed_staffs": [ + { + "id": "gid://shopify/StaffMember/902541635", + "quantity": 1 + } + ], + "current_quantity": 1, + "fulfillable_quantity": 1, + "fulfillment_service": "manual", + "fulfillment_status": null, + "gift_card": false, + "grams": 567, + "name": "IPod Nano - 8GB", + "price": "199.00", + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "product_exists": true, + "product_id": 632910392, + "properties": [], + "quantity": 1, + "requires_shipping": true, + "sku": "IPOD2008PINK", + "taxable": true, + "title": "IPod Nano - 8GB", + "total_discount": "0.00", + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "variant_id": 808950810, + "variant_inventory_management": "shopify", + "variant_title": null, + "vendor": null, + "tax_lines": [], + "duties": [], + "discount_allocations": [] + }, + { + "id": 141249953214522980, + "admin_graphql_api_id": "gid://shopify/LineItem/141249953214522974", + "attributed_staffs": [], + "current_quantity": 1, + "fulfillable_quantity": 1, + "fulfillment_service": "manual", + "fulfillment_status": null, + "gift_card": false, + "grams": 567, + "name": "IPod Nano - 8GB", + "price": "199.00", + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "product_exists": true, + "product_id": 632910392, + "properties": [], + "quantity": 1, + "requires_shipping": true, + "sku": "IPOD2008PINK", + "taxable": true, + "title": "IPod Nano - 8GB", + "total_discount": "0.00", + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "variant_id": 808950810, + "variant_inventory_management": "shopify", + "variant_title": null, + "vendor": null, + "tax_lines": [], + "duties": [], + "discount_allocations": [] + } + ], + "payment_terms": null, + "refunds": [], + "shipping_address": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40003", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "shipping_lines": [ + { + "id": 271878346596884000, + "carrier_identifier": null, + "code": null, + "discounted_price": "10.00", + "discounted_price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "is_removed": false, + "phone": null, + "price": "10.00", + "price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "requested_fulfillment_service_id": null, + "source": "shopify", + "title": "Generic Shipping", + "tax_lines": [], + "discount_allocations": [] + } + ] + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "orders/updated", + "x-shopify-triggered-at": "2024-08-07T22:58:43.610785557Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "payment_schedules/due", + "name": "payment_schedules/due", + "body": { + "id": 606405506930370000, + "payment_terms_id": 706405506930370000, + "amount": "10.00", + "currency": "USD", + "issued_at": "2021-01-01T00:00:00-05:00", + "due_at": "2021-01-02T00:00:00-05:00", + "completed_at": "2021-01-02T00:00:00-05:00", + "created_at": "2021-01-01T00:00:00-05:00", + "updated_at": "2021-01-01T00:00:01-05:00", + "admin_graphql_api_id": "gid://shopify/PaymentSchedule/606405506930370084" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "payment_schedules/due", + "x-shopify-triggered-at": "2024-08-07T22:58:54.463509482Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "payment_terms/create", + "name": "payment_terms/create", + "body": { + "id": 706405506930370000, + "payment_terms_name": "Net 7", + "payment_terms_type": "net", + "due_in_days": 7, + "created_at": "2021-01-01T00:00:00-05:00", + "updated_at": "2021-01-01T00:00:01-05:00", + "payment_schedules": [ + { + "id": 606405506930370000, + "payment_terms_id": 706405506930370000, + "amount": "10.00", + "currency": "USD", + "issued_at": "2021-01-01T00:00:00-05:00", + "due_at": "2021-01-02T00:00:00-05:00", + "completed_at": "2021-01-02T00:00:00-05:00", + "created_at": "2021-01-01T00:00:00-05:00", + "updated_at": "2021-01-01T00:00:01-05:00", + "admin_graphql_api_id": "gid://shopify/PaymentSchedule/606405506930370084" + } + ], + "admin_graphql_api_id": "gid://shopify/PaymentsPaymentFlexibilityPaymentTermsPaymentTerms/706405506930370084" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "payment_terms/create", + "x-shopify-triggered-at": "2024-08-07T22:58:59.427458111Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "payment_terms/delete", + "name": "payment_terms/delete", + "body": { + "id": 706405506930370000 + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "payment_terms/delete", + "x-shopify-triggered-at": "2024-08-07T22:59:04.792354399Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "payment_terms/update", + "name": "payment_terms/update", + "body": { + "id": 706405506930370000, + "payment_terms_name": "Net 7", + "payment_terms_type": "net", + "due_in_days": 7, + "created_at": "2021-01-01T00:00:00-05:00", + "updated_at": "2021-01-01T00:00:01-05:00", + "payment_schedules": [ + { + "id": 606405506930370000, + "payment_terms_id": 706405506930370000, + "amount": "10.00", + "currency": "USD", + "issued_at": "2021-01-01T00:00:00-05:00", + "due_at": "2021-01-02T00:00:00-05:00", + "completed_at": "2021-01-02T00:00:00-05:00", + "created_at": "2021-01-01T00:00:00-05:00", + "updated_at": "2021-01-01T00:00:01-05:00", + "admin_graphql_api_id": "gid://shopify/PaymentSchedule/606405506930370084" + } + ], + "admin_graphql_api_id": "gid://shopify/PaymentsPaymentFlexibilityPaymentTermsPaymentTerms/706405506930370084" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "payment_terms/update", + "x-shopify-triggered-at": "2024-08-07T22:59:10.596439947Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "product_feeds/create", + "name": "product_feeds/create", + "body": { + "id": "gid://shopify/ProductFeed/", + "country": "CA", + "language": "EN", + "status": "" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "product_feeds/create", + "x-shopify-triggered-at": "2024-08-07T22:59:31.722134805Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "product_feeds/full_sync", + "name": "product_feeds/full_sync", + "body": { + "metadata": { + "action": "CREATE", + "type": "FULL", + "resource": "PRODUCT", + "fullSyncId": "gid://shopify/ProductFullSync/1123511235", + "truncatedFields": [], + "occurred_at": "2022-01-01T00:00:00.000Z" + }, + "productFeed": { + "id": "gid://shopify/ProductFeed/12345", + "shop_id": "gid://shopify/Shop/12345", + "country": "CA", + "language": "EN" + }, + "product": { + "id": "gid://shopify/Product/12345", + "title": "Coffee", + "description": "The best coffee in the world", + "onlineStoreUrl": "https://example.com/products/coffee", + "createdAt": "2021-12-31T19:00:00-05:00", + "updatedAt": "2021-12-31T19:00:00-05:00", + "isPublished": true, + "publishedAt": "2021-12-31T19:00:00-05:00", + "productType": "Coffee", + "vendor": "Cawfee Inc", + "handle": "", + "images": { + "edges": [ + { + "node": { + "id": "gid://shopify/ProductImage/394", + "url": "https://cdn.shopify.com/s/files/1/0262/9117/5446/products/IMG_0022.jpg?v=1675101331", + "height": 3024, + "width": 4032 + } + } + ] + }, + "options": [ + { + "name": "Title", + "values": ["151cm", "155cm", "158cm"] + } + ], + "seo": { + "title": "seo title", + "description": "seo description" + }, + "tags": ["tag1", "tag2"], + "variants": { + "edges": [ + { + "node": { + "id": "gid://shopify/ProductVariant/1", + "title": "151cm", + "price": { + "amount": "100.00", + "currencyCode": "CAD" + }, + "compareAtPrice": null, + "sku": "12345", + "barcode": null, + "quantityAvailable": 10, + "availableForSale": true, + "weight": 2.3, + "weightUnit": "KILOGRAMS", + "requireShipping": true, + "inventoryPolicy": "DENY", + "createdAt": "2021-12-31T19:00:00-05:00", + "updatedAt": "2021-12-31T19:00:00-05:00", + "image": { + "id": "gid://shopify/ProductImage/394", + "url": "https://cdn.shopify.com/s/files/1/0262/9117/5446/products/IMG_0022.jpg?v=1675101331", + "height": 3024, + "width": 4032 + }, + "selectedOptions": [ + { + "name": "Title", + "value": "151cm" + } + ] + } + } + ] + } + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "product_feeds/full_sync", + "x-shopify-triggered-at": "2024-08-07T22:59:40.683206202Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "product_feeds/full_sync_finish", + "name": "product_feeds/full_sync_finish", + "body": { + "metadata": { + "action": "CREATE", + "type": "FULL", + "resource": "PRODUCT", + "fullSyncId": "gid://shopify/ProductFullSync/1123511235", + "truncatedFields": [], + "occurred_at": "2022-01-01T00:00:00.000Z" + }, + "productFeed": { + "id": "gid://shopify/ProductFeed/12345", + "shop_id": "gid://shopify/Shop/12345", + "country": "CA", + "language": "EN" + }, + "fullSync": { + "createdAt": "2021-12-31 19:00:00 -0500", + "errorCode": null, + "status": "completed", + "count": 12, + "url": null + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "product_feeds/full_sync_finish", + "x-shopify-triggered-at": "2024-08-07T22:59:45.842464923Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "product_feeds/incremental_sync", + "name": "product_feeds/incremental_sync", + "body": { + "metadata": { + "action": "CREATE", + "type": "INCREMENTAL", + "resource": "PRODUCT", + "truncatedFields": [], + "occured_at": "2021-12-31T19:00:00-05:00" + }, + "productFeed": { + "id": "gid://shopify/ProductFeed/12345", + "shop_id": "gid://shopify/Shop/12345", + "country": "CA", + "language": "EN" + }, + "product": { + "id": "gid://shopify/Product/12345", + "title": "Coffee", + "description": "The best coffee in the world", + "onlineStoreUrl": "https://example.com/products/coffee", + "createdAt": "2021-12-31T19:00:00-05:00", + "updatedAt": "2021-12-31T19:00:00-05:00", + "isPublished": true, + "publishedAt": "2021-12-31T19:00:00-05:00", + "productType": "Coffee", + "vendor": "Cawfee Inc", + "handle": "", + "images": { + "edges": [ + { + "node": { + "id": "gid://shopify/ProductImage/394", + "url": "https://cdn.shopify.com/s/files/1/0262/9117/5446/products/IMG_0022.jpg?v=1675101331", + "height": 3024, + "width": 4032 + } + } + ] + }, + "options": [ + { + "name": "Title", + "values": ["151cm", "155cm", "158cm"] + } + ], + "seo": { + "title": "seo title", + "description": "seo description" + }, + "tags": ["tag1", "tag2"], + "variants": { + "edges": [ + { + "node": { + "id": "gid://shopify/ProductVariant/1", + "title": "151cm", + "price": { + "amount": "100.00", + "currencyCode": "CAD" + }, + "compareAtPrice": null, + "sku": "12345", + "barcode": null, + "quantityAvailable": 10, + "availableForSale": true, + "weight": 2.3, + "weightUnit": "KILOGRAMS", + "requireShipping": true, + "inventoryPolicy": "DENY", + "createdAt": "2021-12-31T19:00:00-05:00", + "updatedAt": "2021-12-31T19:00:00-05:00", + "image": { + "id": "gid://shopify/ProductImage/394", + "url": "https://cdn.shopify.com/s/files/1/0262/9117/5446/products/IMG_0022.jpg?v=1675101331", + "height": 3024, + "width": 4032 + }, + "selectedOptions": [ + { + "name": "Title", + "value": "151cm" + } + ] + } + } + ] + } + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "product_feeds/incremental_sync", + "x-shopify-triggered-at": "2024-08-07T22:59:51.446208444Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "product_feeds/update", + "name": "product_feeds/update", + "body": { + "id": "gid://shopify/ProductFeed/", + "country": "CA", + "language": "EN", + "status": "" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "product_feeds/update", + "x-shopify-triggered-at": "2024-08-07T23:00:00.249354638Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "product_listings/add", + "name": "product_listings/add", + "body": { + "product_listing": { + "product_id": 788032119674292900, + "created_at": null, + "updated_at": "2021-12-31T19:00:00-05:00", + "body_html": "An example T-Shirt", + "handle": "example-t-shirt", + "product_type": "Shirts", + "title": "Example T-Shirt", + "vendor": "Acme", + "available": true, + "tags": "example, mens, t-shirt", + "published_at": "2021-12-31T19:00:00-05:00", + "variants": [ + { + "id": 642667041472714000, + "title": "Small", + "option_values": [ + { + "option_id": 527050010214937800, + "name": "Title", + "value": "Small" + } + ], + "price": "19.99", + "formatted_price": "$19.99", + "compare_at_price": "24.99", + "grams": 200, + "requires_shipping": true, + "sku": null, + "barcode": null, + "taxable": true, + "position": 1, + "available": true, + "inventory_policy": "deny", + "inventory_quantity": 75, + "inventory_management": null, + "fulfillment_service": "manual", + "weight": null, + "weight_unit": "lb", + "image_id": null, + "created_at": "2021-12-29T19:00:00-05:00", + "updated_at": "2021-12-30T19:00:00-05:00" + }, + { + "id": 757650484644203900, + "title": "Medium", + "option_values": [ + { + "option_id": 527050010214937800, + "name": "Title", + "value": "Medium" + } + ], + "price": "19.99", + "formatted_price": "$19.99", + "compare_at_price": "24.99", + "grams": 200, + "requires_shipping": true, + "sku": null, + "barcode": null, + "taxable": true, + "position": 2, + "available": true, + "inventory_policy": "deny", + "inventory_quantity": 50, + "inventory_management": null, + "fulfillment_service": "manual", + "weight": null, + "weight_unit": "lb", + "image_id": null, + "created_at": "2021-12-29T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00" + } + ], + "images": [], + "options": [ + { + "id": 527050010214937800, + "name": "Title", + "product_id": 788032119674292900, + "position": 1, + "values": ["Small", "Medium"] + } + ] + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "product_listings/add", + "x-shopify-triggered-at": "2024-08-07T23:00:06.009653743Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "product_listings/remove", + "name": "product_listings/remove", + "body": { + "product_listing": { + "product_id": 788032119674292900 + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "product_listings/remove", + "x-shopify-triggered-at": "2024-08-07T23:00:12.678096191Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "product_listings/update", + "name": "product_listings/update", + "body": { + "product_listing": { + "product_id": 788032119674292900, + "created_at": null, + "updated_at": "2021-12-31T19:00:00-05:00", + "body_html": "An example T-Shirt", + "handle": "example-t-shirt", + "product_type": "Shirts", + "title": "Example T-Shirt", + "vendor": "Acme", + "available": true, + "tags": "example, mens, t-shirt", + "published_at": "2021-12-31T19:00:00-05:00", + "variants": [ + { + "id": 642667041472714000, + "title": "Small", + "option_values": [ + { + "option_id": 527050010214937800, + "name": "Title", + "value": "Small" + } + ], + "price": "19.99", + "formatted_price": "$19.99", + "compare_at_price": "24.99", + "grams": 200, + "requires_shipping": true, + "sku": null, + "barcode": null, + "taxable": true, + "position": 1, + "available": true, + "inventory_policy": "deny", + "inventory_quantity": 75, + "inventory_management": null, + "fulfillment_service": "manual", + "weight": null, + "weight_unit": "lb", + "image_id": null, + "created_at": "2021-12-29T19:00:00-05:00", + "updated_at": "2021-12-30T19:00:00-05:00" + }, + { + "id": 757650484644203900, + "title": "Medium", + "option_values": [ + { + "option_id": 527050010214937800, + "name": "Title", + "value": "Medium" + } + ], + "price": "19.99", + "formatted_price": "$19.99", + "compare_at_price": "24.99", + "grams": 200, + "requires_shipping": true, + "sku": null, + "barcode": null, + "taxable": true, + "position": 2, + "available": true, + "inventory_policy": "deny", + "inventory_quantity": 50, + "inventory_management": null, + "fulfillment_service": "manual", + "weight": null, + "weight_unit": "lb", + "image_id": null, + "created_at": "2021-12-29T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00" + } + ], + "images": [], + "options": [ + { + "id": 527050010214937800, + "name": "Title", + "product_id": 788032119674292900, + "position": 1, + "values": ["Small", "Medium"] + } + ] + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "product_listings/update", + "x-shopify-triggered-at": "2024-08-07T23:00:18.530397876Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "product_publications/create", + "name": "product_publications/create", + "body": { + "id": null, + "publication_id": null, + "published_at": "2021-12-31T19:00:00-05:00", + "published": true, + "created_at": null, + "updated_at": null, + "product_id": 788032119674292900 + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "product_publications/create", + "x-shopify-triggered-at": "2024-08-07T23:00:31.985927796Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "product_publications/delete", + "name": "product_publications/delete", + "body": { + "id": null + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "product_publications/delete", + "x-shopify-triggered-at": "2024-08-07T23:00:37.591429860Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "product_publications/update", + "name": "product_publications/update", + "body": { + "id": null, + "publication_id": null, + "published_at": "2021-12-31T19:00:00-05:00", + "published": true, + "created_at": null, + "updated_at": null, + "product_id": 788032119674292900 + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "product_publications/update", + "x-shopify-triggered-at": "2024-08-07T23:00:42.927374952Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "products/create", + "name": "products/create", + "body": { + "admin_graphql_api_id": "gid://shopify/Product/788032119674292922", + "body_html": "An example T-Shirt", + "created_at": null, + "handle": "example-t-shirt", + "id": 788032119674292900, + "product_type": "Shirts", + "published_at": "2021-12-31T19:00:00-05:00", + "template_suffix": null, + "title": "Example T-Shirt", + "updated_at": "2021-12-31T19:00:00-05:00", + "vendor": "Acme", + "status": "active", + "published_scope": "web", + "tags": "example, mens, t-shirt", + "variants": [ + { + "admin_graphql_api_id": "gid://shopify/ProductVariant/642667041472713922", + "barcode": null, + "compare_at_price": "24.99", + "created_at": "2021-12-29T19:00:00-05:00", + "id": 642667041472714000, + "inventory_policy": "deny", + "position": 1, + "price": "19.99", + "product_id": 788032119674292900, + "sku": "example-shirt-s", + "taxable": true, + "title": "Small", + "updated_at": "2021-12-30T19:00:00-05:00", + "option1": "Small", + "option2": null, + "option3": null, + "image_id": null, + "inventory_item_id": null, + "inventory_quantity": 75, + "old_inventory_quantity": 75 + }, + { + "admin_graphql_api_id": "gid://shopify/ProductVariant/757650484644203962", + "barcode": null, + "compare_at_price": "24.99", + "created_at": "2021-12-29T19:00:00-05:00", + "id": 757650484644203900, + "inventory_policy": "deny", + "position": 2, + "price": "19.99", + "product_id": 788032119674292900, + "sku": "example-shirt-m", + "taxable": true, + "title": "Medium", + "updated_at": "2021-12-31T19:00:00-05:00", + "option1": "Medium", + "option2": null, + "option3": null, + "image_id": null, + "inventory_item_id": null, + "inventory_quantity": 50, + "old_inventory_quantity": 50 + } + ], + "options": [], + "images": [], + "image": null, + "media": [], + "variant_gids": [ + { + "admin_graphql_api_id": "gid://shopify/ProductVariant/757650484644203962", + "updated_at": "2022-01-01T00:00:00.000Z" + }, + { + "admin_graphql_api_id": "gid://shopify/ProductVariant/642667041472713922", + "updated_at": "2021-12-31T00:00:00.000Z" + } + ], + "has_variants_that_requires_components": false + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "products/create", + "x-shopify-triggered-at": "2024-08-07T22:59:15.982741430Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "products/delete", + "name": "products/delete", + "body": { + "id": 788032119674292900 + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "products/delete", + "x-shopify-triggered-at": "2024-08-07T22:59:21.243809007Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "products/update", + "name": "products/update", + "body": { + "admin_graphql_api_id": "gid://shopify/Product/788032119674292922", + "body_html": "An example T-Shirt", + "created_at": null, + "handle": "example-t-shirt", + "id": 788032119674292900, + "product_type": "Shirts", + "published_at": "2021-12-31T19:00:00-05:00", + "template_suffix": null, + "title": "Example T-Shirt", + "updated_at": "2021-12-31T19:00:00-05:00", + "vendor": "Acme", + "status": "active", + "published_scope": "web", + "tags": "example, mens, t-shirt", + "variants": [ + { + "admin_graphql_api_id": "gid://shopify/ProductVariant/642667041472713922", + "barcode": null, + "compare_at_price": "24.99", + "created_at": "2021-12-29T19:00:00-05:00", + "id": 642667041472714000, + "inventory_policy": "deny", + "position": 1, + "price": "19.99", + "product_id": 788032119674292900, + "sku": "example-shirt-s", + "taxable": true, + "title": "Small", + "updated_at": "2021-12-30T19:00:00-05:00", + "option1": "Small", + "option2": null, + "option3": null, + "image_id": null, + "inventory_item_id": null, + "inventory_quantity": 75, + "old_inventory_quantity": 75 + }, + { + "admin_graphql_api_id": "gid://shopify/ProductVariant/757650484644203962", + "barcode": null, + "compare_at_price": "24.99", + "created_at": "2021-12-29T19:00:00-05:00", + "id": 757650484644203900, + "inventory_policy": "deny", + "position": 2, + "price": "19.99", + "product_id": 788032119674292900, + "sku": "example-shirt-m", + "taxable": true, + "title": "Medium", + "updated_at": "2021-12-31T19:00:00-05:00", + "option1": "Medium", + "option2": null, + "option3": null, + "image_id": null, + "inventory_item_id": null, + "inventory_quantity": 50, + "old_inventory_quantity": 50 + } + ], + "options": [], + "images": [], + "image": null, + "media": [], + "variant_gids": [ + { + "admin_graphql_api_id": "gid://shopify/ProductVariant/757650484644203962", + "updated_at": "2022-01-01T00:00:00.000Z" + }, + { + "admin_graphql_api_id": "gid://shopify/ProductVariant/642667041472713922", + "updated_at": "2021-12-31T00:00:00.000Z" + } + ], + "has_variants_that_requires_components": false + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "products/update", + "x-shopify-triggered-at": "2024-08-07T22:59:26.372189411Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "profiles/create", + "name": "profiles/create", + "body": { + "id": 1 + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "profiles/create", + "x-shopify-triggered-at": "2024-08-07T23:00:48.411909237Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "profiles/delete", + "name": "profiles/delete", + "body": { + "id": 1 + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "profiles/delete", + "x-shopify-triggered-at": "2024-08-07T23:00:53.839947655Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "profiles/update", + "name": "profiles/update", + "body": { + "id": 1 + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "profiles/update", + "x-shopify-triggered-at": "2024-08-07T23:00:59.136451962Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "refunds/create", + "name": "refunds/create", + "body": { + "id": 890088186047892400, + "order_id": 820982911946154500, + "created_at": "2021-12-31T19:00:00-05:00", + "note": "Things were damaged", + "user_id": 548380009, + "processed_at": "2021-12-31T19:00:00-05:00", + "restock": false, + "duties": [], + "total_duties_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "return": null, + "refund_shipping_lines": [], + "admin_graphql_api_id": "gid://shopify/Refund/890088186047892319", + "order_adjustments": [], + "refund_line_items": [ + { + "id": 866550311766439000, + "quantity": 1, + "line_item_id": 866550311766439000, + "location_id": null, + "restock_type": "no_restock", + "subtotal": 199, + "total_tax": 0, + "subtotal_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "total_tax_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "line_item": { + "id": 866550311766439000, + "variant_id": 808950810, + "title": "IPod Nano - 8GB", + "quantity": 1, + "sku": "IPOD2008PINK", + "variant_title": null, + "vendor": null, + "fulfillment_service": "manual", + "product_id": 632910392, + "requires_shipping": true, + "taxable": true, + "gift_card": false, + "name": "IPod Nano - 8GB", + "variant_inventory_management": "shopify", + "properties": [], + "product_exists": true, + "fulfillable_quantity": 1, + "grams": 567, + "price": "199.00", + "total_discount": "0.00", + "fulfillment_status": null, + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "discount_allocations": [], + "duties": [], + "admin_graphql_api_id": "gid://shopify/LineItem/866550311766439020", + "tax_lines": [] + } + }, + { + "id": 141249953214523040, + "quantity": 1, + "line_item_id": 141249953214522980, + "location_id": null, + "restock_type": "no_restock", + "subtotal": 199, + "total_tax": 0, + "subtotal_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "total_tax_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "line_item": { + "id": 141249953214522980, + "variant_id": 808950810, + "title": "IPod Nano - 8GB", + "quantity": 1, + "sku": "IPOD2008PINK", + "variant_title": null, + "vendor": null, + "fulfillment_service": "manual", + "product_id": 632910392, + "requires_shipping": true, + "taxable": true, + "gift_card": false, + "name": "IPod Nano - 8GB", + "variant_inventory_management": "shopify", + "properties": [], + "product_exists": true, + "fulfillable_quantity": 1, + "grams": 567, + "price": "199.00", + "total_discount": "5.00", + "fulfillment_status": null, + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "total_discount_set": { + "shop_money": { + "amount": "5.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "5.00", + "currency_code": "USD" + } + }, + "discount_allocations": [ + { + "amount": "5.00", + "discount_application_index": 0, + "amount_set": { + "shop_money": { + "amount": "5.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "5.00", + "currency_code": "USD" + } + } + }, + { + "amount": "5.00", + "discount_application_index": 2, + "amount_set": { + "shop_money": { + "amount": "5.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "5.00", + "currency_code": "USD" + } + } + } + ], + "duties": [], + "admin_graphql_api_id": "gid://shopify/LineItem/141249953214522974", + "tax_lines": [] + } + } + ], + "transactions": [] + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "refunds/create", + "x-shopify-triggered-at": "2024-08-07T23:01:26.516907657Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "returns/approve", + "name": "returns/approve", + "body": { + "id": 123134564567890, + "admin_graphql_api_id": "gid://shopify/Return/123134564567890", + "status": "open", + "order": { + "id": 4783296544821, + "admin_graphql_api_id": "gid://shopify/Order/4783296544821" + }, + "total_return_line_items": 2, + "name": null, + "return_line_items": [ + { + "id": 987654321, + "admin_graphql_api_id": "gid://shopify/ReturnLineItem/987654321", + "fulfillment_line_item": { + "id": 4463854417760, + "admin_graphql_api_id": "gid://shopify/FulfillmentLineItem/4463854417760", + "line_item": { + "id": 4176556606960, + "admin_graphql_api_id": "gid://shopify/LineItem/4176556606960" + } + }, + "quantity": 1, + "return_reason": "size_too_small", + "return_reason_note": "", + "customer_note": null, + "restocking_fee": { + "id": 283749348573489, + "admin_graphql_api_id": "gid://shopify/RestockingFee/283749348573489", + "price": { + "shop_money": { + "amount": "10.0", + "currency_code": "XXX" + }, + "presentment_money": { + "amount": "10.0", + "currency_code": "XXX" + } + } + } + }, + { + "id": 987654322, + "admin_graphql_api_id": "gid://shopify/ReturnLineItem/987654322", + "fulfillment_line_item": { + "id": 7219216693661, + "admin_graphql_api_id": "gid://shopify/FulfillmentLineItem/7219216693661", + "line_item": { + "id": 7014063798651, + "admin_graphql_api_id": "gid://shopify/LineItem/7014063798651" + } + }, + "quantity": 1, + "return_reason": "size_too_large", + "return_reason_note": "", + "customer_note": null, + "restocking_fee": null + } + ], + "return_shipping_fees": [ + { + "id": 987654321547866, + "admin_graphql_api_id": "gid://shopify/ReturnShippingFee/987654321547866", + "price": { + "shop_money": { + "amount": "10.0", + "currency_code": "XXX" + }, + "presentment_money": { + "amount": "10.0", + "currency_code": "XXX" + } + } + } + ], + "exchange_line_items": [ + { + "id": 987654321283476, + "admin_graphql_api_id": "gid://shopify/ExchangeLineItem/987654321283476", + "line_item": null + } + ], + "total_exchange_line_items": 1 + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "returns/approve", + "x-shopify-triggered-at": "2024-08-07T23:01:31.834007904Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "returns/cancel", + "name": "returns/cancel", + "body": { + "id": 123134564567890, + "admin_graphql_api_id": "gid://shopify/Return/123134564567890", + "order_id": 4783296544821, + "status": "canceled" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "returns/cancel", + "x-shopify-triggered-at": "2024-08-07T23:01:37.414567890Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "returns/close", + "name": "returns/close", + "body": { + "id": 123134564567890, + "admin_graphql_api_id": "gid://shopify/Return/123134564567890", + "order_id": 4783296544821, + "status": "closed" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "returns/close", + "x-shopify-triggered-at": "2024-08-07T23:01:43.121775055Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "returns/decline", + "name": "returns/decline", + "body": { + "id": 123134564567890, + "admin_graphql_api_id": "gid://shopify/Return/123134564567890", + "status": "declined", + "decline": { + "reason": "return_period_ended", + "note": "As discussed on the phone, the 30-day return window has ended." + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "returns/decline", + "x-shopify-triggered-at": "2024-08-07T23:01:48.650976147Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "returns/reopen", + "name": "returns/reopen", + "body": { + "id": 123134564567890, + "admin_graphql_api_id": "gid://shopify/Return/123134564567890", + "order_id": 4783296544821, + "status": "open" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "returns/reopen", + "x-shopify-triggered-at": "2024-08-07T23:01:53.976682665Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "returns/request", + "name": "returns/request", + "body": { + "id": 123134564567890, + "admin_graphql_api_id": "gid://shopify/Return/123134564567890", + "status": "requested", + "order": { + "id": 4783296544821, + "admin_graphql_api_id": "gid://shopify/Order/4783296544821" + }, + "total_return_line_items": 2, + "name": null, + "return_line_items": [ + { + "id": 987654321, + "admin_graphql_api_id": "gid://shopify/ReturnLineItem/987654321", + "fulfillment_line_item": { + "id": 4463854417760, + "admin_graphql_api_id": "gid://shopify/FulfillmentLineItem/4463854417760", + "line_item": { + "id": 4176556606960, + "admin_graphql_api_id": "gid://shopify/LineItem/4176556606960" + } + }, + "quantity": 1, + "return_reason": "size_too_small", + "return_reason_note": "", + "customer_note": null, + "restocking_fee": null + }, + { + "id": 987654322, + "admin_graphql_api_id": "gid://shopify/ReturnLineItem/987654322", + "fulfillment_line_item": { + "id": 7219216693661, + "admin_graphql_api_id": "gid://shopify/FulfillmentLineItem/7219216693661", + "line_item": { + "id": 7014063798651, + "admin_graphql_api_id": "gid://shopify/LineItem/7014063798651" + } + }, + "quantity": 1, + "return_reason": "size_too_large", + "return_reason_note": "", + "customer_note": null, + "restocking_fee": null + } + ], + "return_shipping_fees": [], + "exchange_line_items": [], + "total_exchange_line_items": 0 + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "returns/request", + "x-shopify-triggered-at": "2024-08-07T23:01:59.160232984Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "returns/update", + "name": "returns/update", + "body": { + "admin_graphql_api_id": "gid://shopify/Return/123134564567890", + "return_line_items": { + "removals": [ + { + "admin_graphql_api_id": "gid://shopify/ReturnLineItem/987654321", + "delta": 2 + } + ] + }, + "restocking_fees": { + "updates": [], + "removals": [] + }, + "return_shipping_fees": { + "updates": [], + "removals": [] + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "returns/update", + "x-shopify-triggered-at": "2024-08-07T23:02:04.309153593Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "reverse_deliveries/attach_deliverable", + "name": "reverse_deliveries/attach_deliverable", + "body": { + "id": 9080907654321, + "admin_graphql_api_id": "gid://shopify/ReverseDelivery/9080907654321", + "return": { + "id": 123134564567890, + "admin_graphql_api_id": "gid://shopify/Return/123134564567890" + }, + "shipping_deliverable": { + "tracking": { + "carrier_name": "USPS", + "tracking_number": "123345345", + "tracking_url": null + }, + "label": { + "public_file_url": null, + "created_at": null + } + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "reverse_deliveries/attach_deliverable", + "x-shopify-triggered-at": "2024-08-07T23:02:10.185338627Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "reverse_fulfillment_orders/dispose", + "name": "reverse_fulfillment_orders/dispose", + "body": { + "id": 11111111, + "admin_graphql_api_id": "gid://shopify/ReverseFulfillmentOrder/11111111", + "dispositions": [ + { + "reverse_fulfillment_order_line_item": { + "id": 2222222, + "admin_graphql_api_id": "gid://shopify/ReverseFulfillmentOrderLineItem/2222222" + }, + "reverse_delivery_line_item": null, + "type": "restocked", + "location": { + "id": 3333333, + "admin_graphql_api_id": "gid://shopify/Location/3333333" + }, + "quantity": 1 + }, + { + "reverse_fulfillment_order_line_item": { + "id": 2222223, + "admin_graphql_api_id": "gid://shopify/ReverseFulfillmentOrderLineItem/2222223" + }, + "reverse_delivery_line_item": null, + "type": "restocked", + "location": { + "id": 3333333, + "admin_graphql_api_id": "gid://shopify/Location/3333333" + }, + "quantity": 1 + } + ], + "total_dispositions": 2 + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "reverse_fulfillment_orders/dispose", + "x-shopify-triggered-at": "2024-08-07T23:02:15.745485629Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "scheduled_product_listings/add", + "name": "scheduled_product_listings/add", + "body": { + "scheduled_product_listing": { + "product_id": 788032119674292900, + "created_at": null, + "updated_at": "2021-12-31T19:00:00-05:00", + "body_html": "An example T-Shirt", + "handle": "example-t-shirt", + "product_type": "Shirts", + "title": "Example T-Shirt", + "vendor": "Acme", + "available": true, + "tags": "example, mens, t-shirt", + "variants": [ + { + "id": 642667041472714000, + "title": "Small", + "option_values": [ + { + "option_id": 527050010214937800, + "name": "Title", + "value": "Small" + } + ], + "price": "19.99", + "formatted_price": "$19.99", + "compare_at_price": "24.99", + "grams": 200, + "requires_shipping": true, + "sku": null, + "barcode": null, + "taxable": true, + "position": 1, + "available": true, + "inventory_policy": "deny", + "inventory_quantity": 75, + "inventory_management": null, + "fulfillment_service": "manual", + "weight": null, + "weight_unit": "lb", + "image_id": null, + "created_at": "2021-12-29T19:00:00-05:00", + "updated_at": "2021-12-30T19:00:00-05:00" + }, + { + "id": 757650484644203900, + "title": "Medium", + "option_values": [ + { + "option_id": 527050010214937800, + "name": "Title", + "value": "Medium" + } + ], + "price": "19.99", + "formatted_price": "$19.99", + "compare_at_price": "24.99", + "grams": 200, + "requires_shipping": true, + "sku": null, + "barcode": null, + "taxable": true, + "position": 2, + "available": true, + "inventory_policy": "deny", + "inventory_quantity": 50, + "inventory_management": null, + "fulfillment_service": "manual", + "weight": null, + "weight_unit": "lb", + "image_id": null, + "created_at": "2021-12-29T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00" + } + ], + "publish_at": null, + "images": [], + "options": [ + { + "id": 527050010214937800, + "name": "Title", + "product_id": 788032119674292900, + "position": 1, + "values": ["Small", "Medium"] + } + ] + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "scheduled_product_listings/add", + "x-shopify-triggered-at": "2024-08-07T23:02:21.589526157Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "scheduled_product_listings/remove", + "name": "scheduled_product_listings/remove", + "body": { + "scheduled_product_listing": { + "product_id": 788032119674292900 + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "scheduled_product_listings/remove", + "x-shopify-triggered-at": "2024-08-07T23:02:26.822698262Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "scheduled_product_listings/update", + "name": "scheduled_product_listings/update", + "body": { + "scheduled_product_listing": { + "product_id": 788032119674292900, + "created_at": null, + "updated_at": "2021-12-31T19:00:00-05:00", + "body_html": "An example T-Shirt", + "handle": "example-t-shirt", + "product_type": "Shirts", + "title": "Example T-Shirt", + "vendor": "Acme", + "available": true, + "tags": "example, mens, t-shirt", + "variants": [ + { + "id": 642667041472714000, + "title": "Small", + "option_values": [ + { + "option_id": 527050010214937800, + "name": "Title", + "value": "Small" + } + ], + "price": "19.99", + "formatted_price": "$19.99", + "compare_at_price": "24.99", + "grams": 200, + "requires_shipping": true, + "sku": null, + "barcode": null, + "taxable": true, + "position": 1, + "available": true, + "inventory_policy": "deny", + "inventory_quantity": 75, + "inventory_management": null, + "fulfillment_service": "manual", + "weight": null, + "weight_unit": "lb", + "image_id": null, + "created_at": "2021-12-29T19:00:00-05:00", + "updated_at": "2021-12-30T19:00:00-05:00" + }, + { + "id": 757650484644203900, + "title": "Medium", + "option_values": [ + { + "option_id": 527050010214937800, + "name": "Title", + "value": "Medium" + } + ], + "price": "19.99", + "formatted_price": "$19.99", + "compare_at_price": "24.99", + "grams": 200, + "requires_shipping": true, + "sku": null, + "barcode": null, + "taxable": true, + "position": 2, + "available": true, + "inventory_policy": "deny", + "inventory_quantity": 50, + "inventory_management": null, + "fulfillment_service": "manual", + "weight": null, + "weight_unit": "lb", + "image_id": null, + "created_at": "2021-12-29T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00" + } + ], + "publish_at": null, + "images": [], + "options": [ + { + "id": 527050010214937800, + "name": "Title", + "product_id": 788032119674292900, + "position": 1, + "values": ["Small", "Medium"] + } + ] + } + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "scheduled_product_listings/update", + "x-shopify-triggered-at": "2024-08-07T23:02:32.488713857Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "segments/create", + "name": "segments/create", + "body": { + "id": 1, + "name": "Customers who have one order", + "query": "number_of_orders = 1", + "creationDate": "2022-01-01T00:00:00.000Z", + "lastEditDate": "2022-01-01T00:00:00.000Z" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "segments/create", + "x-shopify-triggered-at": "2024-08-07T23:02:37.769131380Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "segments/delete", + "name": "segments/delete", + "body": { + "id": 1 + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "segments/delete", + "x-shopify-triggered-at": "2024-08-07T23:02:43.121330242Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "segments/update", + "name": "segments/update", + "body": { + "id": 1, + "name": "Customers who have one order", + "query": "number_of_orders = 1", + "creationDate": "2005-05-05T00:00:00.000Z", + "lastEditDate": "2022-01-01T00:00:00.000Z" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "segments/update", + "x-shopify-triggered-at": "2024-08-07T23:02:48.168986480Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "selling_plan_groups/create", + "name": "selling_plan_groups/create", + "body": { + "admin_graphql_api_id": "gid://shopify/SellingPlanGroup/1039518907", + "id": 1039518907, + "name": "Subscribe & Save", + "merchant_code": "sub-n-save", + "admin_graphql_api_app": "gid://shopify/App/2525000003", + "app_id": null, + "description": null, + "options": ["Delivery every"], + "position": null, + "summary": "1 delivery frequency, discount", + "selling_plans": [ + { + "name": "Pay every month deliver every month", + "options": ["month"], + "position": null, + "description": null, + "billing_policy": { + "interval": "month", + "interval_count": 1, + "min_cycles": null, + "max_cycles": null + }, + "delivery_policy": { + "interval": "month", + "interval_count": 1, + "anchors": [], + "cutoff": null, + "pre_anchor_behavior": "asap" + }, + "pricing_policies": [] + } + ], + "product_variants": [], + "products": [] + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "selling_plan_groups/create", + "x-shopify-triggered-at": "2024-08-07T23:02:53.330801847Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "selling_plan_groups/delete", + "name": "selling_plan_groups/delete", + "body": { + "admin_graphql_api_id": "gid://shopify/SellingPlanGroup/1039518908", + "id": 1039518908 + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "selling_plan_groups/delete", + "x-shopify-triggered-at": "2024-08-07T23:02:58.719142003Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "selling_plan_groups/update", + "name": "selling_plan_groups/update", + "body": { + "admin_graphql_api_id": "gid://shopify/SellingPlanGroup/1039518918", + "id": 1039518918, + "name": "Subscribe & Save", + "merchant_code": "sub-n-save", + "admin_graphql_api_app": "gid://shopify/App/2525000003", + "app_id": null, + "description": null, + "options": ["Delivery every"], + "position": null, + "summary": "1 delivery frequency, discount", + "selling_plans": [ + { + "name": "Pay every month deliver every month", + "options": ["month"], + "position": null, + "description": null, + "billing_policy": { + "interval": "month", + "interval_count": 1, + "min_cycles": null, + "max_cycles": null + }, + "delivery_policy": { + "interval": "month", + "interval_count": 1, + "anchors": [], + "cutoff": null, + "pre_anchor_behavior": "asap" + }, + "pricing_policies": [] + } + ], + "product_variants": [], + "products": [] + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "selling_plan_groups/update", + "x-shopify-triggered-at": "2024-08-07T23:03:04.118351440Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "shipping_addresses/create", + "name": "shipping_addresses/create", + "body": {}, + "extraHeaders": { + "transfer-encoding": "chunked", + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "shipping_addresses/create", + "x-shopify-triggered-at": "2024-08-07T23:03:09.447035088Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "shipping_addresses/update", + "name": "shipping_addresses/update", + "body": {}, + "extraHeaders": { + "transfer-encoding": "chunked", + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "shipping_addresses/update", + "x-shopify-triggered-at": "2024-08-07T23:03:16.443104959Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "shop/update", + "name": "shop/update", + "body": { + "id": 548380009, + "name": "Super Toys", + "email": "super@supertoys.com", + "domain": null, + "province": "Tennessee", + "country": "US", + "address1": "190 MacLaren Street", + "zip": "37178", + "city": "Houston", + "source": null, + "phone": "3213213210", + "latitude": null, + "longitude": null, + "primary_locale": "en", + "address2": null, + "created_at": null, + "updated_at": null, + "country_code": "US", + "country_name": "United States", + "currency": "USD", + "customer_email": "super@supertoys.com", + "timezone": "(GMT-05:00) Eastern Time (US & Canada)", + "iana_timezone": null, + "shop_owner": "John Smith", + "money_format": "${{amount}}", + "money_with_currency_format": "${{amount}} USD", + "weight_unit": "kg", + "province_code": "TN", + "taxes_included": null, + "auto_configure_tax_inclusivity": null, + "tax_shipping": null, + "county_taxes": null, + "plan_display_name": "Shopify Plus", + "plan_name": "enterprise", + "has_discounts": false, + "has_gift_cards": true, + "myshopify_domain": null, + "google_apps_domain": null, + "google_apps_login_enabled": null, + "money_in_emails_format": "${{amount}}", + "money_with_currency_in_emails_format": "${{amount}} USD", + "eligible_for_payments": true, + "requires_extra_payments_agreement": false, + "password_enabled": null, + "has_storefront": true, + "finances": true, + "primary_location_id": 655441491, + "checkout_api_supported": true, + "multi_location_enabled": true, + "setup_required": false, + "pre_launch_enabled": false, + "enabled_presentment_currencies": ["USD"], + "marketing_sms_consent_enabled_at_checkout": false, + "transactional_sms_disabled": false + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "shop/update", + "x-shopify-triggered-at": "2024-08-07T23:03:21.884273252Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "subscription_billing_attempts/challenged", + "name": "subscription_billing_attempts/challenged", + "body": { + "id": null, + "admin_graphql_api_id": null, + "idempotency_key": "9a453d81-d41d-403e-806f-714dee215ff9", + "order_id": 1, + "admin_graphql_api_order_id": "gid://shopify/Order/1", + "subscription_contract_id": 9601391618, + "admin_graphql_api_subscription_contract_id": "gid://shopify/SubscriptionContract/9601391618", + "ready": true, + "error_message": null, + "error_code": null + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "subscription_billing_attempts/challenged", + "x-shopify-triggered-at": "2024-08-07T23:03:27.142015031Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "subscription_billing_attempts/failure", + "name": "subscription_billing_attempts/failure", + "body": { + "id": null, + "admin_graphql_api_id": null, + "idempotency_key": "9a453d81-d41d-403e-806f-714dee215ff9", + "order_id": 1, + "admin_graphql_api_order_id": "gid://shopify/Order/1", + "subscription_contract_id": 9928822889, + "admin_graphql_api_subscription_contract_id": "gid://shopify/SubscriptionContract/9928822889", + "ready": true, + "error_message": null, + "error_code": null + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "subscription_billing_attempts/failure", + "x-shopify-triggered-at": "2024-08-07T23:03:32.395340617Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "subscription_billing_attempts/success", + "name": "subscription_billing_attempts/success", + "body": { + "id": null, + "admin_graphql_api_id": null, + "idempotency_key": "9a453d81-d41d-403e-806f-714dee215ff9", + "order_id": 1, + "admin_graphql_api_order_id": "gid://shopify/Order/1", + "subscription_contract_id": 8712043388, + "admin_graphql_api_subscription_contract_id": "gid://shopify/SubscriptionContract/8712043388", + "ready": true, + "error_message": null, + "error_code": null + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "subscription_billing_attempts/success", + "x-shopify-triggered-at": "2024-08-07T23:03:37.594388878Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "subscription_billing_cycle_edits/create", + "name": "subscription_billing_cycle_edits/create", + "body": { + "subscription_contract_id": 2089608828, + "cycle_start_at": "2022-10-01T00:00:00-04:00", + "cycle_end_at": "2022-11-01T00:00:00-04:00", + "cycle_index": 1, + "contract_edit": null, + "billing_attempt_expected_date": "2022-11-01T00:00:00-04:00", + "skipped": false, + "edited": false + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "subscription_billing_cycle_edits/create", + "x-shopify-triggered-at": "2024-08-07T23:03:53.358117796Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "subscription_billing_cycle_edits/delete", + "name": "subscription_billing_cycle_edits/delete", + "body": { + "subscription_contract_id": 8967220567, + "cycle_start_at": "2022-10-01T00:00:00-04:00", + "cycle_end_at": "2022-11-01T00:00:00-04:00", + "cycle_index": 1, + "contract_edit": null, + "billing_attempt_expected_date": "2022-11-01T00:00:00-04:00", + "skipped": false, + "edited": false + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "subscription_billing_cycle_edits/delete", + "x-shopify-triggered-at": "2024-08-07T23:03:58.747170360Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "subscription_billing_cycle_edits/update", + "name": "subscription_billing_cycle_edits/update", + "body": { + "subscription_contract_id": 3383495807, + "cycle_start_at": "2022-10-01T00:00:00-04:00", + "cycle_end_at": "2022-11-01T00:00:00-04:00", + "cycle_index": 1, + "contract_edit": null, + "billing_attempt_expected_date": "2022-11-01T00:00:00-04:00", + "skipped": false, + "edited": false + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "subscription_billing_cycle_edits/update", + "x-shopify-triggered-at": "2024-08-07T23:04:04.593178392Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "subscription_billing_cycles/skip", + "name": "subscription_billing_cycles/skip", + "body": { + "subscription_contract_id": 6216896094, + "cycle_start_at": "2022-10-01T00:00:00-04:00", + "cycle_end_at": "2022-11-01T00:00:00-04:00", + "cycle_index": 1, + "contract_edit": null, + "billing_attempt_expected_date": "2022-11-01T00:00:00-04:00", + "skipped": true, + "edited": true + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "subscription_billing_cycles/skip", + "x-shopify-triggered-at": "2024-08-07T23:03:42.860287603Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "subscription_billing_cycles/unskip", + "name": "subscription_billing_cycles/unskip", + "body": { + "subscription_contract_id": 9448952952, + "cycle_start_at": "2022-10-01T00:00:00-04:00", + "cycle_end_at": "2022-11-01T00:00:00-04:00", + "cycle_index": 1, + "contract_edit": null, + "billing_attempt_expected_date": "2022-11-01T00:00:00-04:00", + "skipped": false, + "edited": true + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "subscription_billing_cycles/unskip", + "x-shopify-triggered-at": "2024-08-07T23:03:48.346695038Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "subscription_contracts/activate", + "name": "subscription_contracts/activate", + "body": { + "admin_graphql_api_id": "gid://shopify/SubscriptionContract/8144725024", + "id": 8144725024, + "billing_policy": { + "interval": "week", + "interval_count": 4, + "min_cycles": 1, + "max_cycles": 2 + }, + "currency_code": "USD", + "customer_id": 1, + "admin_graphql_api_customer_id": "gid://shopify/Customer/1", + "delivery_policy": { + "interval": "week", + "interval_count": 2 + }, + "status": "active", + "admin_graphql_api_origin_order_id": "gid://shopify/Order/1", + "origin_order_id": 1, + "revision_id": "6734576965" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "subscription_contracts/activate", + "x-shopify-triggered-at": "2024-08-07T23:04:10.231356281Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "subscription_contracts/cancel", + "name": "subscription_contracts/cancel", + "body": { + "admin_graphql_api_id": "gid://shopify/SubscriptionContract/8879965539", + "id": 8879965539, + "billing_policy": { + "interval": "week", + "interval_count": 4, + "min_cycles": 1, + "max_cycles": 2 + }, + "currency_code": "USD", + "customer_id": 1, + "admin_graphql_api_customer_id": "gid://shopify/Customer/1", + "delivery_policy": { + "interval": "week", + "interval_count": 2 + }, + "status": "cancelled", + "admin_graphql_api_origin_order_id": "gid://shopify/Order/1", + "origin_order_id": 1, + "revision_id": "7654492114" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "subscription_contracts/cancel", + "x-shopify-triggered-at": "2024-08-07T23:04:15.455046945Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "subscription_contracts/create", + "name": "subscription_contracts/create", + "body": { + "admin_graphql_api_id": "gid://shopify/SubscriptionContract/2130602109", + "id": 2130602109, + "billing_policy": { + "interval": "week", + "interval_count": 4, + "min_cycles": 1, + "max_cycles": 2 + }, + "currency_code": "USD", + "customer_id": 1, + "admin_graphql_api_customer_id": "gid://shopify/Customer/1", + "delivery_policy": { + "interval": "week", + "interval_count": 2 + }, + "status": "active", + "admin_graphql_api_origin_order_id": "gid://shopify/Order/1", + "origin_order_id": 1, + "revision_id": "5735405187" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "subscription_contracts/create", + "x-shopify-triggered-at": "2024-08-07T23:04:20.428915161Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "subscription_contracts/expire", + "name": "subscription_contracts/expire", + "body": { + "admin_graphql_api_id": "gid://shopify/SubscriptionContract/3504917051", + "id": 3504917051, + "billing_policy": { + "interval": "week", + "interval_count": 4, + "min_cycles": 1, + "max_cycles": 2 + }, + "currency_code": "USD", + "customer_id": 1, + "admin_graphql_api_customer_id": "gid://shopify/Customer/1", + "delivery_policy": { + "interval": "week", + "interval_count": 2 + }, + "status": "expired", + "admin_graphql_api_origin_order_id": "gid://shopify/Order/1", + "origin_order_id": 1, + "revision_id": "7780548242" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "subscription_contracts/expire", + "x-shopify-triggered-at": "2024-08-07T23:04:26.319892364Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "subscription_contracts/fail", + "name": "subscription_contracts/fail", + "body": { + "admin_graphql_api_id": "gid://shopify/SubscriptionContract/5483094388", + "id": 5483094388, + "billing_policy": { + "interval": "week", + "interval_count": 4, + "min_cycles": 1, + "max_cycles": 2 + }, + "currency_code": "USD", + "customer_id": 1, + "admin_graphql_api_customer_id": "gid://shopify/Customer/1", + "delivery_policy": { + "interval": "week", + "interval_count": 2 + }, + "status": "failed", + "admin_graphql_api_origin_order_id": "gid://shopify/Order/1", + "origin_order_id": 1, + "revision_id": "8086755151" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "subscription_contracts/fail", + "x-shopify-triggered-at": "2024-08-07T23:04:31.543127745Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "subscription_contracts/pause", + "name": "subscription_contracts/pause", + "body": { + "admin_graphql_api_id": "gid://shopify/SubscriptionContract/8004927509", + "id": 8004927509, + "billing_policy": { + "interval": "week", + "interval_count": 4, + "min_cycles": 1, + "max_cycles": 2 + }, + "currency_code": "USD", + "customer_id": 1, + "admin_graphql_api_customer_id": "gid://shopify/Customer/1", + "delivery_policy": { + "interval": "week", + "interval_count": 2 + }, + "status": "paused", + "admin_graphql_api_origin_order_id": "gid://shopify/Order/1", + "origin_order_id": 1, + "revision_id": "1393248951" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "subscription_contracts/pause", + "x-shopify-triggered-at": "2024-08-07T23:04:37.111638027Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "subscription_contracts/update", + "name": "subscription_contracts/update", + "body": { + "admin_graphql_api_id": "gid://shopify/SubscriptionContract/8249570610", + "id": 8249570610, + "billing_policy": { + "interval": "week", + "interval_count": 4, + "min_cycles": 1, + "max_cycles": 2 + }, + "currency_code": "USD", + "customer_id": 1, + "admin_graphql_api_customer_id": "gid://shopify/Customer/1", + "delivery_policy": { + "interval": "week", + "interval_count": 2 + }, + "status": "active", + "admin_graphql_api_origin_order_id": "gid://shopify/Order/1", + "origin_order_id": 1, + "revision_id": "5132632722" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "subscription_contracts/update", + "x-shopify-triggered-at": "2024-08-07T23:04:42.270520870Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "tax_services/create", + "name": "tax_services/create", + "body": { + "id": null, + "name": "Tax Service", + "url": "https://taxes.shopify.com", + "active": true + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "tax_services/create", + "x-shopify-triggered-at": "2024-08-07T23:05:02.595708933Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "tax_services/update", + "name": "tax_services/update", + "body": { + "id": null, + "name": "Tax Service", + "url": "https://taxes.shopify.com", + "active": true + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "tax_services/update", + "x-shopify-triggered-at": "2024-08-07T23:05:08.762628798Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "tender_transactions/create", + "name": "tender_transactions/create", + "body": { + "id": 220982911946154500, + "order_id": 820982911946154500, + "amount": "403.00", + "currency": "USD", + "user_id": null, + "test": false, + "processed_at": null, + "remote_reference": "1001", + "payment_details": null, + "payment_method": "unknown" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "tender_transactions/create", + "x-shopify-triggered-at": "2024-08-07T23:05:17.607923518Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "themes/create", + "name": "themes/create", + "body": { + "id": 512162865275216960, + "name": "Comfort", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "role": "main", + "theme_store_id": 1234, + "previewable": true, + "processing": false, + "admin_graphql_api_id": "gid://shopify/Theme/512162865275216980" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "themes/create", + "x-shopify-triggered-at": "2024-08-07T23:05:23.115137891Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "themes/delete", + "name": "themes/delete", + "body": { + "id": 512162865275216960 + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "themes/delete", + "x-shopify-triggered-at": "2024-08-07T23:05:28.399540399Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "themes/publish", + "name": "themes/publish", + "body": { + "id": 512162865275216960, + "name": "Comfort", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "role": "main", + "theme_store_id": 1234, + "previewable": true, + "processing": false, + "admin_graphql_api_id": "gid://shopify/Theme/512162865275216980" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "themes/publish", + "x-shopify-triggered-at": "2024-08-07T23:05:33.743928137Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "themes/update", + "name": "themes/update", + "body": { + "id": 512162865275216960, + "name": "Comfort", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "role": "main", + "theme_store_id": 1234, + "previewable": true, + "processing": false, + "admin_graphql_api_id": "gid://shopify/Theme/512162865275216980" + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "themes/update", + "x-shopify-triggered-at": "2024-08-07T23:05:39.099831671Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "variants/in_stock", + "name": "variants/in_stock", + "body": { + "id": 642667041472714000, + "product_id": 788032119674292900, + "title": "Small", + "price": "19.99", + "sku": "example-shirt-s", + "position": 1, + "inventory_policy": "deny", + "compare_at_price": "24.99", + "option1": "Small", + "option2": null, + "option3": null, + "created_at": "2021-12-29T19:00:00-05:00", + "updated_at": "2021-12-30T19:00:00-05:00", + "taxable": true, + "barcode": null, + "inventory_quantity": 75, + "old_inventory_quantity": 75, + "admin_graphql_api_id": "gid://shopify/ProductVariant/642667041472713922", + "image_id": null + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "variants/in_stock", + "x-shopify-triggered-at": "2024-08-07T23:05:51.902368158Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "shopify", + "providerLabel": "Shopify", + "eventType": "variants/out_of_stock", + "name": "variants/out_of_stock", + "body": { + "id": 642667041472714000, + "product_id": 788032119674292900, + "title": "Small", + "price": "19.99", + "sku": "example-shirt-s", + "position": 1, + "inventory_policy": "deny", + "compare_at_price": "24.99", + "option1": "Small", + "option2": null, + "option3": null, + "created_at": "2021-12-29T19:00:00-05:00", + "updated_at": "2021-12-30T19:00:00-05:00", + "taxable": true, + "barcode": null, + "inventory_quantity": 0, + "old_inventory_quantity": 0, + "admin_graphql_api_id": "gid://shopify/ProductVariant/642667041472713922", + "image_id": null + }, + "extraHeaders": { + "x-shopify-api-version": "2024-10", + "x-shopify-shop-domain": "shop.myshopify.com", + "x-shopify-test": "true", + "x-shopify-topic": "variants/out_of_stock", + "x-shopify-triggered-at": "2024-08-07T23:08:54.875833943Z" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:shopify/2024-10", + "snapshotDate": "2026-07" + } + }, + { + "provider": "stripe", + "providerLabel": "Stripe", + "presetId": "stripe", + "eventType": "checkout.session.completed", + "name": "checkout.session.completed", + "body": { + "id": "evt_1MhUT6E0b6fckueSlc4GyvIi", + "object": "event", + "api_version": "2022-11-15", + "created": 1677834000, + "data": { + "object": { + "id": "cs_test_a1CagZ1XmCLIZb3ME6r06G1h8ygwbbbMw8Nqk0R86MWMT4dmCjDiUYfQDr", + "object": "checkout.session", + "after_expiration": null, + "allow_promotion_codes": false, + "amount_subtotal": 0, + "amount_total": 0, + "automatic_tax": { + "enabled": false, + "status": null + }, + "billing_address_collection": "auto", + "cancel_url": "https://stripe.com", + "client_reference_id": null, + "consent": null, + "consent_collection": { + "promotions": "none", + "terms_of_service": "none" + }, + "created": 1677833849, + "currency": "usd", + "custom_fields": [], + "custom_text": { + "shipping_address": null, + "submit": null + }, + "customer": "cus_NSPHp4fOlXGw8P", + "customer_creation": "if_required", + "customer_details": { + "address": { + "city": null, + "country": "CM", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "hook@me.com", + "name": "Martin", + "phone": null, + "tax_exempt": "none", + "tax_ids": [] + }, + "customer_email": null, + "expires_at": 1677920249, + "invoice": "in_1MhUT5E0b6fckueSfPlY05Fw", + "invoice_creation": null, + "livemode": false, + "locale": "auto", + "metadata": {}, + "mode": "subscription", + "payment_intent": null, + "payment_link": "plink_1MhUOwE0b6fckueSjmvf9Yea", + "payment_method_collection": "always", + "payment_method_options": {}, + "payment_method_types": ["card"], + "payment_status": "paid", + "phone_number_collection": { + "enabled": false + }, + "recovered_from": null, + "setup_intent": null, + "shipping_address_collection": null, + "shipping_options": [], + "status": "complete", + "submit_type": "auto", + "subscription": "sub_1MhUT5E0b6fckueSYAo1LoiF", + "success_url": "https://stripe.com", + "total_details": { + "amount_discount": 0, + "amount_shipping": 0, + "amount_tax": 0 + }, + "url": null, + "shipping_cost": null, + "shipping_details": null + } + }, + "livemode": false, + "pending_webhooks": 3, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "checkout.session.completed" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:stripe/2022-11-15", + "snapshotDate": "2026-07" + } + }, + { + "provider": "stripe", + "providerLabel": "Stripe", + "presetId": "stripe", + "eventType": "customer.created", + "name": "customer.created", + "body": { + "id": "evt_1MhUT6E0b6fckueSqWR0Bec4", + "object": "event", + "api_version": "2022-11-15", + "created": 1677833998, + "data": { + "object": { + "id": "cus_NSPHp4fOlXGw8P", + "object": "customer", + "address": { + "city": null, + "country": "CM", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "balance": 0, + "created": 1677833998, + "currency": null, + "default_source": null, + "delinquent": false, + "description": null, + "discount": null, + "email": "hook@me.com", + "invoice_prefix": "3DAA104C", + "invoice_settings": { + "custom_fields": null, + "default_payment_method": null, + "footer": null, + "rendering_options": null + }, + "livemode": false, + "metadata": {}, + "name": "Martin", + "next_invoice_sequence": 2, + "phone": null, + "preferred_locales": ["en-US"], + "shipping": null, + "tax_exempt": "none", + "test_clock": null + } + }, + "livemode": false, + "pending_webhooks": 3, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "customer.created" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:stripe/2022-11-15", + "snapshotDate": "2026-07" + } + }, + { + "provider": "stripe", + "providerLabel": "Stripe", + "presetId": "stripe", + "eventType": "customer.subscription.created", + "name": "customer.subscription.created", + "body": { + "id": "evt_1MhUZtE0b6fckueSDaOf1oJ8", + "object": "event", + "api_version": "2022-11-15", + "created": 1677834420, + "data": { + "object": { + "id": "sub_1MhUZrE0b6fckueSZfQ2Ytz6", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "enabled": false + }, + "billing_cycle_anchor": 1680426419, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1677834419, + "currency": "usd", + "current_period_end": 1680426419, + "current_period_start": 1677834419, + "customer": "cus_NSPOMxl5eL01qM", + "days_until_due": null, + "default_payment_method": "pm_1MhUZoE0b6fckueSNbTLQLkm", + "default_source": null, + "default_tax_rates": [], + "description": null, + "discount": null, + "ended_at": null, + "items": { + "object": "list", + "data": [ + { + "id": "si_NSPOg1R6mHTgyg", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1677834419, + "metadata": {}, + "plan": { + "id": "price_1M3wjkE0b6fckueSKPgfRMSG", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 700, + "amount_decimal": "700", + "billing_scheme": "per_unit", + "created": 1668409544, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": null, + "product": "prod_MnXpcVDdQq8wCY", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1M3wjkE0b6fckueSKPgfRMSG", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1668409544, + "currency": "usd", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": null, + "metadata": {}, + "nickname": null, + "product": "prod_MnXpcVDdQq8wCY", + "recurring": { + "aggregate_usage": null, + "interval": "month", + "interval_count": 1, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 700, + "unit_amount_decimal": "700" + }, + "quantity": 10, + "subscription": "sub_1MhUZrE0b6fckueSZfQ2Ytz6", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1MhUZrE0b6fckueSZfQ2Ytz6" + }, + "latest_invoice": "in_1MhUZrE0b6fckueSp9LCa3Rr", + "livemode": false, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": null, + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1M3wjkE0b6fckueSKPgfRMSG", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 700, + "amount_decimal": "700", + "billing_scheme": "per_unit", + "created": 1668409544, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": null, + "product": "prod_MnXpcVDdQq8wCY", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 10, + "schedule": null, + "start_date": 1677834419, + "status": "trialing", + "test_clock": null, + "transfer_data": null, + "trial_end": 1680426419, + "trial_settings": { + "end_behavior": { + "missing_payment_method": "cancel" + } + }, + "trial_start": 1677834419 + } + }, + "livemode": false, + "pending_webhooks": 4, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "customer.subscription.created" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:stripe/2022-11-15", + "snapshotDate": "2026-07" + } + }, + { + "provider": "stripe", + "providerLabel": "Stripe", + "presetId": "stripe", + "eventType": "customer.updated", + "name": "customer.updated", + "body": { + "id": "evt_1MhUT6E0b6fckueSfmuGgXWq", + "object": "event", + "api_version": "2022-11-15", + "created": 1677833999, + "data": { + "object": { + "id": "cus_NSPHp4fOlXGw8P", + "object": "customer", + "address": { + "city": null, + "country": "CM", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "balance": 0, + "created": 1677833998, + "currency": "usd", + "default_source": null, + "delinquent": false, + "description": null, + "discount": null, + "email": "hook@me.com", + "invoice_prefix": "3DAA104C", + "invoice_settings": { + "custom_fields": null, + "default_payment_method": null, + "footer": null, + "rendering_options": null + }, + "livemode": false, + "metadata": {}, + "name": "Martin", + "next_invoice_sequence": 2, + "phone": null, + "preferred_locales": ["en-US"], + "shipping": null, + "tax_exempt": "none", + "test_clock": null + }, + "previous_attributes": { + "currency": null + } + }, + "livemode": false, + "pending_webhooks": 3, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "customer.updated" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:stripe/2022-11-15", + "snapshotDate": "2026-07" + } + }, + { + "provider": "stripe", + "providerLabel": "Stripe", + "presetId": "stripe", + "eventType": "invoice.created", + "name": "invoice.created", + "body": { + "id": "evt_1MhUT7E0b6fckueStwy86nfu", + "object": "event", + "api_version": "2022-11-15", + "created": 1677833999, + "data": { + "object": { + "id": "in_1MhUT5E0b6fckueSfPlY05Fw", + "object": "invoice", + "account_country": "US", + "account_name": "Rodriq", + "account_tax_ids": null, + "amount_due": 0, + "amount_paid": 0, + "amount_remaining": 0, + "amount_shipping": 0, + "application": null, + "application_fee_amount": null, + "attempt_count": 0, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "enabled": false, + "status": null + }, + "billing_reason": "subscription_create", + "charge": null, + "collection_method": "charge_automatically", + "created": 1677833999, + "currency": "usd", + "custom_fields": null, + "customer": "cus_NSPHp4fOlXGw8P", + "customer_address": { + "city": null, + "country": "CM", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "customer_email": "hook@me.com", + "customer_name": "Martin", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discount": null, + "discounts": [], + "due_date": null, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1IvKcSE0b6fckueS/test_YWNjdF8xSXZLY1NFMGI2ZmNrdWVTLF9OU1BIM0pMOHVzalNXcGNINFpUM29ucVhrQm80M1F3LDY4Mzc0ODAx0200k1WbLvaG?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1IvKcSE0b6fckueS/test_YWNjdF8xSXZLY1NFMGI2ZmNrdWVTLF9OU1BIM0pMOHVzalNXcGNINFpUM29ucVhrQm80M1F3LDY4Mzc0ODAx0200k1WbLvaG/pdf?s=ap", + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1MhUT5E0b6fckueSJCaQVbIe", + "object": "line_item", + "amount": 0, + "amount_excluding_tax": 0, + "currency": "usd", + "description": "Trial period for Meetin Grande", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "livemode": false, + "metadata": {}, + "period": { + "end": 1680425999, + "start": 1677833999 + }, + "plan": { + "id": "price_1M3wjkE0b6fckueSKPgfRMSG", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 700, + "amount_decimal": "700", + "billing_scheme": "per_unit", + "created": 1668409544, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": null, + "product": "prod_MnXpcVDdQq8wCY", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1M3wjkE0b6fckueSKPgfRMSG", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1668409544, + "currency": "usd", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": null, + "metadata": {}, + "nickname": null, + "product": "prod_MnXpcVDdQq8wCY", + "recurring": { + "aggregate_usage": null, + "interval": "month", + "interval_count": 1, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 700, + "unit_amount_decimal": "700" + }, + "proration": false, + "proration_details": { + "credited_items": null + }, + "quantity": 11, + "subscription": "sub_1MhUT5E0b6fckueSYAo1LoiF", + "subscription_item": "si_NSPHIHu3GFH1sj", + "tax_amounts": [], + "tax_rates": [], + "type": "subscription", + "unit_amount_excluding_tax": "0" + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1MhUT5E0b6fckueSfPlY05Fw/lines" + }, + "livemode": false, + "metadata": {}, + "next_payment_attempt": null, + "number": "3DAA104C-0001", + "on_behalf_of": null, + "paid": true, + "paid_out_of_band": false, + "payment_intent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "period_end": 1677833999, + "period_start": 1677833999, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "quote": null, + "receipt_number": null, + "rendering_options": null, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "paid", + "status_transitions": { + "finalized_at": 1677833999, + "marked_uncollectible_at": null, + "paid_at": 1677833999, + "voided_at": null + }, + "subscription": "sub_1MhUT5E0b6fckueSYAo1LoiF", + "subtotal": 0, + "subtotal_excluding_tax": 0, + "tax": null, + "test_clock": null, + "total": 0, + "total_discount_amounts": [], + "total_excluding_tax": 0, + "total_tax_amounts": [], + "transfer_data": null, + "webhooks_delivered_at": null + } + }, + "livemode": false, + "pending_webhooks": 5, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "invoice.created" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:stripe/2022-11-15", + "snapshotDate": "2026-07" + } + }, + { + "provider": "stripe", + "providerLabel": "Stripe", + "presetId": "stripe", + "eventType": "invoice.finalized", + "name": "invoice.finalized", + "body": { + "id": "evt_1MhUT7E0b6fckueS9G2IKvN4", + "object": "event", + "api_version": "2022-11-15", + "created": 1677833999, + "data": { + "object": { + "id": "in_1MhUT5E0b6fckueSfPlY05Fw", + "object": "invoice", + "account_country": "US", + "account_name": "Rodriq", + "account_tax_ids": null, + "amount_due": 0, + "amount_paid": 0, + "amount_remaining": 0, + "amount_shipping": 0, + "application": null, + "application_fee_amount": null, + "attempt_count": 0, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "enabled": false, + "status": null + }, + "billing_reason": "subscription_create", + "charge": null, + "collection_method": "charge_automatically", + "created": 1677833999, + "currency": "usd", + "custom_fields": null, + "customer": "cus_NSPHp4fOlXGw8P", + "customer_address": { + "city": null, + "country": "CM", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "customer_email": "hook@me.com", + "customer_name": "Martin", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discount": null, + "discounts": [], + "due_date": null, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1IvKcSE0b6fckueS/test_YWNjdF8xSXZLY1NFMGI2ZmNrdWVTLF9OU1BIM0pMOHVzalNXcGNINFpUM29ucVhrQm80M1F3LDY4Mzc0ODAx0200k1WbLvaG?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1IvKcSE0b6fckueS/test_YWNjdF8xSXZLY1NFMGI2ZmNrdWVTLF9OU1BIM0pMOHVzalNXcGNINFpUM29ucVhrQm80M1F3LDY4Mzc0ODAx0200k1WbLvaG/pdf?s=ap", + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1MhUT5E0b6fckueSJCaQVbIe", + "object": "line_item", + "amount": 0, + "amount_excluding_tax": 0, + "currency": "usd", + "description": "Trial period for Meetin Grande", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "livemode": false, + "metadata": {}, + "period": { + "end": 1680425999, + "start": 1677833999 + }, + "plan": { + "id": "price_1M3wjkE0b6fckueSKPgfRMSG", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 700, + "amount_decimal": "700", + "billing_scheme": "per_unit", + "created": 1668409544, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": null, + "product": "prod_MnXpcVDdQq8wCY", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1M3wjkE0b6fckueSKPgfRMSG", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1668409544, + "currency": "usd", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": null, + "metadata": {}, + "nickname": null, + "product": "prod_MnXpcVDdQq8wCY", + "recurring": { + "aggregate_usage": null, + "interval": "month", + "interval_count": 1, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 700, + "unit_amount_decimal": "700" + }, + "proration": false, + "proration_details": { + "credited_items": null + }, + "quantity": 11, + "subscription": "sub_1MhUT5E0b6fckueSYAo1LoiF", + "subscription_item": "si_NSPHIHu3GFH1sj", + "tax_amounts": [], + "tax_rates": [], + "type": "subscription", + "unit_amount_excluding_tax": "0" + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1MhUT5E0b6fckueSfPlY05Fw/lines" + }, + "livemode": false, + "metadata": {}, + "next_payment_attempt": null, + "number": "3DAA104C-0001", + "on_behalf_of": null, + "paid": true, + "paid_out_of_band": false, + "payment_intent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "period_end": 1677833999, + "period_start": 1677833999, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "quote": null, + "receipt_number": null, + "rendering_options": null, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "paid", + "status_transitions": { + "finalized_at": 1677833999, + "marked_uncollectible_at": null, + "paid_at": 1677833999, + "voided_at": null + }, + "subscription": "sub_1MhUT5E0b6fckueSYAo1LoiF", + "subtotal": 0, + "subtotal_excluding_tax": 0, + "tax": null, + "test_clock": null, + "total": 0, + "total_discount_amounts": [], + "total_excluding_tax": 0, + "total_tax_amounts": [], + "transfer_data": null, + "webhooks_delivered_at": null + } + }, + "livemode": false, + "pending_webhooks": 4, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "invoice.finalized" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:stripe/2022-11-15", + "snapshotDate": "2026-07" + } + }, + { + "provider": "stripe", + "providerLabel": "Stripe", + "presetId": "stripe", + "eventType": "invoice.paid", + "name": "invoice.paid", + "body": { + "id": "evt_1MhUT7E0b6fckueShdwYrsWN", + "object": "event", + "api_version": "2022-11-15", + "created": 1677833999, + "data": { + "object": { + "id": "in_1MhUT5E0b6fckueSfPlY05Fw", + "object": "invoice", + "account_country": "US", + "account_name": "Rodriq", + "account_tax_ids": null, + "amount_due": 0, + "amount_paid": 0, + "amount_remaining": 0, + "amount_shipping": 0, + "application": null, + "application_fee_amount": null, + "attempt_count": 0, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "enabled": false, + "status": null + }, + "billing_reason": "subscription_create", + "charge": null, + "collection_method": "charge_automatically", + "created": 1677833999, + "currency": "usd", + "custom_fields": null, + "customer": "cus_NSPHp4fOlXGw8P", + "customer_address": { + "city": null, + "country": "CM", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "customer_email": "hook@me.com", + "customer_name": "Martin", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discount": null, + "discounts": [], + "due_date": null, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1IvKcSE0b6fckueS/test_YWNjdF8xSXZLY1NFMGI2ZmNrdWVTLF9OU1BIM0pMOHVzalNXcGNINFpUM29ucVhrQm80M1F3LDY4Mzc0ODAx0200k1WbLvaG?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1IvKcSE0b6fckueS/test_YWNjdF8xSXZLY1NFMGI2ZmNrdWVTLF9OU1BIM0pMOHVzalNXcGNINFpUM29ucVhrQm80M1F3LDY4Mzc0ODAx0200k1WbLvaG/pdf?s=ap", + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1MhUT5E0b6fckueSJCaQVbIe", + "object": "line_item", + "amount": 0, + "amount_excluding_tax": 0, + "currency": "usd", + "description": "Trial period for Meetin Grande", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "livemode": false, + "metadata": {}, + "period": { + "end": 1680425999, + "start": 1677833999 + }, + "plan": { + "id": "price_1M3wjkE0b6fckueSKPgfRMSG", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 700, + "amount_decimal": "700", + "billing_scheme": "per_unit", + "created": 1668409544, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": null, + "product": "prod_MnXpcVDdQq8wCY", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1M3wjkE0b6fckueSKPgfRMSG", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1668409544, + "currency": "usd", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": null, + "metadata": {}, + "nickname": null, + "product": "prod_MnXpcVDdQq8wCY", + "recurring": { + "aggregate_usage": null, + "interval": "month", + "interval_count": 1, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 700, + "unit_amount_decimal": "700" + }, + "proration": false, + "proration_details": { + "credited_items": null + }, + "quantity": 11, + "subscription": "sub_1MhUT5E0b6fckueSYAo1LoiF", + "subscription_item": "si_NSPHIHu3GFH1sj", + "tax_amounts": [], + "tax_rates": [], + "type": "subscription", + "unit_amount_excluding_tax": "0" + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1MhUT5E0b6fckueSfPlY05Fw/lines" + }, + "livemode": false, + "metadata": {}, + "next_payment_attempt": null, + "number": "3DAA104C-0001", + "on_behalf_of": null, + "paid": true, + "paid_out_of_band": false, + "payment_intent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "period_end": 1677833999, + "period_start": 1677833999, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "quote": null, + "receipt_number": null, + "rendering_options": null, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "paid", + "status_transitions": { + "finalized_at": 1677833999, + "marked_uncollectible_at": null, + "paid_at": 1677833999, + "voided_at": null + }, + "subscription": "sub_1MhUT5E0b6fckueSYAo1LoiF", + "subtotal": 0, + "subtotal_excluding_tax": 0, + "tax": null, + "test_clock": null, + "total": 0, + "total_discount_amounts": [], + "total_excluding_tax": 0, + "total_tax_amounts": [], + "transfer_data": null, + "webhooks_delivered_at": null + } + }, + "livemode": false, + "pending_webhooks": 4, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "invoice.paid" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:stripe/2022-11-15", + "snapshotDate": "2026-07" + } + }, + { + "provider": "stripe", + "providerLabel": "Stripe", + "presetId": "stripe", + "eventType": "invoice.payment_succeeded", + "name": "invoice.payment_succeeded", + "body": { + "id": "evt_1MhUT7E0b6fckueSTtEJC9R4", + "object": "event", + "api_version": "2022-11-15", + "created": 1677833999, + "data": { + "object": { + "id": "in_1MhUT5E0b6fckueSfPlY05Fw", + "object": "invoice", + "account_country": "US", + "account_name": "Rodriq", + "account_tax_ids": null, + "amount_due": 0, + "amount_paid": 0, + "amount_remaining": 0, + "amount_shipping": 0, + "application": null, + "application_fee_amount": null, + "attempt_count": 0, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "enabled": false, + "status": null + }, + "billing_reason": "subscription_create", + "charge": null, + "collection_method": "charge_automatically", + "created": 1677833999, + "currency": "usd", + "custom_fields": null, + "customer": "cus_NSPHp4fOlXGw8P", + "customer_address": { + "city": null, + "country": "CM", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "customer_email": "hook@me.com", + "customer_name": "Martin", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discount": null, + "discounts": [], + "due_date": null, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1IvKcSE0b6fckueS/test_YWNjdF8xSXZLY1NFMGI2ZmNrdWVTLF9OU1BIM0pMOHVzalNXcGNINFpUM29ucVhrQm80M1F3LDY4Mzc0ODAx0200k1WbLvaG?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1IvKcSE0b6fckueS/test_YWNjdF8xSXZLY1NFMGI2ZmNrdWVTLF9OU1BIM0pMOHVzalNXcGNINFpUM29ucVhrQm80M1F3LDY4Mzc0ODAx0200k1WbLvaG/pdf?s=ap", + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1MhUT5E0b6fckueSJCaQVbIe", + "object": "line_item", + "amount": 0, + "amount_excluding_tax": 0, + "currency": "usd", + "description": "Trial period for Meetin Grande", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "livemode": false, + "metadata": {}, + "period": { + "end": 1680425999, + "start": 1677833999 + }, + "plan": { + "id": "price_1M3wjkE0b6fckueSKPgfRMSG", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 700, + "amount_decimal": "700", + "billing_scheme": "per_unit", + "created": 1668409544, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": null, + "product": "prod_MnXpcVDdQq8wCY", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1M3wjkE0b6fckueSKPgfRMSG", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1668409544, + "currency": "usd", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": null, + "metadata": {}, + "nickname": null, + "product": "prod_MnXpcVDdQq8wCY", + "recurring": { + "aggregate_usage": null, + "interval": "month", + "interval_count": 1, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 700, + "unit_amount_decimal": "700" + }, + "proration": false, + "proration_details": { + "credited_items": null + }, + "quantity": 11, + "subscription": "sub_1MhUT5E0b6fckueSYAo1LoiF", + "subscription_item": "si_NSPHIHu3GFH1sj", + "tax_amounts": [], + "tax_rates": [], + "type": "subscription", + "unit_amount_excluding_tax": "0" + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1MhUT5E0b6fckueSfPlY05Fw/lines" + }, + "livemode": false, + "metadata": {}, + "next_payment_attempt": null, + "number": "3DAA104C-0001", + "on_behalf_of": null, + "paid": true, + "paid_out_of_band": false, + "payment_intent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "period_end": 1677833999, + "period_start": 1677833999, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "quote": null, + "receipt_number": null, + "rendering_options": null, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "paid", + "status_transitions": { + "finalized_at": 1677833999, + "marked_uncollectible_at": null, + "paid_at": 1677833999, + "voided_at": null + }, + "subscription": "sub_1MhUT5E0b6fckueSYAo1LoiF", + "subtotal": 0, + "subtotal_excluding_tax": 0, + "tax": null, + "test_clock": null, + "total": 0, + "total_discount_amounts": [], + "total_excluding_tax": 0, + "total_tax_amounts": [], + "transfer_data": null, + "webhooks_delivered_at": null + } + }, + "livemode": false, + "pending_webhooks": 5, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "invoice.payment_succeeded" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:stripe/2022-11-15", + "snapshotDate": "2026-07" + } + }, + { + "provider": "stripe", + "providerLabel": "Stripe", + "presetId": "stripe", + "eventType": "payment_link.created", + "name": "payment_link.created", + "body": { + "id": "evt_1MhUOwE0b6fckueScE3ilVdk", + "object": "event", + "api_version": "2022-11-15", + "created": 1677833742, + "data": { + "object": { + "id": "plink_1MhUOwE0b6fckueSjmvf9Yea", + "object": "payment_link", + "active": true, + "after_completion": { + "hosted_confirmation": { + "custom_message": null + }, + "type": "hosted_confirmation" + }, + "allow_promotion_codes": false, + "application_fee_amount": null, + "application_fee_percent": null, + "automatic_tax": { + "enabled": false + }, + "billing_address_collection": "auto", + "consent_collection": { + "promotions": "none", + "terms_of_service": "none" + }, + "currency": "usd", + "custom_fields": [], + "custom_text": { + "shipping_address": null, + "submit": null + }, + "customer_creation": "if_required", + "invoice_creation": { + "enabled": false, + "invoice_data": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "metadata": {}, + "rendering_options": null + } + }, + "livemode": false, + "metadata": {}, + "on_behalf_of": null, + "payment_intent_data": null, + "payment_method_collection": "always", + "payment_method_types": null, + "phone_number_collection": { + "enabled": false + }, + "shipping_address_collection": null, + "shipping_options": [], + "submit_type": "auto", + "subscription_data": { + "description": null, + "trial_period_days": 30 + }, + "tax_id_collection": { + "enabled": false + }, + "transfer_data": null, + "url": "https://buy.stripe.com/test_fZebKI0dz4Gf9Bm14h" + } + }, + "livemode": false, + "pending_webhooks": 10, + "request": { + "id": "req_yw1O7LbtRDYxgG", + "idempotency_key": "f6a8093e-63be-4656-a4a4-a08659ce719d" + }, + "type": "payment_link.created" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:stripe/2022-11-15", + "snapshotDate": "2026-07" + } + }, + { + "provider": "stripe", + "providerLabel": "Stripe", + "presetId": "stripe", + "eventType": "payment_link.updated", + "name": "payment_link.updated", + "body": { + "id": "evt_1MhUpbE0b6fckueS04UJ4Hhr", + "object": "event", + "api_version": "2022-11-15", + "created": 1677835395, + "data": { + "object": { + "id": "plink_1MZqEkE0b6fckueSGoXNsufx", + "object": "payment_link", + "active": false, + "after_completion": { + "hosted_confirmation": { + "custom_message": null + }, + "type": "hosted_confirmation" + }, + "allow_promotion_codes": false, + "application_fee_amount": null, + "application_fee_percent": null, + "automatic_tax": { + "enabled": false + }, + "billing_address_collection": "auto", + "consent_collection": { + "promotions": "none", + "terms_of_service": "none" + }, + "currency": "usd", + "custom_fields": [], + "custom_text": { + "shipping_address": null, + "submit": null + }, + "customer_creation": "if_required", + "invoice_creation": { + "enabled": false, + "invoice_data": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "metadata": {}, + "rendering_options": null + } + }, + "livemode": false, + "metadata": {}, + "on_behalf_of": null, + "payment_intent_data": null, + "payment_method_collection": "always", + "payment_method_types": null, + "phone_number_collection": { + "enabled": false + }, + "shipping_address_collection": null, + "shipping_options": [], + "submit_type": "auto", + "subscription_data": { + "description": null, + "trial_period_days": null + }, + "tax_id_collection": { + "enabled": false + }, + "transfer_data": null, + "url": "https://buy.stripe.com/test_4gw3ece4p1u3aFq4gr" + }, + "previous_attributes": { + "active": true + } + }, + "livemode": false, + "pending_webhooks": 8, + "request": { + "id": "req_dxXueNGdjQ9B47", + "idempotency_key": "5411e769-c2a9-4315-9965-ca16afded864" + }, + "type": "payment_link.updated" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:stripe/2022-11-15", + "snapshotDate": "2026-07" + } + }, + { + "provider": "stripe", + "providerLabel": "Stripe", + "presetId": "stripe", + "eventType": "payment_method.attached", + "name": "payment_method.attached", + "body": { + "id": "evt_1MhUT6E0b6fckueSb5uPJGPA", + "object": "event", + "api_version": "2022-11-15", + "created": 1677833998, + "data": { + "object": { + "id": "pm_1MhUT1E0b6fckueSV6KgiBFn", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": "CM", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "hook@me.com", + "name": "Martin", + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 12, + "exp_year": 2023, + "fingerprint": "iMCe31XWVrH1fujS", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": ["visa"], + "preferred": null + }, + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1677833996, + "customer": "cus_NSPHp4fOlXGw8P", + "livemode": false, + "metadata": {}, + "type": "card" + } + }, + "livemode": false, + "pending_webhooks": 3, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "payment_method.attached" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:stripe/2022-11-15", + "snapshotDate": "2026-07" + } + }, + { + "provider": "stripe", + "providerLabel": "Stripe", + "presetId": "stripe", + "eventType": "setup_intent.created", + "name": "setup_intent.created", + "body": { + "id": "evt_1MhUZqE0b6fckueSDMcrg1zt", + "object": "event", + "api_version": "2022-11-15", + "created": 1677834417, + "data": { + "object": { + "id": "seti_1MhUZpE0b6fckueSO1ROP6RG", + "object": "setup_intent", + "application": null, + "cancellation_reason": null, + "client_secret": "seti_1MhUZpE0b6fckueSO1ROP6RG_secret_NSPOCUAQ6cVd3xDNuz1SvYqodrJrHqh", + "created": 1677834417, + "customer": null, + "description": null, + "flow_directions": null, + "last_setup_error": null, + "latest_attempt": "setatt_1MhUZpE0b6fckueSuPUf8BpQ", + "livemode": false, + "mandate": null, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_options": { + "card": { + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": ["card"], + "single_use_mandate": null, + "status": "succeeded", + "usage": "off_session" + } + }, + "livemode": false, + "pending_webhooks": 3, + "request": { + "id": "req_JFbK69aRBjemw9", + "idempotency_key": "58143ed3-e3a0-4a99-a612-8647a7d911b8" + }, + "type": "setup_intent.created" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:stripe/2022-11-15", + "snapshotDate": "2026-07" + } + }, + { + "provider": "stripe", + "providerLabel": "Stripe", + "presetId": "stripe", + "eventType": "setup_intent.succeeded", + "name": "setup_intent.succeeded", + "body": { + "id": "evt_1MhUZqE0b6fckueSGwHOxNw6", + "object": "event", + "api_version": "2022-11-15", + "created": 1677834418, + "data": { + "object": { + "id": "seti_1MhUZpE0b6fckueSO1ROP6RG", + "object": "setup_intent", + "application": null, + "cancellation_reason": null, + "client_secret": "seti_1MhUZpE0b6fckueSO1ROP6RG_secret_NSPOCUAQ6cVd3xDNuz1SvYqodrJrHqh", + "created": 1677834417, + "customer": null, + "description": null, + "flow_directions": null, + "last_setup_error": null, + "latest_attempt": "setatt_1MhUZpE0b6fckueSuPUf8BpQ", + "livemode": false, + "mandate": null, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": "pm_1MhUZoE0b6fckueSNbTLQLkm", + "payment_method_options": { + "card": { + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": ["card"], + "single_use_mandate": null, + "status": "succeeded", + "usage": "off_session" + } + }, + "livemode": false, + "pending_webhooks": 3, + "request": { + "id": "req_JFbK69aRBjemw9", + "idempotency_key": "58143ed3-e3a0-4a99-a612-8647a7d911b8" + }, + "type": "setup_intent.succeeded" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:stripe/2022-11-15", + "snapshotDate": "2026-07" + } + }, + { + "provider": "woocommerce", + "providerLabel": "WooCommerce", + "eventType": "coupon.created", + "name": "coupon.created", + "body": { + "id": 17, + "code": "", + "amount": "10.00", + "status": "publish", + "date_created": "2023-03-25T16:22:37", + "date_created_gmt": "2023-03-25T16:22:37", + "date_modified": "2023-03-25T16:22:37", + "date_modified_gmt": "2023-03-25T16:22:37", + "discount_type": "percent", + "description": "", + "date_expires": "2023-03-31T00:00:00", + "date_expires_gmt": "2023-03-31T00:00:00", + "usage_count": 0, + "individual_use": false, + "product_ids": [], + "excluded_product_ids": [], + "usage_limit": null, + "usage_limit_per_user": null, + "limit_usage_to_x_items": null, + "free_shipping": false, + "product_categories": [], + "excluded_product_categories": [], + "exclude_sale_items": false, + "minimum_amount": "0.00", + "maximum_amount": "0.00", + "email_restrictions": [], + "used_by": [], + "meta_data": [], + "_links": { + "self": [ + { + "href": "http://localhost/wordpress/wp-json/wc/v3/coupons/17" + } + ], + "collection": [ + { + "href": "http://localhost/wordpress/wp-json/wc/v3/coupons" + } + ] + } + }, + "extraHeaders": { + "x-wc-webhook-event": "created", + "x-wc-webhook-resource": "coupon", + "x-wc-webhook-source": "http://localhost/wordpress/", + "x-wc-webhook-topic": "coupon.created" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:woocommerce/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "woocommerce", + "providerLabel": "WooCommerce", + "eventType": "coupon.deleted", + "name": "coupon.deleted", + "body": { + "id": 16 + }, + "extraHeaders": { + "x-wc-webhook-event": "deleted", + "x-wc-webhook-resource": "coupon", + "x-wc-webhook-source": "http://localhost/wordpress/", + "x-wc-webhook-topic": "coupon.deleted" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:woocommerce/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "woocommerce", + "providerLabel": "WooCommerce", + "eventType": "coupon.restored", + "name": "coupon.restored", + "body": { + "id": 16, + "code": "hook", + "amount": "5.00", + "status": "publish", + "date_created": "2023-03-25T16:04:36", + "date_created_gmt": "2023-03-25T16:04:36", + "date_modified": "2023-03-26T09:42:59", + "date_modified_gmt": "2023-03-26T09:42:59", + "discount_type": "percent", + "description": "", + "date_expires": "2023-03-31T00:00:00", + "date_expires_gmt": "2023-03-31T00:00:00", + "usage_count": 0, + "individual_use": false, + "product_ids": [], + "excluded_product_ids": [], + "usage_limit": null, + "usage_limit_per_user": null, + "limit_usage_to_x_items": null, + "free_shipping": false, + "product_categories": [], + "excluded_product_categories": [], + "exclude_sale_items": false, + "minimum_amount": "0.00", + "maximum_amount": "0.00", + "email_restrictions": [], + "used_by": [], + "meta_data": [ + { + "id": 77, + "key": "_wp_old_slug", + "value": "hook__trashed" + } + ], + "_links": { + "self": [ + { + "href": "http://localhost/wordpress/wp-json/wc/v3/coupons/16" + } + ], + "collection": [ + { + "href": "http://localhost/wordpress/wp-json/wc/v3/coupons" + } + ] + } + }, + "extraHeaders": { + "x-wc-webhook-event": "restored", + "x-wc-webhook-resource": "coupon", + "x-wc-webhook-source": "http://localhost/wordpress/", + "x-wc-webhook-topic": "coupon.restored" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:woocommerce/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "woocommerce", + "providerLabel": "WooCommerce", + "eventType": "product.created", + "name": "product.created", + "body": { + "id": 18, + "name": "Perpemint", + "slug": "perpemint", + "permalink": "http://localhost/wordpress/product/perpemint/", + "date_created": "2023-03-26T09:29:39", + "date_created_gmt": "2023-03-26T09:29:39", + "date_modified": "2023-03-26T09:29:39", + "date_modified_gmt": "2023-03-26T09:29:39", + "type": "simple", + "status": "publish", + "featured": false, + "catalog_visibility": "visible", + "description": "

Very Usefull

\n", + "short_description": "", + "sku": "", + "price": "150", + "regular_price": "200", + "sale_price": "150", + "date_on_sale_from": null, + "date_on_sale_from_gmt": null, + "date_on_sale_to": null, + "date_on_sale_to_gmt": null, + "on_sale": true, + "purchasable": true, + "total_sales": 0, + "virtual": false, + "downloadable": false, + "downloads": [], + "download_limit": -1, + "download_expiry": -1, + "external_url": "", + "button_text": "", + "tax_status": "taxable", + "tax_class": "", + "manage_stock": false, + "stock_quantity": null, + "backorders": "no", + "backorders_allowed": false, + "backordered": false, + "low_stock_amount": null, + "sold_individually": false, + "weight": "", + "dimensions": { + "length": "", + "width": "", + "height": "" + }, + "shipping_required": true, + "shipping_taxable": true, + "shipping_class": "", + "shipping_class_id": 0, + "reviews_allowed": true, + "average_rating": "0.00", + "rating_count": 0, + "upsell_ids": [], + "cross_sell_ids": [], + "parent_id": 0, + "purchase_note": "", + "categories": [ + { + "id": 15, + "name": "Uncategorized", + "slug": "uncategorized" + } + ], + "tags": [], + "images": [], + "attributes": [], + "default_attributes": [], + "variations": [], + "grouped_products": [], + "menu_order": 0, + "price_html": "200 CFA 150 CFA", + "related_ids": [13, 12], + "meta_data": [], + "stock_status": "instock", + "has_options": false, + "_links": { + "self": [ + { + "href": "http://localhost/wordpress/wp-json/wc/v3/products/18" + } + ], + "collection": [ + { + "href": "http://localhost/wordpress/wp-json/wc/v3/products" + } + ] + } + }, + "extraHeaders": { + "x-wc-webhook-event": "created", + "x-wc-webhook-resource": "product", + "x-wc-webhook-source": "http://localhost/wordpress/", + "x-wc-webhook-topic": "product.created" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:woocommerce/current", + "snapshotDate": "2026-07" + } + }, + { + "provider": "woocommerce", + "providerLabel": "WooCommerce", + "eventType": "product.updated", + "name": "product.updated", + "body": { + "id": 18, + "name": "Perpemint", + "slug": "perpemint", + "permalink": "http://localhost/wordpress/product/perpemint/", + "date_created": "2023-03-26T09:29:39", + "date_created_gmt": "2023-03-26T09:29:39", + "date_modified": "2023-03-26T09:29:39", + "date_modified_gmt": "2023-03-26T09:29:39", + "type": "simple", + "status": "publish", + "featured": false, + "catalog_visibility": "visible", + "description": "

Very Usefull

\n", + "short_description": "", + "sku": "", + "price": "150", + "regular_price": "200", + "sale_price": "150", + "date_on_sale_from": null, + "date_on_sale_from_gmt": null, + "date_on_sale_to": null, + "date_on_sale_to_gmt": null, + "on_sale": true, + "purchasable": true, + "total_sales": 0, + "virtual": false, + "downloadable": false, + "downloads": [], + "download_limit": -1, + "download_expiry": -1, + "external_url": "", + "button_text": "", + "tax_status": "taxable", + "tax_class": "", + "manage_stock": false, + "stock_quantity": null, + "backorders": "no", + "backorders_allowed": false, + "backordered": false, + "low_stock_amount": null, + "sold_individually": false, + "weight": "", + "dimensions": { + "length": "", + "width": "", + "height": "" + }, + "shipping_required": true, + "shipping_taxable": true, + "shipping_class": "", + "shipping_class_id": 0, + "reviews_allowed": true, + "average_rating": "0.00", + "rating_count": 0, + "upsell_ids": [], + "cross_sell_ids": [], + "parent_id": 0, + "purchase_note": "", + "categories": [ + { + "id": 15, + "name": "Uncategorized", + "slug": "uncategorized" + } + ], + "tags": [], + "images": [], + "attributes": [], + "default_attributes": [], + "variations": [], + "grouped_products": [], + "menu_order": 0, + "price_html": "200 CFA 150 CFA", + "related_ids": [12, 13], + "meta_data": [], + "stock_status": "instock", + "has_options": false, + "_links": { + "self": [ + { + "href": "http://localhost/wordpress/wp-json/wc/v3/products/18" + } + ], + "collection": [ + { + "href": "http://localhost/wordpress/wp-json/wc/v3/products" + } + ] + } + }, + "extraHeaders": { + "x-wc-webhook-event": "updated", + "x-wc-webhook-resource": "product", + "x-wc-webhook-source": "http://localhost/wordpress/", + "x-wc-webhook-topic": "product.updated" + }, + "provenance": { + "kind": "upstream", + "ref": "hookdeck/webhook-samples:woocommerce/current", + "snapshotDate": "2026-07" + } + } +] diff --git a/internal-packages/webhook-sources/src/handAuthored/anthropic.ts b/internal-packages/webhook-sources/src/handAuthored/anthropic.ts new file mode 100644 index 0000000000..b67aa54749 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/anthropic.ts @@ -0,0 +1,119 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Anthropic (Managed Agents) webhook samples. Anthropic signs deliveries with a single + * `X-Webhook-Signature` header (secret `whsec_`-prefixed, verified via the SDK's + * `client.beta.webhooks.unwrap()`), not the three-header Standard Webhooks scheme our `svix` + * preset verifies, so these ship sample-only (no `presetId`). Every payload is a deliberately + * thin envelope: `{ type: "event", id, created_at, data: { type, id, organization_id, + * workspace_id } }`. Anthropic does not include the full resource in the delivery, by design + * ("avoids delivering stale data on retries and keeps every delivery small") — the integrator + * is expected to follow up with a `GET` against `data.id` (e.g. `sessions.retrieve(data.id)`). + */ +export const samples: SampleRecord[] = [ + { + provider: "anthropic", + providerLabel: "Anthropic (Claude)", + eventType: "session.status_run_started", + name: "Session run started", + description: + "A Managed Agents session transitioned to running. Fires on every transition into that status, not just the first. `data.id` is the session id; fetch it with `sessions.retrieve()` for the current state.", + body: { + type: "event", + id: "event_01HZXQ2K8VN3R5T7WA9BCEFGH", + created_at: "2026-07-14T09:12:03Z", + data: { + type: "session.status_run_started", + id: "sesn_01JX4KPQF7VN2R8TA9WBCDEFH", + organization_id: "8a3d2f1e-6b47-4c9a-9d2e-1f5c8a3b7e6d", + workspace_id: "c7b0e4d9-2a1f-4e6b-8c3d-5f9a7b2e4d1c", + }, + }, + docsUrl: "https://platform.claude.com/docs/en/managed-agents/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "anthropic", + providerLabel: "Anthropic (Claude)", + eventType: "session.status_idled", + name: "Session idled", + description: + "The agent is awaiting input — a tool permission approval or the next user message. `data.id` is the same session id from the run-started event; fetch it to read `stop_reason`.", + body: { + type: "event", + id: "event_01HZXQ3M9WP4S6U8XB1CDFGHJ", + created_at: "2026-07-14T09:14:47Z", + data: { + type: "session.status_idled", + id: "sesn_01JX4KPQF7VN2R8TA9WBCDEFH", + organization_id: "8a3d2f1e-6b47-4c9a-9d2e-1f5c8a3b7e6d", + workspace_id: "c7b0e4d9-2a1f-4e6b-8c3d-5f9a7b2e4d1c", + }, + }, + docsUrl: "https://platform.claude.com/docs/en/managed-agents/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "anthropic", + providerLabel: "Anthropic (Claude)", + eventType: "deployment_run.succeeded", + name: "Deployment run succeeded", + description: + "A scheduled deployment's cron firing created its session. `data.id` is the deployment run id (`drun_...`), not the session id — fetch the deployment run for its `session_id`, then subscribe to that session's events.", + body: { + type: "event", + id: "event_01HZXQ5R2YT6V9WA3CEGHJKLM", + created_at: "2026-07-14T20:00:01Z", + data: { + type: "deployment_run.succeeded", + id: "drun_01KM8NRTV2WY4Z6A9CDEFGHJ", + organization_id: "8a3d2f1e-6b47-4c9a-9d2e-1f5c8a3b7e6d", + workspace_id: "c7b0e4d9-2a1f-4e6b-8c3d-5f9a7b2e4d1c", + }, + }, + docsUrl: "https://platform.claude.com/docs/en/managed-agents/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "anthropic", + providerLabel: "Anthropic (Claude)", + eventType: "deployment_run.failed", + name: "Deployment run failed", + description: + "A scheduled deployment's cron firing did not create a session (e.g. the environment was archived, or the vault credential was missing). `data.id` is the deployment run id; fetch it for `error.type` / `error.message`.", + body: { + type: "event", + id: "event_01HZXQ6T4ZV8WB5CEGHJKLMNP", + created_at: "2026-07-15T02:00:02Z", + data: { + type: "deployment_run.failed", + id: "drun_01KM9PSUW3XZ5A7BDEFGHJKM", + organization_id: "8a3d2f1e-6b47-4c9a-9d2e-1f5c8a3b7e6d", + workspace_id: "c7b0e4d9-2a1f-4e6b-8c3d-5f9a7b2e4d1c", + }, + }, + docsUrl: "https://platform.claude.com/docs/en/managed-agents/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "anthropic", + providerLabel: "Anthropic (Claude)", + eventType: "agent.updated", + name: "Agent updated", + description: + "A new version of an agent config was published (updates that don't create a new version don't fire this). `data.id` is the agent id; fetch it for the new `version` and config.", + body: { + type: "event", + id: "event_01HZXQ7V6BW1XC7DFHJKLMNPQ", + created_at: "2026-07-15T11:30:19Z", + data: { + type: "agent.updated", + id: "agent_01LN2QTVX4YZ6B8CEFGHJKLN", + organization_id: "8a3d2f1e-6b47-4c9a-9d2e-1f5c8a3b7e6d", + workspace_id: "c7b0e4d9-2a1f-4e6b-8c3d-5f9a7b2e4d1c", + }, + }, + docsUrl: "https://platform.claude.com/docs/en/managed-agents/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/assemblyai.ts b/internal-packages/webhook-sources/src/handAuthored/assemblyai.ts new file mode 100644 index 0000000000..cb18155704 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/assemblyai.ts @@ -0,0 +1,39 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * AssemblyAI transcript webhooks. The documented payload is intentionally minimal - just + * `transcript_id` and `status` - with only two possible `status` values (`completed`, `error`). Auth + * is a static shared secret header (`webhook_auth_header_name`/`webhook_auth_header_value`, set by the + * integrator when creating the transcript), not an HMAC/Ed25519 signature, so these ship sample-only + * (no `presetId`). + */ +export const samples: SampleRecord[] = [ + { + provider: "assemblyai", + providerLabel: "AssemblyAI", + eventType: "completed", + name: "Transcript completed", + description: + "The transcription finished successfully. Fetch GET /v2/transcript/{transcript_id} to retrieve the text.", + body: { + transcript_id: "5552493-16d8-42d8-8feb-c2a16b56f6e8", + status: "completed", + }, + docsUrl: "https://www.assemblyai.com/docs/concepts/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "assemblyai", + providerLabel: "AssemblyAI", + eventType: "error", + name: "Transcript error", + description: + "The transcription failed. Fetch GET /v2/transcript/{transcript_id} to read the `error` field with the failure reason.", + body: { + transcript_id: "8317204-9a2c-4f11-b6d0-1e6c4a8f7c3d", + status: "error", + }, + docsUrl: "https://www.assemblyai.com/docs/concepts/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/attio.ts b/internal-packages/webhook-sources/src/handAuthored/attio.ts new file mode 100644 index 0000000000..0ee92b1992 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/attio.ts @@ -0,0 +1,145 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Attio samples. No preset: Attio signs with its own `attio-signature` header, a bare hex + * HMAC-SHA256 digest of the raw body (no `sha256=` prefix, no timestamp), which doesn't match + * stripe, github, svix, square, or discord. Each delivery wraps one event in a `webhook_id` + + * `events` envelope; Attio notes deliveries currently carry exactly one event but may batch more + * in future, so `events` is modeled as an array per the real shape. + */ +export const samples: SampleRecord[] = [ + { + provider: "attio", + providerLabel: "Attio", + eventType: "record.created", + name: "Record created (company)", + description: "A new company record was created in the workspace via the UI.", + body: { + webhook_id: "23e42eaf-323a-41da-b5bb-fd67eebda553", + events: [ + { + event_type: "record.created", + id: { + workspace_id: "14beef7a-99f7-4534-a87e-70b564330a4c", + object_id: "97052eb9-e65e-443f-a297-f2d9a4a7f795", + record_id: "bf071e1f-6035-429d-b874-d83ea64ea13b", + }, + actor: { + type: "workspace-member", + id: "50cf242c-7fa3-4cad-87d0-75b1af71c57b", + }, + }, + ], + }, + docsUrl: "https://docs.attio.com/rest-api/guides/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "attio", + providerLabel: "Attio", + eventType: "record.updated", + name: "Record updated (stage attribute changed)", + description: "An existing record's deal-stage attribute was changed by a workspace member.", + body: { + webhook_id: "23e42eaf-323a-41da-b5bb-fd67eebda553", + events: [ + { + event_type: "record.updated", + id: { + workspace_id: "14beef7a-99f7-4534-a87e-70b564330a4c", + object_id: "6a1f3d9c-8b2e-4c7a-9d5f-2e8b1a4c7d9e", + record_id: "d4c8b2a6-1e9f-4d3c-8a7b-5f2e9c1d6a8b", + attribute_id: "41252299-f8c7-4b5e-99c9-4ff8321d2f96", + }, + actor: { + type: "workspace-member", + id: "50cf242c-7fa3-4cad-87d0-75b1af71c57b", + }, + }, + ], + }, + docsUrl: "https://docs.attio.com/rest-api/guides/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "attio", + providerLabel: "Attio", + eventType: "record.deleted", + name: "Record deleted (person)", + description: "A person record was permanently deleted from the workspace.", + body: { + webhook_id: "23e42eaf-323a-41da-b5bb-fd67eebda553", + events: [ + { + event_type: "record.deleted", + id: { + workspace_id: "14beef7a-99f7-4534-a87e-70b564330a4c", + object_id: "9b3e6f1a-2c8d-4e5b-9f7a-1d4c8e2b6f9a", + record_id: "2f6b9d4e-8a1c-4f7d-9b3e-6c2a5f8d1b4c", + }, + actor: { + type: "workspace-member", + id: "7d2a9f4c-1b6e-4c8d-9a3f-5e1b8c2d6f9a", + }, + }, + ], + }, + docsUrl: "https://docs.attio.com/rest-api/guides/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "attio", + providerLabel: "Attio", + eventType: "list-entry.created", + name: "List entry created (added to sales pipeline)", + description: "An existing company record was added as an entry to the sales pipeline list.", + body: { + webhook_id: "23e42eaf-323a-41da-b5bb-fd67eebda553", + events: [ + { + event_type: "list-entry.created", + id: { + workspace_id: "14beef7a-99f7-4534-a87e-70b564330a4c", + list_id: "33ebdbe9-e529-47c9-b894-0ba25e9c15c0", + entry_id: "2e6e29ea-c4e0-4f44-842d-78a891f8c156", + }, + parent_object_id: "97052eb9-e65e-443f-a297-f2d9a4a7f795", + parent_record_id: "bf071e1f-6035-429d-b874-d83ea64ea13b", + actor: { + type: "workspace-member", + id: "50cf242c-7fa3-4cad-87d0-75b1af71c57b", + }, + }, + ], + }, + docsUrl: "https://docs.attio.com/rest-api/guides/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "attio", + providerLabel: "Attio", + eventType: "note.created", + name: "Note created (on a company record)", + description: "A workspace member added a note to an existing company record.", + body: { + webhook_id: "23e42eaf-323a-41da-b5bb-fd67eebda553", + events: [ + { + event_type: "note.created", + id: { + workspace_id: "14beef7a-99f7-4534-a87e-70b564330a4c", + note_id: "ff3f3bd4-40f4-4f80-8187-cd02385af424", + }, + parent_object_id: "97052eb9-e65e-443f-a297-f2d9a4a7f795", + parent_record_id: "bf071e1f-6035-429d-b874-d83ea64ea13b", + actor: { + type: "workspace-member", + id: "50cf242c-7fa3-4cad-87d0-75b1af71c57b", + }, + }, + ], + }, + docsUrl: "https://docs.attio.com/rest-api/guides/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/auth0.ts b/internal-packages/webhook-sources/src/handAuthored/auth0.ts new file mode 100644 index 0000000000..7ad09c52cb --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/auth0.ts @@ -0,0 +1,214 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Auth0 log stream samples. Auth0 authenticates these deliveries with a static bearer token (the + * Authorization Token set on the log stream), not an HMAC/Ed25519 signature, so no preset applies. + * Each delivery is a JSON array of log events; every element wraps the actual log fields under `data`, + * with `data.type` carrying the Auth0 log event type code (see the Log Event Type Codes reference). + */ +export const samples: SampleRecord[] = [ + { + provider: "auth0", + providerLabel: "Auth0", + eventType: "s", + name: "Successful login", + description: "A user successfully authenticated against a database connection.", + body: [ + { + log_id: "90020250708141238000000000000000001", + data: { + date: "2026-07-08T14:12:38.123Z", + type: "s", + description: "", + connection: "Username-Password-Authentication", + connection_id: "con_9f1KcxcOAJVzAKGh", + client_id: "AaiyAPdpYdesoKnqjj8HJqRn4T5titww", + client_name: "Trigger.dev Dashboard", + ip: "203.0.113.42", + user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + is_mobile: false, + hostname: "trigger-dev.us.auth0.com", + tenant_name: "trigger-dev", + environment_name: "production", + strategy: "auth0", + strategy_type: "database", + user_id: "auth0|64f1b2e3c9d4a10012345678", + user_name: "jordan@example.com", + log_id: "90020250708141238000000000000000001", + details: { + initiatedAt: 1752070357800, + completedAt: 1752070358123, + elapsedTime: 323, + session_id: "sess_2yQ8pXwK9nR3vT7hLmZ4jC1s", + }, + }, + }, + ], + docsUrl: "https://auth0.com/docs/customize/log-streams/custom-log-streams", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "auth0", + providerLabel: "Auth0", + eventType: "fp", + name: "Failed login (wrong password)", + description: "A login attempt failed because the password did not match the account.", + body: [ + { + log_id: "90020250708141902000000000000000002", + data: { + date: "2026-07-08T14:19:02.881Z", + type: "fp", + description: "Wrong password.", + connection: "Username-Password-Authentication", + connection_id: "con_9f1KcxcOAJVzAKGh", + client_id: "AaiyAPdpYdesoKnqjj8HJqRn4T5titww", + client_name: "Trigger.dev Dashboard", + ip: "198.51.100.17", + user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + is_mobile: false, + hostname: "trigger-dev.us.auth0.com", + tenant_name: "trigger-dev", + environment_name: "production", + strategy: "auth0", + strategy_type: "database", + user_id: "auth0|64f1b2e3c9d4a10012345678", + user_name: "jordan@example.com", + log_id: "90020250708141902000000000000000002", + details: { + error: { + message: "Wrong password.", + oauthError: "invalid_user_password", + type: "invalid_password", + }, + }, + }, + }, + ], + docsUrl: "https://auth0.com/docs/customize/log-streams/custom-log-streams", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "auth0", + providerLabel: "Auth0", + eventType: "fu", + name: "Failed login (invalid username)", + description: "A login attempt failed because no account matched the given identifier.", + body: [ + { + log_id: "90020250708142511000000000000000003", + data: { + date: "2026-07-08T14:25:11.402Z", + type: "fu", + description: "Invalid username or password.", + connection: "Username-Password-Authentication", + connection_id: "con_9f1KcxcOAJVzAKGh", + client_id: "AaiyAPdpYdesoKnqjj8HJqRn4T5titww", + client_name: "Trigger.dev Dashboard", + ip: "203.0.113.90", + user_agent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15", + is_mobile: true, + hostname: "trigger-dev.us.auth0.com", + tenant_name: "trigger-dev", + environment_name: "production", + strategy: "auth0", + strategy_type: "database", + user_id: null, + user_name: "unknown-visitor@example.com", + log_id: "90020250708142511000000000000000003", + details: { + error: { + message: "Invalid username or password.", + oauthError: "invalid_user_password", + type: "invalid_username", + }, + }, + }, + }, + ], + docsUrl: "https://auth0.com/docs/customize/log-streams/custom-log-streams", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "auth0", + providerLabel: "Auth0", + eventType: "f", + name: "Failed login (federated connection denied)", + description: + "A login attempt failed for a reason outside of bad credentials, e.g. a Post-Login Action denying access on a social connection.", + body: [ + { + log_id: "90020250708150044000000000000000004", + data: { + date: "2026-07-08T15:00:44.219Z", + type: "f", + description: "Access denied.", + connection: "google-oauth2", + connection_id: "con_7bWmQdRk2LpNxVaU", + client_id: "AaiyAPdpYdesoKnqjj8HJqRn4T5titww", + client_name: "Trigger.dev Dashboard", + ip: "203.0.113.211", + user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", + is_mobile: false, + hostname: "trigger-dev.us.auth0.com", + tenant_name: "trigger-dev", + environment_name: "production", + strategy: "google-oauth2", + strategy_type: "social", + user_id: "google-oauth2|108234567890123456789", + user_name: "morgan@example.com", + log_id: "90020250708150044000000000000000004", + details: { + error: { + message: "Access denied.", + oauthError: "access_denied", + type: "oauth-authorization-code", + }, + }, + }, + }, + ], + docsUrl: "https://auth0.com/docs/customize/log-streams/custom-log-streams", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "auth0", + providerLabel: "Auth0", + eventType: "ss", + name: "Successful signup", + description: "A new user completed signup against a database connection.", + body: [ + { + log_id: "90020250708153327000000000000000005", + data: { + date: "2026-07-08T15:33:27.556Z", + type: "ss", + description: "", + connection: "Username-Password-Authentication", + connection_id: "con_9f1KcxcOAJVzAKGh", + client_id: "AaiyAPdpYdesoKnqjj8HJqRn4T5titww", + client_name: "Trigger.dev Dashboard", + ip: "203.0.113.55", + user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + is_mobile: false, + hostname: "trigger-dev.us.auth0.com", + tenant_name: "trigger-dev", + environment_name: "production", + strategy: "auth0", + strategy_type: "database", + user_id: "auth0|64f1c9a7d8e2b30012349999", + user_name: "taylor@example.com", + log_id: "90020250708153327000000000000000005", + details: { + initiatedAt: 1752075206900, + completedAt: 1752075207556, + elapsedTime: 656, + session_id: "sess_8kR2wLpN4vT9jC1hZmQ3yX7s", + }, + }, + }, + ], + docsUrl: "https://auth0.com/docs/customize/log-streams/custom-log-streams", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/brex.ts b/internal-packages/webhook-sources/src/handAuthored/brex.ts new file mode 100644 index 0000000000..c96bd2f69c --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/brex.ts @@ -0,0 +1,95 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Brex samples. Confirmed shapes: `TRANSFER_PROCESSED`/`TRANSFER_FAILED` (docs' own verified test + * fixture: `event_type`, `transfer_id`, `company_id`) and `EXPENSE_PAYMENT_UPDATED` (docs' webhook + * examples page, a card-charge event: `event_type`, `expense_id`, `payment_status`, `payment_type`, + * `company_id`, `amount`, `payment_description`). `REFERRAL_ACTIVATED` and `USER_UPDATED` are listed + * in the docs' webhook event catalog table but shown there without a sample payload, so their bodies + * extrapolate the same minimal `event_type` + resource id + `company_id` shape the confirmed events + * use. + */ +export const samples: SampleRecord[] = [ + { + provider: "brex", + providerLabel: "Brex", + presetId: "svix", + eventType: "TRANSFER_PROCESSED", + name: "Transfer processed", + description: "A transfer was successfully received.", + body: { + event_type: "TRANSFER_PROCESSED", + transfer_id: "dptx_ck9g2h4nq000101ms2s5cz3rq", + company_id: "cuacc_ck9wodfq7h000801q48qqsae5k", + }, + docsUrl: "https://developer.brex.com/docs/webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "brex", + providerLabel: "Brex", + presetId: "svix", + eventType: "TRANSFER_FAILED", + name: "Transfer failed", + description: "A transfer attempt failed.", + body: { + event_type: "TRANSFER_FAILED", + transfer_id: "dptx_ck3h9k2mp000201nt3t6da4sw", + company_id: "cuacc_ck9wodfq7h000801q48qqsae5k", + }, + docsUrl: "https://developer.brex.com/docs/webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "brex", + providerLabel: "Brex", + presetId: "svix", + eventType: "EXPENSE_PAYMENT_UPDATED", + name: "Card expense payment updated", + description: "A charge on a Brex card produced a new or updated expense payment.", + body: { + event_type: "EXPENSE_PAYMENT_UPDATED", + expense_id: "expense_cl3khzfnr00000k92bdjtmmes", + payment_status: "CLEARED", + payment_type: "PURCHASE", + company_id: "cuacc_ck9wodfq7h000801q48qqsae5k", + amount: { + amount: 4899, + currency: "USD", + }, + payment_description: "Acme Cloud Hosting", + }, + docsUrl: "https://developer.brex.com/docs/webhook_examples/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "brex", + providerLabel: "Brex", + presetId: "svix", + eventType: "REFERRAL_ACTIVATED", + name: "Referral activated", + description: "A referred contact signed up for Brex using a referral link.", + body: { + event_type: "REFERRAL_ACTIVATED", + referral_id: "referral_ck7m3n9pq000401vb8x2ez6ta", + company_id: "cuacc_ck9wodfq7h000801q48qqsae5k", + }, + docsUrl: "https://developer.brex.com/docs/webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "brex", + providerLabel: "Brex", + presetId: "svix", + eventType: "USER_UPDATED", + name: "User updated", + description: "A user on the account was updated.", + body: { + event_type: "USER_UPDATED", + user_id: "buser_ck2p6r4st000501wc9y3fa7ub", + company_id: "cuacc_ck9wodfq7h000801q48qqsae5k", + }, + docsUrl: "https://developer.brex.com/docs/webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/cal-com.ts b/internal-packages/webhook-sources/src/handAuthored/cal-com.ts new file mode 100644 index 0000000000..eb0332f410 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/cal-com.ts @@ -0,0 +1,163 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Cal.com samples. No preset: Cal.com signs with its own `x-cal-signature-256` header, a plain hex + * HMAC-SHA256 digest of the raw body (no `sha256=` prefix), which does not match our `github` + * preset's wire format. `MEETING_ENDED` is an exception among these events: its fields sit flat at + * the top level instead of nested under `payload`. + */ +export const samples: SampleRecord[] = [ + { + provider: "cal-com", + providerLabel: "Cal.com", + eventType: "BOOKING_CREATED", + name: "Booking created", + description: "A new booking was made for an event type and accepted immediately.", + body: { + triggerEvent: "BOOKING_CREATED", + createdAt: "2026-07-01T14:05:00.000Z", + payload: { + bookerUrl: "https://cal.com/jordan-lee", + title: "30 Min Meeting between Jordan Lee and Priya Nair", + startTime: "2026-07-03T16:00:00.000Z", + endTime: "2026-07-03T16:30:00.000Z", + eventTypeId: 48213, + organizer: { + id: 9021, + name: "Jordan Lee", + email: "jordan.lee@example.com", + username: "jordan-lee", + timeZone: "America/Los_Angeles", + timeFormat: "h:mma", + }, + attendees: [ + { + email: "priya.nair@example.com", + name: "Priya Nair", + timeZone: "Asia/Kolkata", + }, + ], + location: "https://meet.google.com/abc-defg-hij", + uid: "8f2b6c1a9d3e4a7fb5c21e6f9a0d4b7c", + bookingId: 5502391, + status: "ACCEPTED", + }, + }, + docsUrl: "https://cal.com/docs/developing/guides/automation/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "cal-com", + providerLabel: "Cal.com", + eventType: "BOOKING_CANCELLED", + name: "Booking cancelled", + description: "The attendee cancelled an existing booking and gave a reason.", + body: { + triggerEvent: "BOOKING_CANCELLED", + createdAt: "2026-07-02T09:41:12.000Z", + payload: { + title: "30 Min Meeting between Jordan Lee and Priya Nair", + startTime: "2026-07-03T16:00:00.000Z", + endTime: "2026-07-03T16:30:00.000Z", + organizer: { + id: 9021, + name: "Jordan Lee", + email: "jordan.lee@example.com", + username: "jordan-lee", + timeZone: "America/Los_Angeles", + timeFormat: "h:mma", + }, + attendees: [ + { + email: "priya.nair@example.com", + name: "Priya Nair", + timeZone: "Asia/Kolkata", + }, + ], + uid: "8f2b6c1a9d3e4a7fb5c21e6f9a0d4b7c", + bookingId: 5502391, + cancellationReason: "Something came up, need to reschedule for next week.", + status: "CANCELLED", + }, + }, + docsUrl: "https://cal.com/docs/developing/guides/automation/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "cal-com", + providerLabel: "Cal.com", + eventType: "BOOKING_RESCHEDULED", + name: "Booking rescheduled", + description: + "An existing booking was moved to a new time; the payload links old and new slots.", + body: { + triggerEvent: "BOOKING_RESCHEDULED", + createdAt: "2026-07-04T11:18:47.000Z", + payload: { + title: "30 Min Meeting between Jordan Lee and Priya Nair", + startTime: "2026-07-03T16:00:00.000Z", + endTime: "2026-07-03T16:30:00.000Z", + organizer: { + id: 9021, + name: "Jordan Lee", + email: "jordan.lee@example.com", + username: "jordan-lee", + timeZone: "America/Los_Angeles", + timeFormat: "h:mma", + }, + attendees: [ + { + email: "priya.nair@example.com", + name: "Priya Nair", + timeZone: "Asia/Kolkata", + }, + ], + uid: "8f2b6c1a9d3e4a7fb5c21e6f9a0d4b7c", + bookingId: 5502391, + rescheduleId: 5502391, + rescheduleUid: "3f6a1c2e71b44b8d9e2a6d4f0a8b1c3d", + rescheduleStartTime: "2026-07-10T17:00:00.000Z", + rescheduleEndTime: "2026-07-10T17:30:00.000Z", + status: "ACCEPTED", + }, + }, + docsUrl: "https://cal.com/docs/developing/guides/automation/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "cal-com", + providerLabel: "Cal.com", + eventType: "MEETING_ENDED", + name: "Meeting ended", + description: + "A booked meeting finished. Unlike the other trigger events, MEETING_ENDED carries its fields flat at the top level instead of under payload.", + body: { + triggerEvent: "MEETING_ENDED", + id: 5502391, + uid: "8f2b6c1a9d3e4a7fb5c21e6f9a0d4b7c", + userId: 9021, + eventTypeId: 48213, + title: "30 Min Meeting between Jordan Lee and Priya Nair", + startTime: "2026-07-03T16:00:00.000Z", + endTime: "2026-07-03T16:30:00.000Z", + createdAt: "2026-07-01T14:05:00.000Z", + updatedAt: "2026-07-03T16:30:05.000Z", + status: "ACCEPTED", + user: { + email: "jordan.lee@example.com", + name: "Jordan Lee", + timeZone: "America/Los_Angeles", + }, + attendees: [ + { + id: 771034, + email: "priya.nair@example.com", + name: "Priya Nair", + timeZone: "Asia/Kolkata", + }, + ], + }, + docsUrl: "https://cal.com/docs/developing/guides/automation/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/calendly.ts b/internal-packages/webhook-sources/src/handAuthored/calendly.ts new file mode 100644 index 0000000000..95a72577b1 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/calendly.ts @@ -0,0 +1,236 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Calendly samples. No preset: Calendly signs with its own `Calendly-Webhook-Signature` header + * (`t=,v1=`, HMAC-SHA256 over `{t}.{rawBody}`), which does not match `stripe-signature`. + */ +export const samples: SampleRecord[] = [ + { + provider: "calendly", + providerLabel: "Calendly", + eventType: "invitee.created", + name: "Invitee created (one-on-one)", + description: "A new invitee booked a one-on-one event, with a custom question answered.", + body: { + created_at: "2026-06-01T15:22:41.000000Z", + event: "invitee.created", + payload: { + cancel_url: "https://calendly.com/cancellations/3f6a1c2e-71b4-4b8d-9e2a-6d4f0a8b1c3d", + created_at: "2026-06-01T15:22:41.000000Z", + email: "jordan.rivera@example.com", + event: "https://api.calendly.com/scheduled_events/8f2b6c1a-9d3e-4a7f-b5c2-1e6f9a0d4b7c", + name: "Jordan Rivera", + new_invitee: null, + old_invitee: null, + questions_and_answers: [ + { + question: "What would you like to discuss?", + answer: "Onboarding walkthrough for the new workspace.", + position: 0, + }, + ], + reschedule_url: "https://calendly.com/reschedulings/3f6a1c2e-71b4-4b8d-9e2a-6d4f0a8b1c3d", + rescheduled: false, + status: "active", + text_reminder_number: null, + timezone: "America/Los_Angeles", + tracking: { + utm_campaign: null, + utm_source: null, + utm_medium: null, + utm_content: null, + utm_term: null, + salesforce_uuid: null, + }, + updated_at: "2026-06-01T15:22:41.000000Z", + uri: "https://api.calendly.com/scheduled_events/8f2b6c1a-9d3e-4a7f-b5c2-1e6f9a0d4b7c/invitees/1a4d7e2b-5c8f-4a1d-9b3e-6f0c8d2a5b7e", + }, + }, + docsUrl: "https://developer.calendly.com/api-docs/4c305798a61d3-webhook-signatures", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "calendly", + providerLabel: "Calendly", + eventType: "invitee.created", + name: "Invitee created (group event, via campaign link)", + description: "A new invitee joined a group event through a link carrying UTM tracking params.", + body: { + created_at: "2026-06-03T09:05:12.000000Z", + event: "invitee.created", + payload: { + cancel_url: "https://calendly.com/cancellations/9a1e5f3c-2b7d-4c6a-8e1f-0d3b6a9c2e5f", + created_at: "2026-06-03T09:05:12.000000Z", + email: "priya.nair@example.com", + event: "https://api.calendly.com/scheduled_events/2c5e8a1d-4f7b-4d9e-a2c5-8b1e4f7a0d3c", + name: "Priya Nair", + new_invitee: null, + old_invitee: null, + questions_and_answers: [], + reschedule_url: "https://calendly.com/reschedulings/9a1e5f3c-2b7d-4c6a-8e1f-0d3b6a9c2e5f", + rescheduled: false, + status: "active", + text_reminder_number: null, + timezone: "Europe/London", + tracking: { + utm_campaign: "summer-webinar", + utm_source: "newsletter", + utm_medium: "email", + utm_content: "cta-button", + utm_term: null, + salesforce_uuid: null, + }, + updated_at: "2026-06-03T09:05:12.000000Z", + uri: "https://api.calendly.com/scheduled_events/2c5e8a1d-4f7b-4d9e-a2c5-8b1e4f7a0d3c/invitees/6d9c2a5e-7f1b-4a3d-9e6c-2b5f8a1d4c7e", + }, + }, + docsUrl: "https://developer.calendly.com/api-docs/4c305798a61d3-webhook-signatures", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "calendly", + providerLabel: "Calendly", + eventType: "invitee.created", + name: "Invitee created (rebooked after reschedule)", + description: + "The replacement invitee record created when an existing invitee reschedules; old_invitee links back to the canceled booking.", + body: { + created_at: "2026-06-05T18:41:07.000000Z", + event: "invitee.created", + payload: { + cancel_url: "https://calendly.com/cancellations/5b8e1c4a-9f2d-4b6e-8c1a-3d5f9b2e6a8c", + created_at: "2026-06-05T18:41:07.000000Z", + email: "morgan.blake@example.com", + event: "https://api.calendly.com/scheduled_events/4d7f0a3c-6b9e-4c2d-8f5a-1e3d6b9c2f5a", + name: "Morgan Blake", + new_invitee: null, + old_invitee: + "https://api.calendly.com/scheduled_events/9e2c5a8d-1f4b-4d7e-a3c6-5b8e1a4d7c9e/invitees/3c6f9a2d-5e8b-4a1c-9d3f-6a9c2e5b8d1f", + questions_and_answers: [ + { + question: "Anything else we should know?", + answer: "Moving the call an hour later.", + position: 0, + }, + ], + reschedule_url: "https://calendly.com/reschedulings/5b8e1c4a-9f2d-4b6e-8c1a-3d5f9b2e6a8c", + rescheduled: false, + status: "active", + text_reminder_number: null, + timezone: "America/New_York", + tracking: { + utm_campaign: null, + utm_source: null, + utm_medium: null, + utm_content: null, + utm_term: null, + salesforce_uuid: null, + }, + updated_at: "2026-06-05T18:41:07.000000Z", + uri: "https://api.calendly.com/scheduled_events/4d7f0a3c-6b9e-4c2d-8f5a-1e3d6b9c2f5a/invitees/8f1a4d7c-2e5b-4a9d-8c1f-3e6a9d2c5f8b", + }, + }, + docsUrl: "https://developer.calendly.com/api-docs/4c305798a61d3-webhook-signatures", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "calendly", + providerLabel: "Calendly", + eventType: "invitee.canceled", + name: "Invitee canceled (self-canceled with reason)", + description: "The invitee canceled their own booking and left a cancellation reason.", + body: { + created_at: "2026-06-08T11:30:55.000000Z", + event: "invitee.canceled", + payload: { + cancel_url: "https://calendly.com/cancellations/3f6a1c2e-71b4-4b8d-9e2a-6d4f0a8b1c3d", + cancellation: { + canceled_by: "Jordan Rivera", + canceler_type: "invitee", + reason: "Something came up, will rebook next week.", + }, + created_at: "2026-06-01T15:22:41.000000Z", + email: "jordan.rivera@example.com", + event: "https://api.calendly.com/scheduled_events/8f2b6c1a-9d3e-4a7f-b5c2-1e6f9a0d4b7c", + name: "Jordan Rivera", + new_invitee: null, + old_invitee: null, + questions_and_answers: [ + { + question: "What would you like to discuss?", + answer: "Onboarding walkthrough for the new workspace.", + position: 0, + }, + ], + reschedule_url: "https://calendly.com/reschedulings/3f6a1c2e-71b4-4b8d-9e2a-6d4f0a8b1c3d", + rescheduled: false, + status: "canceled", + text_reminder_number: null, + timezone: "America/Los_Angeles", + tracking: { + utm_campaign: null, + utm_source: null, + utm_medium: null, + utm_content: null, + utm_term: null, + salesforce_uuid: null, + }, + updated_at: "2026-06-08T11:30:55.000000Z", + uri: "https://api.calendly.com/scheduled_events/8f2b6c1a-9d3e-4a7f-b5c2-1e6f9a0d4b7c/invitees/1a4d7e2b-5c8f-4a1d-9b3e-6f0c8d2a5b7e", + }, + }, + docsUrl: "https://developer.calendly.com/api-docs/4c305798a61d3-webhook-signatures", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "calendly", + providerLabel: "Calendly", + eventType: "invitee.canceled", + name: "Invitee canceled (host rescheduled the meeting)", + description: + "The host rescheduled the meeting on the invitee's behalf; rescheduled is true and new_invitee points at the replacement booking.", + body: { + created_at: "2026-06-05T18:41:07.000000Z", + event: "invitee.canceled", + payload: { + cancel_url: "https://calendly.com/cancellations/9e2c5a8d-1f4b-4d7e-a3c6-5b8e1a4d7c9e", + cancellation: { + canceled_by: "Alex Chen", + canceler_type: "host", + reason: null, + }, + created_at: "2026-06-02T13:00:00.000000Z", + email: "morgan.blake@example.com", + event: "https://api.calendly.com/scheduled_events/9e2c5a8d-1f4b-4d7e-a3c6-5b8e1a4d7c9e", + name: "Morgan Blake", + new_invitee: + "https://api.calendly.com/scheduled_events/4d7f0a3c-6b9e-4c2d-8f5a-1e3d6b9c2f5a/invitees/8f1a4d7c-2e5b-4a9d-8c1f-3e6a9d2c5f8b", + old_invitee: null, + questions_and_answers: [ + { + question: "Anything else we should know?", + answer: "Moving the call an hour later.", + position: 0, + }, + ], + reschedule_url: "https://calendly.com/reschedulings/9e2c5a8d-1f4b-4d7e-a3c6-5b8e1a4d7c9e", + rescheduled: true, + status: "canceled", + text_reminder_number: null, + timezone: "America/New_York", + tracking: { + utm_campaign: null, + utm_source: null, + utm_medium: null, + utm_content: null, + utm_term: null, + salesforce_uuid: null, + }, + updated_at: "2026-06-05T18:41:07.000000Z", + uri: "https://api.calendly.com/scheduled_events/9e2c5a8d-1f4b-4d7e-a3c6-5b8e1a4d7c9e/invitees/3c6f9a2d-5e8b-4a1c-9d3f-6a9c2e5b8d1f", + }, + }, + docsUrl: "https://developer.calendly.com/api-docs/4c305798a61d3-webhook-signatures", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/clerk.ts b/internal-packages/webhook-sources/src/handAuthored/clerk.ts new file mode 100644 index 0000000000..791bc65bd5 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/clerk.ts @@ -0,0 +1,39 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Clerk samples. Clerk signs webhooks with Svix, so `presetId: "svix"` keeps them under the round-trip + * guarantee while carrying their own freeform `provider`/`providerLabel`. + */ +export const samples: SampleRecord[] = [ + { + provider: "clerk", + providerLabel: "Clerk", + presetId: "svix", + eventType: "user.created", + name: "User created", + description: "A new Clerk user. Clerk signs webhooks with Svix.", + body: { + type: "user.created", + object: "event", + data: { + id: "user_29w83sxmDNGwOuEthce5gg56FcC", + object: "user", + first_name: "Jordan", + last_name: "Lee", + email_addresses: [ + { + id: "idn_29w83yL7CwVlJXylYLxcslromF1", + object: "email_address", + email_address: "jordan@example.com", + verification: { status: "verified", strategy: "email_code" }, + }, + ], + primary_email_address_id: "idn_29w83yL7CwVlJXylYLxcslromF1", + created_at: 1654012591514, + updated_at: 1654012591835, + }, + }, + docsUrl: "https://clerk.com/docs/integrations/webhooks/overview", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/close-crm.ts b/internal-packages/webhook-sources/src/handAuthored/close-crm.ts new file mode 100644 index 0000000000..ac4a2ca9ef --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/close-crm.ts @@ -0,0 +1,287 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Close samples. Every delivery wraps a single Event Log entry in `{ event, subscription_id }`; + * `event.object_type` + `event.action` together identify the resource and action (Close's own docs + * list them as separate fields, e.g. `lead` / `created`), so `eventType` here joins them as + * `"."`. Close does not sign with any of our verifier presets (see the registry + * entry), so no sample sets `presetId`. The `lead.created` and `activity.*.created` bodies follow the + * Lead / Contact / CallActivity field names from the Close API reference; the opportunity-status-change + * activity's `old_status` / `new_status` fields are inferred from the Event Log docs' description of + * that activity type (the reference page for that resource ships no field-level schema). The final + * sample is Close's own published webhook example (an opportunity moving to "Won"), lightly + * re-dated. + */ +export const samples: SampleRecord[] = [ + { + provider: "close-crm", + providerLabel: "Close", + eventType: "lead.created", + name: "Lead created", + description: "A new lead was created in the organization, regardless of the method used.", + body: { + event: { + id: "ev_2r8KzQyF7bWmNdE1TxJcP4", + date_created: "2026-07-10T15:02:11.204000", + date_updated: "2026-07-10T15:02:11.204000", + object_type: "lead", + object_id: "lead_9k2R7wYtVbXmS3Ac6Fq1Zp", + lead_id: "lead_9k2R7wYtVbXmS3Ac6Fq1Zp", + organization_id: "orga_Hn4Wq7VtRj2AmY8bKzXc1S", + user_id: "user_Vp3Km9RtWzY6QaX1Nc4Bf7", + request_id: "req_7YtRj2AmVp3Km9RtWzY6Qa", + action: "created", + changed_fields: [], + previous_data: null, + data: { + id: "lead_9k2R7wYtVbXmS3Ac6Fq1Zp", + name: "Acme Rocket Co", + display_name: "Acme Rocket Co", + description: "Inbound demo request from the pricing page.", + url: "https://acmerocket.example", + status_id: "stat_1Nc4Bf7Hn4Wq7VtRj2AmY8", + status_label: "Potential", + html_url: "https://app.close.com/lead/lead_9k2R7wYtVbXmS3Ac6Fq1Zp/", + addresses: [ + { + label: "business", + address_1: "142 Harbor Ave", + address_2: "Suite 400", + city: "Portland", + state: "OR", + zipcode: "97201", + country: "US", + }, + ], + contacts: [ + { + id: "cont_Bf7Hn4Wq7VtRj2AmY8bKzX", + name: "Dana Whitfield", + title: "VP Operations", + display_name: "Dana Whitfield", + emails: [ + { email: "dana@acmerocket.example", type: "office", is_unsubscribed: false }, + ], + phones: [ + { + phone: "+15035550142", + type: "office", + phone_formatted: "+1 503-555-0142", + country: "US", + }, + ], + lead_id: "lead_9k2R7wYtVbXmS3Ac6Fq1Zp", + organization_id: "orga_Hn4Wq7VtRj2AmY8bKzXc1S", + }, + ], + custom: {}, + created_by: "user_Vp3Km9RtWzY6QaX1Nc4Bf7", + created_by_name: "Sam Okafor", + updated_by: "user_Vp3Km9RtWzY6QaX1Nc4Bf7", + organization_id: "orga_Hn4Wq7VtRj2AmY8bKzXc1S", + date_created: "2026-07-10T15:02:11.198000+00:00", + date_updated: "2026-07-10T15:02:11.198000+00:00", + }, + meta: { request_method: "POST", request_path: "/api/v1/lead/" }, + }, + subscription_id: "whsub_Km9RtWzY6QaX1Nc4Bf7Hn4", + }, + docsUrl: "https://developer.close.com/topics/webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "close-crm", + providerLabel: "Close", + eventType: "activity.note.created", + name: "Note activity created", + description: "A freeform note was logged on a lead.", + body: { + event: { + id: "ev_4TxJcP4r8KzQyF7bWmNdE1", + date_created: "2026-07-10T15:11:47.512000", + date_updated: "2026-07-10T15:11:47.512000", + object_type: "activity.note", + object_id: "acti_Wq7VtRj2AmY8bKzXc1SHn4", + lead_id: "lead_9k2R7wYtVbXmS3Ac6Fq1Zp", + organization_id: "orga_Hn4Wq7VtRj2AmY8bKzXc1S", + user_id: "user_Vp3Km9RtWzY6QaX1Nc4Bf7", + request_id: "req_9RtWzY6QaX1Nc4Bf7Hn4Wq", + action: "created", + changed_fields: [], + previous_data: null, + data: { + id: "acti_Wq7VtRj2AmY8bKzXc1SHn4", + _type: "Note", + note: "Left a voicemail, asked to follow up next week after their board meeting.", + note_html: + "

Left a voicemail, asked to follow up next week after their board meeting.

", + lead_id: "lead_9k2R7wYtVbXmS3Ac6Fq1Zp", + contact_id: "cont_Bf7Hn4Wq7VtRj2AmY8bKzX", + organization_id: "orga_Hn4Wq7VtRj2AmY8bKzXc1S", + created_by: "user_Vp3Km9RtWzY6QaX1Nc4Bf7", + created_by_name: "Sam Okafor", + updated_by: "user_Vp3Km9RtWzY6QaX1Nc4Bf7", + date_created: "2026-07-10T15:11:47.502000+00:00", + date_updated: "2026-07-10T15:11:47.502000+00:00", + }, + meta: { request_method: "POST", request_path: "/api/v1/activity/note/" }, + }, + subscription_id: "whsub_Km9RtWzY6QaX1Nc4Bf7Hn4", + }, + docsUrl: "https://developer.close.com/api/resources/activities/notes.md", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "close-crm", + providerLabel: "Close", + eventType: "activity.call.created", + name: "Call activity created", + description: "An inbound call was logged on a lead's contact.", + body: { + event: { + id: "ev_7bWmNdE1TxJcP4r8KzQyF4", + date_created: "2026-07-10T16:04:33.877000", + date_updated: "2026-07-10T16:04:33.877000", + object_type: "activity.call", + object_id: "acti_c1SHn4Wq7VtRj2AmY8bKzX", + lead_id: "lead_9k2R7wYtVbXmS3Ac6Fq1Zp", + organization_id: "orga_Hn4Wq7VtRj2AmY8bKzXc1S", + user_id: "user_Vp3Km9RtWzY6QaX1Nc4Bf7", + request_id: "req_1Nc4Bf7Hn4Wq7VtRj2AmY8", + action: "created", + changed_fields: [], + previous_data: null, + data: { + id: "acti_c1SHn4Wq7VtRj2AmY8bKzX", + _type: "Call", + direction: "inbound", + status: "completed", + duration: 184, + phone: "+15035550142", + note: "", + recording_url: null, + outcome_id: null, + contact_id: "cont_Bf7Hn4Wq7VtRj2AmY8bKzX", + lead_id: "lead_9k2R7wYtVbXmS3Ac6Fq1Zp", + organization_id: "orga_Hn4Wq7VtRj2AmY8bKzXc1S", + created_by: "user_Vp3Km9RtWzY6QaX1Nc4Bf7", + created_by_name: "Sam Okafor", + date_created: "2026-07-10T16:04:33.861000+00:00", + date_updated: "2026-07-10T16:04:33.861000+00:00", + }, + meta: { request_method: "POST", request_path: "/api/v1/activity/call/" }, + }, + subscription_id: "whsub_Km9RtWzY6QaX1Nc4Bf7Hn4", + }, + docsUrl: "https://developer.close.com/api/resources/activities/calls.md", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "close-crm", + providerLabel: "Close", + eventType: "activity.opportunity_status_change.created", + name: "Opportunity status change activity created", + description: + "A dedicated status-change activity was created for an opportunity (distinct from the opportunity's own `updated` event); `new_status`/`old_status` name the transition.", + body: { + event: { + id: "ev_Bf7Hn4Wq7VtRj2AmY8bKzX", + date_created: "2026-07-10T17:20:05.339000", + date_updated: "2026-07-10T17:20:05.339000", + object_type: "activity.opportunity_status_change", + object_id: "acti_5R3RfhIQixdnmjf2wMS9M6", + lead_id: "lead_9k2R7wYtVbXmS3Ac6Fq1Zp", + organization_id: "orga_Hn4Wq7VtRj2AmY8bKzXc1S", + user_id: "user_Vp3Km9RtWzY6QaX1Nc4Bf7", + request_id: "req_c1SHn4Wq7VtRj2AmY8bKzXc", + action: "created", + changed_fields: [], + previous_data: null, + data: { + id: "acti_5R3RfhIQixdnmjf2wMS9M6", + _type: "StatusChange.Opportunity", + opportunity_id: "oppo_8H4sjNso7FyBFaeR3RXi5P", + lead_id: "lead_9k2R7wYtVbXmS3Ac6Fq1Zp", + old_status: { id: "stat_3FD9DnGUCJzccBKTh8LiiK", label: "Active", type: "active" }, + new_status: { id: "stat_wMS9M6HC2O3CSEOzF5g2vE", label: "Won", type: "won" }, + organization_id: "orga_Hn4Wq7VtRj2AmY8bKzXc1S", + created_by: "user_Vp3Km9RtWzY6QaX1Nc4Bf7", + created_by_name: "Sam Okafor", + date_created: "2026-07-10T17:20:05.331000+00:00", + date_updated: "2026-07-10T17:20:05.331000+00:00", + }, + meta: { + request_method: "PUT", + request_path: "/api/v1/opportunity/oppo_8H4sjNso7FyBFaeR3RXi5P/", + }, + }, + subscription_id: "whsub_Km9RtWzY6QaX1Nc4Bf7Hn4", + }, + docsUrl: "https://developer.close.com/api/resources/activities/opportunity-status-changes.md", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "close-crm", + providerLabel: "Close", + eventType: "opportunity.updated", + name: "Opportunity updated (won)", + description: + "Close's own published webhook example: an opportunity's status field changed to \"Won\"; `previous_data` carries the prior status. Re-dated from the docs example, otherwise verbatim.", + body: { + event: { + id: "ev_2sYKRjcrA79yKxi3S4Crd7", + date_created: "2026-07-10T18:48:23.395000", + date_updated: "2026-07-10T18:48:23.395000", + object_type: "opportunity", + object_id: "oppo_7H4sjNso7FyBFaeR3RXi5PMJbilfo0c6UPCxsJtEhCO", + lead_id: "lead_zwqYhEFwzPyfCErS8uQ77is2wFLvr9BgVi6cTfbFM68", + organization_id: "orga_XbVPx5fFbKlYTz9PW5Ih1XDhViV10YihIaEgMEb6fVW", + user_id: "user_N6KhMpzHRCYQHdn4gRNIFNN5JExnsrprKA6ekxM63XA", + request_id: "req_4S2L8JTBAA1OUS74SVmfbN", + action: "updated", + changed_fields: ["confidence", "date_updated", "status_id", "status_label", "status_type"], + previous_data: { + status_type: "active", + confidence: 70, + date_updated: "2026-07-10T18:47:39.873000+00:00", + status_id: "stat_3FD9DnGUCJzccBKTh8LiiKoyVPpMJsOkJdcGoA5AYKH", + status_label: "Active", + }, + data: { + id: "oppo_8H4sjNso7FyBFaeR3RXi5PMJbilfo0c6UPCxsJtEhCO", + lead_id: "lead_zwqYhEFwzPyfCErS8uQ77is2wFLvr9BgVi6cTfbFM68", + lead_name: "KLine", + contact_id: "cont_BwlwYQkIP6AooiXP1CMvc6Zbb5gGh2gPu4dqIDlDrII", + contact_name: "Mr. Jones", + organization_id: "orga_XbVPx5fFbKlYTz9PW5Ih1XDhViV10YihIaEgMEb6fVW", + status_id: "stat_wMS9M6HC2O3CSEOzF5g2vEGt6RM5R3RfhIQixdnmjf2", + status_label: "Won", + status_type: "won", + confidence: 100, + value: 100000, + value_period: "one_time", + value_currency: "USD", + value_formatted: "$1,000", + note: "", + date_won: "2026-07-10", + date_lost: null, + created_by: "user_N6KhMpzHRCYQHdn4gRNIFNN5JExnsrprKA6ekxM63XA", + created_by_name: "Joe Kemp", + updated_by: "user_N6KhMpzHRCYQHdn4gRNIFNN5JExnsrprKA6ekxM63XA", + updated_by_name: "Joe Kemp", + user_id: "user_N6KhMpzHRCYQHdn4gRNIFNN5JExnsrprKA6ekxM63XA", + user_name: "Joe Kemp", + date_created: "2026-07-10T18:41:24.496000+00:00", + date_updated: "2026-07-10T18:48:23.392000+00:00", + }, + meta: { + request_method: "PUT", + request_path: "/api/v1/opportunity/oppo_7H4sjNso7FyBFaeR3RXi5PMJbilfo0c6UPCxsJtEhCO/", + }, + }, + subscription_id: "whsub_8AmjKCZYT3zI8eZoi4HhFC", + }, + docsUrl: "https://developer.close.com/topics/webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/deepgram.ts b/internal-packages/webhook-sources/src/handAuthored/deepgram.ts new file mode 100644 index 0000000000..ab29ddc580 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/deepgram.ts @@ -0,0 +1,304 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Deepgram prerecorded-transcription callback samples. When a `callback` URL is supplied to the + * `/v1/listen` endpoint, Deepgram POSTs the finished job's result to that URL - the same JSON shape + * the synchronous API would have returned, `{ metadata, results }`, with no `type`/`event` discriminant + * anywhere in the body. Auth is a `dg-token` header identifying (not authenticating) the API key used; + * there is no HMAC/Ed25519 signature, so these ship sample-only (no `presetId`). A failed job instead + * delivers Deepgram's general API error shape, `{ err_code, err_msg, request_id }`. `eventType` here is + * our own descriptive label (there is no wire value to mirror), distinguishing the shapes an integrator + * actually needs to branch on: plain mono, multichannel, diarized/utterance-level, and failure. + */ +export const samples: SampleRecord[] = [ + { + provider: "deepgram", + providerLabel: "Deepgram", + eventType: "transcript.completed", + name: "Transcript completed", + description: + "A prerecorded mono transcription job finished. `results.channels[].alternatives[0]` carries the transcript, overall confidence, and per-word timing.", + body: { + metadata: { + transaction_key: "deprecated", + request_id: "c22bea24-ecd3-4b53-bcbf-8ef087d905a5", + sha256: "d3b8f2a1c9e4567890abcdef1234567890abcdef1234567890abcdef123456", + created: "2026-07-14T09:32:11.418Z", + duration: 18.42, + channels: 1, + models: ["30089e05-99d1-4376-b32e-c263170674af"], + model_info: { + "30089e05-99d1-4376-b32e-c263170674af": { + name: "2-general-nova", + version: "2024-01-09.29447", + arch: "nova-2", + }, + }, + }, + results: { + channels: [ + { + alternatives: [ + { + transcript: "Thanks for calling support, how can I help you today?", + confidence: 0.9942, + words: [ + { + word: "thanks", + start: 0.08, + end: 0.32, + confidence: 0.9976, + punctuated_word: "Thanks", + }, + { + word: "for", + start: 0.32, + end: 0.48, + confidence: 0.9991, + punctuated_word: "for", + }, + { + word: "calling", + start: 0.48, + end: 0.8, + confidence: 0.9958, + punctuated_word: "calling", + }, + { + word: "support", + start: 0.8, + end: 1.24, + confidence: 0.9967, + punctuated_word: "support,", + }, + { + word: "how", + start: 1.4, + end: 1.56, + confidence: 0.9989, + punctuated_word: "how", + }, + { + word: "can", + start: 1.56, + end: 1.68, + confidence: 0.999, + punctuated_word: "can", + }, + { word: "i", start: 1.68, end: 1.76, confidence: 0.9984, punctuated_word: "I" }, + { + word: "help", + start: 1.76, + end: 2.0, + confidence: 0.9979, + punctuated_word: "help", + }, + { + word: "you", + start: 2.0, + end: 2.16, + confidence: 0.9985, + punctuated_word: "you", + }, + { + word: "today", + start: 2.16, + end: 2.48, + confidence: 0.9971, + punctuated_word: "today?", + }, + ], + }, + ], + }, + ], + }, + }, + docsUrl: "https://developers.deepgram.com/docs/callback", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "deepgram", + providerLabel: "Deepgram", + eventType: "transcript.completed-multichannel", + name: "Transcript completed (multichannel)", + description: + "A stereo recording transcribed with `multichannel=true`. `results.channels` has one entry per input channel (here, a two-line call recording with the agent on channel 0 and the customer on channel 1), each with its own alternatives.", + body: { + metadata: { + transaction_key: "deprecated", + request_id: "6f3a9e2d-4b1c-4a8e-9d5f-2c8b7a6e5d4c", + sha256: "9a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f", + created: "2026-07-13T15:04:52.907Z", + duration: 42.19, + channels: 2, + models: ["30089e05-99d1-4376-b32e-c263170674af"], + model_info: { + "30089e05-99d1-4376-b32e-c263170674af": { + name: "2-phonecall-nova", + version: "2024-01-09.29447", + arch: "nova-2", + }, + }, + }, + results: { + channels: [ + { + alternatives: [ + { + transcript: "Support line, this is Marcus, what's the account number on file?", + confidence: 0.9861, + words: [ + { + word: "support", + start: 0.0, + end: 0.36, + confidence: 0.9944, + punctuated_word: "Support", + }, + { + word: "line", + start: 0.36, + end: 0.6, + confidence: 0.9902, + punctuated_word: "line,", + }, + ], + }, + ], + }, + { + alternatives: [ + { + transcript: "Hi, it's ACC-58213, I can't access my dashboard.", + confidence: 0.9714, + words: [ + { word: "hi", start: 3.1, end: 3.24, confidence: 0.9931, punctuated_word: "Hi," }, + { + word: "it's", + start: 3.32, + end: 3.48, + confidence: 0.9822, + punctuated_word: "it's", + }, + ], + }, + ], + }, + ], + }, + }, + docsUrl: "https://developers.deepgram.com/docs/callback", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "deepgram", + providerLabel: "Deepgram", + eventType: "transcript.completed-diarized", + name: "Transcript completed (diarized)", + description: + "A single-channel recording transcribed with `diarize=true` and `utterances=true`. Each word carries a `speaker` index, and `results.utterances` groups words into per-speaker turns.", + body: { + metadata: { + transaction_key: "deprecated", + request_id: "b1c8e4a7-3d92-4f6e-8a1b-5e9c7d2f4a83", + sha256: "5e4d3c2b1a0f9e8d7c6b5a4938271605f4e3d2c1b0a9f8e7d6c5b4a39281706", + created: "2026-07-12T11:47:03.220Z", + duration: 61.05, + channels: 1, + models: ["e2f5b1c9-8a3d-4e7f-9c1a-2b6d8f4e5a70"], + model_info: { + "e2f5b1c9-8a3d-4e7f-9c1a-2b6d8f4e5a70": { + name: "3-general", + version: "2024-11-13.0", + arch: "nova-3", + }, + }, + }, + results: { + channels: [ + { + alternatives: [ + { + transcript: + "Let's kick off the standup. I finished the migration script yesterday. Nice, I'll review the PR this morning.", + confidence: 0.9788, + words: [ + { + word: "let's", + start: 0.04, + end: 0.28, + confidence: 0.9912, + punctuated_word: "Let's", + speaker: 0, + }, + { + word: "kick", + start: 0.28, + end: 0.48, + confidence: 0.9955, + punctuated_word: "kick", + speaker: 0, + }, + { + word: "i", + start: 3.12, + end: 3.2, + confidence: 0.988, + punctuated_word: "I", + speaker: 1, + }, + { + word: "finished", + start: 3.2, + end: 3.56, + confidence: 0.9903, + punctuated_word: "finished", + speaker: 1, + }, + ], + }, + ], + }, + ], + utterances: [ + { + start: 0.04, + end: 2.9, + confidence: 0.9912, + channel: 0, + transcript: "Let's kick off the standup.", + speaker: 0, + id: "3f2a1b0c-9d8e-4f7a-6b5c-4d3e2f1a0b9c", + }, + { + start: 3.12, + end: 5.64, + confidence: 0.9903, + channel: 0, + transcript: "I finished the migration script yesterday.", + speaker: 1, + id: "8c7b6a5d-4e3f-2a1b-0c9d-8e7f6a5b4c3d", + }, + ], + }, + }, + docsUrl: "https://developers.deepgram.com/docs/callback", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "deepgram", + providerLabel: "Deepgram", + eventType: "transcript.failed", + name: "Transcript failed", + description: + "Deepgram could not complete the job (here, fetching the source audio URL failed) and delivers its general API error shape to the callback URL instead of a results object.", + body: { + err_code: "REMOTE_CONTENT_ERROR", + err_msg: "There was an error fetching the audio from the provided URL.", + request_id: "a4e8d1c6-2f9b-4a3e-8d5c-1b6f9e2a7c4d", + }, + docsUrl: "https://developers.deepgram.com/docs/callback", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/discord.ts b/internal-packages/webhook-sources/src/handAuthored/discord.ts new file mode 100644 index 0000000000..31f9af8fd0 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/discord.ts @@ -0,0 +1,150 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Discord Webhook Events samples. Every delivery (including the initial `PING` handshake) shares + * the outer envelope `{ version, application_id, type, event? }`; `type: 0` is the PING handshake + * with no `event` field, `type: 1` carries `event: { type, timestamp, data }`. `QUEST_USER_ENROLLMENT` + * is documented but explicitly marked as not currently deliverable to apps, so it is excluded here. + */ +export const samples: SampleRecord[] = [ + { + provider: "discord", + providerLabel: "Discord", + presetId: "discord", + eventType: "PING", + name: "Webhook handshake PING", + description: + "Sent once when you save a webhook events URL in the developer portal, to confirm you can verify and respond in time.", + body: { + version: 1, + application_id: "1102345678901234567", + type: 0, + }, + docsUrl: "https://docs.discord.com/developers/events/webhook-events", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "discord", + providerLabel: "Discord", + presetId: "discord", + eventType: "APPLICATION_AUTHORIZED", + name: "App authorized to a server", + description: "A user installed and authorized the app to a guild (server).", + body: { + version: 1, + application_id: "1102345678901234567", + type: 1, + event: { + type: "APPLICATION_AUTHORIZED", + timestamp: "2026-07-14T18:22:03.064834", + data: { + integration_type: 0, + scopes: ["applications.commands", "bot"], + user: { + id: "912233445566778899", + username: "quinn.dev", + discriminator: "0", + avatar: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", + global_name: "Quinn", + }, + guild: { + id: "445566778899001122", + name: "Trigger.dev Community", + icon: "f2e1d0c9b8a7968574635241302f1e0d", + }, + }, + }, + }, + docsUrl: "https://docs.discord.com/developers/events/webhook-events", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "discord", + providerLabel: "Discord", + presetId: "discord", + eventType: "APPLICATION_DEAUTHORIZED", + name: "App deauthorized by a user", + description: + "A user removed the app's authorization (uninstalled it from their account or a server).", + body: { + version: 1, + application_id: "1102345678901234567", + type: 1, + event: { + type: "APPLICATION_DEAUTHORIZED", + timestamp: "2026-07-14T19:05:41.221009", + data: { + user: { + id: "912233445566778899", + username: "quinn.dev", + discriminator: "0", + avatar: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", + global_name: "Quinn", + }, + }, + }, + }, + docsUrl: "https://docs.discord.com/developers/events/webhook-events", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "discord", + providerLabel: "Discord", + presetId: "discord", + eventType: "ENTITLEMENT_CREATE", + name: "Premium entitlement created", + description: "A user purchased or was granted an application subscription entitlement.", + body: { + version: 1, + application_id: "1102345678901234567", + type: 1, + event: { + type: "ENTITLEMENT_CREATE", + timestamp: "2026-07-14T20:11:09.109604", + data: { + id: "1201983746501928374", + application_id: "1102345678901234567", + sku_id: "1091827364501928374", + user_id: "912233445566778899", + type: 8, + deleted: false, + consumed: false, + gift_code_flags: 0, + promotion_id: null, + }, + }, + }, + docsUrl: "https://docs.discord.com/developers/events/webhook-events", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "discord", + providerLabel: "Discord", + presetId: "discord", + eventType: "ENTITLEMENT_DELETE", + name: "Premium entitlement revoked", + description: "An entitlement was deleted, typically because of a refund or chargeback.", + body: { + version: 1, + application_id: "1102345678901234567", + type: 1, + event: { + type: "ENTITLEMENT_DELETE", + timestamp: "2026-07-14T21:47:33.552817", + data: { + id: "1201983746501928374", + application_id: "1102345678901234567", + sku_id: "1091827364501928374", + user_id: "912233445566778899", + type: 8, + deleted: true, + consumed: false, + gift_code_flags: 0, + promotion_id: null, + }, + }, + }, + docsUrl: "https://docs.discord.com/developers/events/webhook-events", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/docusign.ts b/internal-packages/webhook-sources/src/handAuthored/docusign.ts new file mode 100644 index 0000000000..401af2ec52 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/docusign.ts @@ -0,0 +1,278 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * DocuSign Connect samples, JSON SIM (Connect 2.0) envelope format. No preset: DocuSign signs with + * `X-DocuSign-Signature-1` (additional secrets add `-2`, `-3`, ...), an HMAC-SHA256 base64 digest of the + * raw JSON body keyed by the Connect configuration's HMAC secret. There is no `t=`/`v1=` component and + * no request-URL-inclusive signing, so this doesn't match our stripe, github, svix, square, or discord + * presets. The exact set of fields present under `data.envelopeSummary` depends on the subscriber's + * Connect configuration (which optional data fields were selected), so field presence can vary in + * practice; the shapes below reflect a typical default configuration. + */ +export const samples: SampleRecord[] = [ + { + provider: "docusign", + providerLabel: "DocuSign", + eventType: "envelope-sent", + name: "Envelope sent", + description: "An envelope was sent to its recipients for signing.", + body: { + event: "envelope-sent", + apiVersion: "v2.1", + uri: "/restapi/v2.1/accounts/8675309a-1234-4a1b-9c1d-3e5f7a9b1c2d/envelopes/3f9a2c1e-6b4d-4e2f-8a1c-9d3b5f7e2a6c", + retryCount: 0, + configurationId: 271828, + generatedDateTime: "2026-06-28T15:02:11.4471203Z", + data: { + accountId: "8675309a-1234-4a1b-9c1d-3e5f7a9b1c2d", + userId: "1e6b9c3f-8d2a-4c7e-9b1d-6a3f8c2e5d9b", + envelopeId: "3f9a2c1e-6b4d-4e2f-8a1c-9d3b5f7e2a6c", + envelopeSummary: { + status: "sent", + emailSubject: "Please DocuSign: Master Services Agreement.pdf", + emailBlurb: "Please review and sign the attached agreement at your earliest convenience.", + envelopeIdStamping: "true", + signingLocation: "online", + allowMarkup: "false", + allowReassign: "true", + createdDateTime: "2026-06-28T15:01:42.0000000Z", + sentDateTime: "2026-06-28T15:02:11.0000000Z", + statusChangedDateTime: "2026-06-28T15:02:11.0000000Z", + documentsUri: "/envelopes/3f9a2c1e-6b4d-4e2f-8a1c-9d3b5f7e2a6c/documents", + recipientsUri: "/envelopes/3f9a2c1e-6b4d-4e2f-8a1c-9d3b5f7e2a6c/recipients", + attachmentsUri: "/envelopes/3f9a2c1e-6b4d-4e2f-8a1c-9d3b5f7e2a6c/attachments", + envelopeUri: "/envelopes/3f9a2c1e-6b4d-4e2f-8a1c-9d3b5f7e2a6c", + customFieldsUri: "/envelopes/3f9a2c1e-6b4d-4e2f-8a1c-9d3b5f7e2a6c/custom_fields", + notificationUri: "/envelopes/3f9a2c1e-6b4d-4e2f-8a1c-9d3b5f7e2a6c/notification", + recipients: { + signers: [ + { + recipientId: "1", + recipientIdGuid: "5c2a8d1e-7f3b-4a6c-9e2d-8b1f5a3c7e9d", + name: "Jordan Reyes", + email: "jordan.reyes@example.com", + recipientType: "signer", + routingOrder: "1", + status: "sent", + deliveryMethod: "email", + sentDateTime: "2026-06-28T15:02:11.0000000Z", + }, + ], + }, + }, + }, + }, + docsUrl: "https://developers.docusign.com/platform/webhooks/connect/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "docusign", + providerLabel: "DocuSign", + eventType: "envelope-completed", + name: "Envelope completed", + description: + "All recipients on an envelope have finished signing and the envelope is complete.", + body: { + event: "envelope-completed", + apiVersion: "v2.1", + uri: "/restapi/v2.1/accounts/8675309a-1234-4a1b-9c1d-3e5f7a9b1c2d/envelopes/3f9a2c1e-6b4d-4e2f-8a1c-9d3b5f7e2a6c", + retryCount: 0, + configurationId: 271828, + generatedDateTime: "2026-06-28T15:11:47.9012345Z", + data: { + accountId: "8675309a-1234-4a1b-9c1d-3e5f7a9b1c2d", + userId: "1e6b9c3f-8d2a-4c7e-9b1d-6a3f8c2e5d9b", + envelopeId: "3f9a2c1e-6b4d-4e2f-8a1c-9d3b5f7e2a6c", + envelopeSummary: { + status: "completed", + emailSubject: "Please DocuSign: Master Services Agreement.pdf", + createdDateTime: "2026-06-28T15:01:42.0000000Z", + sentDateTime: "2026-06-28T15:02:11.0000000Z", + deliveredDateTime: "2026-06-28T15:05:03.0000000Z", + completedDateTime: "2026-06-28T15:11:47.0000000Z", + statusChangedDateTime: "2026-06-28T15:11:47.0000000Z", + documentsUri: "/envelopes/3f9a2c1e-6b4d-4e2f-8a1c-9d3b5f7e2a6c/documents", + recipientsUri: "/envelopes/3f9a2c1e-6b4d-4e2f-8a1c-9d3b5f7e2a6c/recipients", + certificateUri: "/envelopes/3f9a2c1e-6b4d-4e2f-8a1c-9d3b5f7e2a6c/documents/certificate", + recipients: { + signers: [ + { + recipientId: "1", + recipientIdGuid: "5c2a8d1e-7f3b-4a6c-9e2d-8b1f5a3c7e9d", + name: "Jordan Reyes", + email: "jordan.reyes@example.com", + recipientType: "signer", + routingOrder: "1", + status: "completed", + deliveryMethod: "email", + sentDateTime: "2026-06-28T15:02:11.0000000Z", + deliveredDateTime: "2026-06-28T15:05:03.0000000Z", + signedDateTime: "2026-06-28T15:11:47.0000000Z", + }, + ], + }, + }, + }, + }, + docsUrl: "https://developers.docusign.com/platform/webhooks/connect/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "docusign", + providerLabel: "DocuSign", + eventType: "envelope-declined", + name: "Envelope declined", + description: "A recipient declined to sign, which moves the whole envelope to declined.", + body: { + event: "envelope-declined", + apiVersion: "v2.1", + uri: "/restapi/v2.1/accounts/8675309a-1234-4a1b-9c1d-3e5f7a9b1c2d/envelopes/7a1d4c9e-2b6f-4d8a-9e3c-5f1b8a2d6c9e", + retryCount: 0, + configurationId: 271828, + generatedDateTime: "2026-07-02T09:47:03.1120987Z", + data: { + accountId: "8675309a-1234-4a1b-9c1d-3e5f7a9b1c2d", + userId: "1e6b9c3f-8d2a-4c7e-9b1d-6a3f8c2e5d9b", + envelopeId: "7a1d4c9e-2b6f-4d8a-9e3c-5f1b8a2d6c9e", + envelopeSummary: { + status: "declined", + emailSubject: "Please DocuSign: Contractor Agreement.pdf", + createdDateTime: "2026-07-02T09:30:00.0000000Z", + sentDateTime: "2026-07-02T09:31:04.0000000Z", + declinedDateTime: "2026-07-02T09:47:03.0000000Z", + statusChangedDateTime: "2026-07-02T09:47:03.0000000Z", + documentsUri: "/envelopes/7a1d4c9e-2b6f-4d8a-9e3c-5f1b8a2d6c9e/documents", + recipientsUri: "/envelopes/7a1d4c9e-2b6f-4d8a-9e3c-5f1b8a2d6c9e/recipients", + recipients: { + signers: [ + { + recipientId: "1", + recipientIdGuid: "9d3b5f7e-2a6c-4e8d-9b1f-5a3c7e9d2b6a", + name: "Morgan Alvarez", + email: "morgan.alvarez@example.com", + recipientType: "signer", + routingOrder: "1", + status: "declined", + deliveryMethod: "email", + sentDateTime: "2026-07-02T09:31:04.0000000Z", + declinedDateTime: "2026-07-02T09:47:03.0000000Z", + declineReason: "Terms need revision before I can sign.", + }, + ], + }, + }, + }, + }, + docsUrl: "https://developers.docusign.com/platform/webhooks/connect/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "docusign", + providerLabel: "DocuSign", + eventType: "envelope-voided", + name: "Envelope voided", + description: + "The sender voided the envelope before it was completed, invalidating it for all recipients.", + body: { + event: "envelope-voided", + apiVersion: "v2.1", + uri: "/restapi/v2.1/accounts/8675309a-1234-4a1b-9c1d-3e5f7a9b1c2d/envelopes/2e5a8c1d-6f3b-4a9e-8d2c-7b1f5a3e9c6d", + retryCount: 0, + configurationId: 271828, + generatedDateTime: "2026-07-05T18:22:39.5560321Z", + data: { + accountId: "8675309a-1234-4a1b-9c1d-3e5f7a9b1c2d", + userId: "1e6b9c3f-8d2a-4c7e-9b1d-6a3f8c2e5d9b", + envelopeId: "2e5a8c1d-6f3b-4a9e-8d2c-7b1f5a3e9c6d", + envelopeSummary: { + status: "voided", + emailSubject: "Please DocuSign: Vendor Renewal.pdf", + createdDateTime: "2026-07-04T10:15:00.0000000Z", + sentDateTime: "2026-07-04T10:16:22.0000000Z", + voidedDateTime: "2026-07-05T18:22:39.0000000Z", + statusChangedDateTime: "2026-07-05T18:22:39.0000000Z", + voidedReason: "Superseded by a corrected version of this document.", + documentsUri: "/envelopes/2e5a8c1d-6f3b-4a9e-8d2c-7b1f5a3e9c6d/documents", + recipientsUri: "/envelopes/2e5a8c1d-6f3b-4a9e-8d2c-7b1f5a3e9c6d/recipients", + recipients: { + signers: [ + { + recipientId: "1", + recipientIdGuid: "3c7e9d2b-6a5f-4c8e-9d1b-2a6f5c9e3d7b", + name: "Priya Nair", + email: "priya.nair@example.com", + recipientType: "signer", + routingOrder: "1", + status: "sent", + deliveryMethod: "email", + sentDateTime: "2026-07-04T10:16:22.0000000Z", + }, + ], + }, + }, + }, + }, + docsUrl: "https://developers.docusign.com/platform/webhooks/connect/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "docusign", + providerLabel: "DocuSign", + eventType: "recipient-completed", + name: "Recipient completed", + description: + "One recipient finished their signing action; other recipients on the envelope may still be pending.", + body: { + event: "recipient-completed", + apiVersion: "v2.1", + uri: "/restapi/v2.1/accounts/8675309a-1234-4a1b-9c1d-3e5f7a9b1c2d/envelopes/9b2d5f8a-1c6e-4d3b-8a9c-2e5f7b1d9a3c", + retryCount: 0, + configurationId: 271828, + generatedDateTime: "2026-07-08T13:05:52.7789456Z", + data: { + accountId: "8675309a-1234-4a1b-9c1d-3e5f7a9b1c2d", + userId: "1e6b9c3f-8d2a-4c7e-9b1d-6a3f8c2e5d9b", + envelopeId: "9b2d5f8a-1c6e-4d3b-8a9c-2e5f7b1d9a3c", + recipientId: "1", + envelopeSummary: { + status: "sent", + emailSubject: "Please DocuSign: Offer Letter.pdf", + createdDateTime: "2026-07-08T12:50:10.0000000Z", + sentDateTime: "2026-07-08T12:51:03.0000000Z", + statusChangedDateTime: "2026-07-08T13:05:52.0000000Z", + documentsUri: "/envelopes/9b2d5f8a-1c6e-4d3b-8a9c-2e5f7b1d9a3c/documents", + recipientsUri: "/envelopes/9b2d5f8a-1c6e-4d3b-8a9c-2e5f7b1d9a3c/recipients", + recipients: { + signers: [ + { + recipientId: "1", + recipientIdGuid: "7b1f5a3e-9c6d-4b2a-8e5f-3c9d7b1a5e2f", + name: "Sam Okafor", + email: "sam.okafor@example.com", + recipientType: "signer", + routingOrder: "1", + status: "completed", + deliveryMethod: "email", + sentDateTime: "2026-07-08T12:51:03.0000000Z", + deliveredDateTime: "2026-07-08T12:58:41.0000000Z", + signedDateTime: "2026-07-08T13:05:52.0000000Z", + }, + { + recipientId: "2", + recipientIdGuid: "5e2f7b1a-9d3c-4e8b-9a2d-6c5f1a7e3b9d", + name: "Dana Whitfield", + email: "dana.whitfield@example.com", + recipientType: "signer", + routingOrder: "2", + status: "created", + deliveryMethod: "email", + }, + ], + }, + }, + }, + }, + docsUrl: "https://developers.docusign.com/platform/webhooks/connect/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/elevenlabs.ts b/internal-packages/webhook-sources/src/handAuthored/elevenlabs.ts new file mode 100644 index 0000000000..26e6b6e43b --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/elevenlabs.ts @@ -0,0 +1,271 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * ElevenLabs Conversational AI post-call samples. No preset: ElevenLabs signs with + * `elevenlabs-signature: t=,v0=` (HMAC-SHA256 over `{timestamp}.{rawBody}`), which is close + * to but does not exactly match our `stripe` preset's header name or `v1=` value prefix. Every body is + * the thin `{ type, event_timestamp, data }` envelope; `post_call_audio` carries only three fields by + * design (no transcript/metadata/analysis). + */ +export const samples: SampleRecord[] = [ + { + provider: "elevenlabs", + providerLabel: "ElevenLabs", + eventType: "post_call_transcription", + name: "Post-call transcription (successful call)", + description: + "Full conversation data delivered after a call ends: transcript turns, call metadata, and analysis results.", + body: { + type: "post_call_transcription", + event_timestamp: 1752562811, + data: { + agent_id: "xI1AVR5jrfLZ6mSjxgTS", + agent_name: "Support Agent", + conversation_id: "conv_4f9b2e7c1a8d6f3e0b5c", + status: "done", + user_id: "user_8a2d5f1c9e7b3a6d", + authorization_method: "signed_url", + transcript: [ + { + role: "agent", + message: "Hi, thanks for calling. How can I help you today?", + tool_calls: null, + tool_results: null, + feedback: null, + time_in_call_secs: 0, + conversation_turn_metrics: null, + }, + { + role: "user", + message: "I need to check the status of my last order.", + tool_calls: null, + tool_results: null, + feedback: null, + time_in_call_secs: 4, + conversation_turn_metrics: null, + }, + { + role: "agent", + message: "Your order shipped yesterday and should arrive by Friday.", + tool_calls: null, + tool_results: null, + feedback: null, + time_in_call_secs: 9, + conversation_turn_metrics: null, + }, + ], + metadata: { + start_time_unix_secs: 1752562780, + call_duration_secs: 31, + cost: 412, + deletion_settings: { + deletion_time_unix_secs: null, + deleted_logs_at_time_unix_secs: null, + deleted_audio_at_time_unix_secs: null, + deleted_transcript_at_time_unix_secs: null, + delete_transcript_and_pii: false, + delete_audio: false, + }, + feedback: { + overall_score: null, + likes: 0, + dislikes: 0, + }, + authorization_method: "signed_url", + charging: { + dev_discount: false, + }, + termination_reason: "the conversation was ended by the user", + }, + analysis: { + evaluation_criteria_results: {}, + data_collection_results: {}, + call_successful: "success", + transcript_summary: + "Caller asked for their order status; agent confirmed shipping and ETA.", + }, + conversation_initiation_client_data: { + dynamic_variables: { + customer_name: "Priya Nair", + }, + }, + }, + }, + docsUrl: "https://elevenlabs.io/docs/eleven-agents/workflows/post-call-webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "elevenlabs", + providerLabel: "ElevenLabs", + eventType: "post_call_transcription", + name: "Post-call transcription (tool call, evaluation failed)", + description: + "A transcript where the agent invoked a tool mid-call and the post-call evaluation criteria did not pass.", + body: { + type: "post_call_transcription", + event_timestamp: 1752563912, + data: { + agent_id: "xI1AVR5jrfLZ6mSjxgTS", + agent_name: "Booking Agent", + conversation_id: "conv_9c3a7e1f5b2d8c4a6f0e", + status: "done", + user_id: "user_1b6e9a3f7c2d5b8e", + authorization_method: "public", + transcript: [ + { + role: "agent", + message: "Let me look up available slots for you.", + tool_calls: [ + { + tool_name: "check_availability", + tool_call_id: "tool_call_7d2e4c1b", + params_as_json: '{"date":"2026-07-20"}', + }, + ], + tool_results: null, + feedback: null, + time_in_call_secs: 6, + conversation_turn_metrics: null, + }, + { + role: "agent", + message: "There are no slots left on that date, unfortunately.", + tool_calls: null, + tool_results: [ + { + tool_name: "check_availability", + tool_call_id: "tool_call_7d2e4c1b", + result_value: '{"slots":[]}', + }, + ], + feedback: null, + time_in_call_secs: 8, + conversation_turn_metrics: null, + }, + ], + metadata: { + start_time_unix_secs: 1752563890, + call_duration_secs: 22, + cost: 298, + deletion_settings: { + deletion_time_unix_secs: null, + deleted_logs_at_time_unix_secs: null, + deleted_audio_at_time_unix_secs: null, + deleted_transcript_at_time_unix_secs: null, + delete_transcript_and_pii: false, + delete_audio: false, + }, + feedback: { + overall_score: 2, + likes: 0, + dislikes: 1, + }, + authorization_method: "public", + charging: { + dev_discount: false, + }, + termination_reason: "client disconnected", + }, + analysis: { + evaluation_criteria_results: { + resolved_user_request: { + result: "failure", + rationale: "No booking slot was found or offered as an alternative.", + }, + }, + data_collection_results: {}, + call_successful: "failure", + transcript_summary: + "Caller wanted to book a slot; none were available and no alternative was offered.", + }, + conversation_initiation_client_data: { + dynamic_variables: { + requested_date: "2026-07-20", + }, + }, + }, + }, + docsUrl: "https://elevenlabs.io/docs/eleven-agents/workflows/post-call-webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "elevenlabs", + providerLabel: "ElevenLabs", + eventType: "post_call_audio", + name: "Post-call audio", + description: + "Minimal payload delivered separately from transcription webhooks, carrying only the base64-encoded MP3 of the full call. No transcript, metadata, or analysis fields are included.", + body: { + type: "post_call_audio", + event_timestamp: 1752562819, + data: { + agent_id: "xI1AVR5jrfLZ6mSjxgTS", + conversation_id: "conv_4f9b2e7c1a8d6f3e0b5c", + full_audio: + "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4LjI5LjEwMAAAAAAAAAAAAAAA//sample-not-real-audio-bytes", + }, + }, + docsUrl: "https://elevenlabs.io/docs/eleven-agents/workflows/post-call-webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "elevenlabs", + providerLabel: "ElevenLabs", + eventType: "call_initiation_failure", + name: "Call initiation failure (SIP, busy)", + description: + "The outbound call never connected because the SIP trunk reported the line as busy. `data.metadata.body` carries the SIP-specific failure detail.", + body: { + type: "call_initiation_failure", + event_timestamp: 1752564001, + data: { + agent_id: "xI1AVR5jrfLZ6mSjxgTS", + conversation_id: "conv_2e8c5a1f9b3d7c4e6a0f", + failure_reason: "busy", + metadata: { + type: "sip", + body: { + from_number: 15550001234, + to_number: 15550005678, + sip_status_code: 486, + error_reason: "Busy Here", + call_sid: "sip_call_3a7e1c9d5b2f", + sip_status: "486 Busy Here", + twirp_code: "unavailable", + }, + }, + }, + }, + docsUrl: "https://elevenlabs.io/docs/eleven-agents/workflows/post-call-webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "elevenlabs", + providerLabel: "ElevenLabs", + eventType: "call_initiation_failure", + name: "Call initiation failure (Twilio, no answer)", + description: + "The outbound call was routed through Twilio and rang without being answered. `data.metadata.body` follows Twilio's StatusCallback field names.", + body: { + type: "call_initiation_failure", + event_timestamp: 1752564205, + data: { + agent_id: "xI1AVR5jrfLZ6mSjxgTS", + conversation_id: "conv_7b1d4f9a2c6e8b3d5f0a", + failure_reason: "no-answer", + metadata: { + type: "twilio", + body: { + Called: "+15550005678", + Caller: "+15550001234", + CallSid: "CAfake1234567890abcdef1234567890", + CallStatus: "no-answer", + SipResponseCode: "480", + }, + }, + }, + }, + docsUrl: "https://elevenlabs.io/docs/eleven-agents/workflows/post-call-webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/gitlab.ts b/internal-packages/webhook-sources/src/handAuthored/gitlab.ts new file mode 100644 index 0000000000..1d703e57bc --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/gitlab.ts @@ -0,0 +1,270 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * GitLab's default webhook auth is a plaintext `X-Gitlab-Token` shared secret (no signature at all), + * which our hookdeck-sourced samples carry via extraHeaders and which does not map to any preset. + * GitLab also offers an opt-in "signing token" mode that follows the Standard Webhooks spec exactly: + * `webhook-id` / `webhook-timestamp` / `webhook-signature` headers, HMAC-SHA256 base64 over + * `{id}.{timestamp}.{body}`, secret prefixed `whsec_`. That mode matches our `svix` preset, so these + * samples are tagged `presetId: "svix"` and cover event types not already present in the hookdeck set + * (Merge Request Hook, Pipeline Hook, Job Hook). + */ +export const samples: SampleRecord[] = [ + { + provider: "gitlab", + providerLabel: "GitLab", + presetId: "svix", + eventType: "Merge Request Hook", + name: "Merge request opened", + description: "A merge request is opened against the target branch.", + body: { + object_kind: "merge_request", + event_type: "merge_request", + user: { + id: 1, + name: "Alex Garcia", + username: "agarcia", + avatar_url: "https://www.gravatar.com/avatar/1a29da0ccd0994?s=80&d=identicon", + email: "agarcia@example.com", + }, + project: { + id: 2, + name: "Flight Management", + description: "Flight management application for tracking aircraft status.", + web_url: "https://gitlab.example.com/flightjs/flight-management", + avatar_url: null, + git_ssh_url: "ssh://git@gitlab.example.com/flightjs/flight-management.git", + git_http_url: "https://gitlab.example.com/flightjs/flight-management.git", + namespace: "Flightjs", + visibility_level: 0, + path_with_namespace: "flightjs/flight-management", + default_branch: "main", + ci_config_path: null, + }, + object_attributes: { + id: 93, + iid: 16, + author_id: 1, + created_at: "2026-06-30 05:56:22 UTC", + updated_at: "2026-06-30 05:56:25 UTC", + title: "Add input validation to booking form", + description: "This merge request adds input validation to the booking form.", + draft: false, + state: "opened", + merge_status: "checking", + detailed_merge_status: "checking", + source_branch: "feature/booking-validation", + source_project_id: 2, + target_branch: "main", + target_project_id: 2, + target_branch_protected: true, + merge_commit_sha: null, + merged_at: null, + merge_error: null, + merge_when_pipeline_succeeds: false, + milestone_id: 8, + assignee_ids: [1], + reviewer_ids: [25], + labels: [{ id: 19, title: "enhancement", color: "#adb21a" }], + last_commit: { + id: "e59094b8de0f2f91abbe4760a52d9137260252d8", + message: "Add email format validation", + timestamp: "2026-06-30T05:01:10+00:00", + url: "https://gitlab.example.com/flightjs/flight-management/-/commit/e59094b8de0f2f91abbe4760a52d9137260252d8", + author: { name: "Alex Garcia", email: "agarcia@example.com" }, + }, + url: "https://gitlab.example.com/flightjs/flight-management/-/merge_requests/16", + action: "open", + actioned_at: "2026-06-30 05:56:26 UTC", + }, + labels: [{ id: 19, title: "enhancement", color: "#adb21a" }], + changes: { + merge_status: { previous: "preparing", current: "checking" }, + updated_at: { previous: "2026-06-30 05:56:22 UTC", current: "2026-06-30 05:56:25 UTC" }, + }, + assignees: [ + { id: 1, name: "Alex Garcia", username: "agarcia", email: "agarcia@example.com" }, + ], + reviewers: [ + { + id: 25, + name: "Sidney Jones", + username: "sjones", + state: "unreviewed", + re_requested: false, + }, + ], + }, + extraHeaders: { + "x-gitlab-event": "Merge Request Hook", + "x-gitlab-instance": "https://gitlab.example.com", + }, + docsUrl: + "https://docs.gitlab.com/user/project/integrations/webhook_events/#merge-request-events", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "gitlab", + providerLabel: "GitLab", + presetId: "svix", + eventType: "Pipeline Hook", + name: "Pipeline succeeded", + description: "A CI/CD pipeline finishes with an overall success status.", + body: { + object_kind: "pipeline", + object_attributes: { + id: 31, + iid: 3, + name: "Pipeline for branch: main", + ref: "main", + tag: false, + sha: "bcbb5ec396a2c0f828686f14fac9b80b780504f2", + before_sha: "959e04d7c7a30600b7a35cb4a53db4b0ae0f0433", + source: "push", + status: "success", + detailed_status: "passed", + stages: ["build", "test", "deploy"], + created_at: "2026-06-30 15:23:28 UTC", + finished_at: "2026-06-30 15:26:29 UTC", + duration: 63, + queued_duration: 10, + url: "https://gitlab.example.com/flightjs/flight-management/-/pipelines/31", + }, + merge_request: null, + user: { + id: 1, + name: "Alex Garcia", + username: "agarcia", + avatar_url: "https://www.gravatar.com/avatar/1a29da0ccd0994?s=80&d=identicon", + email: "agarcia@example.com", + }, + project: { + id: 2, + name: "Flight Management", + web_url: "https://gitlab.example.com/flightjs/flight-management", + namespace: "Flightjs", + visibility_level: 0, + path_with_namespace: "flightjs/flight-management", + default_branch: "main", + }, + commit: { + id: "bcbb5ec396a2c0f828686f14fac9b80b780504f2", + message: "Add email format validation\n", + title: "Add email format validation", + timestamp: "2026-06-30T15:23:21+00:00", + url: "https://gitlab.example.com/flightjs/flight-management/-/commit/bcbb5ec396a2c0f828686f14fac9b80b780504f2", + author: { name: "Alex Garcia", email: "agarcia@example.com" }, + }, + builds: [ + { + id: 380, + stage: "deploy", + name: "production", + status: "success", + created_at: "2026-06-30 15:23:28 UTC", + started_at: "2026-06-30 15:25:40 UTC", + finished_at: "2026-06-30 15:26:29 UTC", + duration: 49.0, + when: "manual", + manual: true, + allow_failure: false, + }, + { + id: 377, + stage: "test", + name: "test-image", + status: "success", + created_at: "2026-06-30 15:23:28 UTC", + started_at: "2026-06-30 15:24:12 UTC", + finished_at: "2026-06-30 15:24:29 UTC", + duration: 17.0, + when: "on_success", + manual: false, + allow_failure: false, + }, + ], + }, + extraHeaders: { + "x-gitlab-event": "Pipeline Hook", + "x-gitlab-instance": "https://gitlab.example.com", + }, + docsUrl: "https://docs.gitlab.com/user/project/integrations/webhook_events/#pipeline-events", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "gitlab", + providerLabel: "GitLab", + presetId: "svix", + eventType: "Job Hook", + name: "Job created", + description: "An individual CI/CD job within a pipeline changes state.", + body: { + object_kind: "build", + ref: "main", + tag: false, + before_sha: "2293ada6b400935a1378653304eaf6221e0fdb8f", + sha: "2293ada6b400935a1378653304eaf6221e0fdb8f", + retries_count: 0, + build_id: 1977, + build_name: "test-image", + build_stage: "test", + build_status: "created", + build_created_at: "2026-06-30T02:41:37.886Z", + build_started_at: null, + build_finished_at: null, + build_duration: null, + build_allow_failure: false, + build_failure_reason: "unknown_failure", + pipeline_id: 2366, + runner: { + id: 380987, + description: "shared-runner-6.gitlab.example.com", + runner_type: "instance_type", + active: true, + is_shared: true, + tags: ["linux", "docker"], + }, + project_id: 2, + project_name: "flightjs/flight-management", + user: { + id: 1, + name: "Alex Garcia", + username: "agarcia", + email: "agarcia@example.com", + }, + commit: { + id: 2366, + name: "Build pipeline", + sha: "2293ada6b400935a1378653304eaf6221e0fdb8f", + message: "Add email format validation\n", + author_name: "Alex Garcia", + author_email: "agarcia@example.com", + status: "created", + duration: null, + }, + repository: { + name: "flight-management", + url: "https://gitlab.example.com/flightjs/flight-management", + git_ssh_url: "ssh://git@gitlab.example.com/flightjs/flight-management.git", + git_http_url: "https://gitlab.example.com/flightjs/flight-management.git", + visibility_level: 0, + }, + project: { + id: 2, + name: "Flight Management", + web_url: "https://gitlab.example.com/flightjs/flight-management", + namespace: "Flightjs", + visibility_level: 0, + path_with_namespace: "flightjs/flight-management", + default_branch: "main", + }, + environment: null, + }, + extraHeaders: { + "x-gitlab-event": "Job Hook", + "x-gitlab-instance": "https://gitlab.example.com", + }, + docsUrl: "https://docs.gitlab.com/user/project/integrations/webhook_events/#job-events", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/index.ts b/internal-packages/webhook-sources/src/handAuthored/index.ts new file mode 100644 index 0000000000..5658e15017 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/index.ts @@ -0,0 +1,85 @@ +/** GENERATED by catalog/build-aggregates.ts. Do not edit by hand. */ +import { type SampleRecord } from "../sampleRecord.js"; +import { samples as s_anthropic } from "./anthropic.js"; +import { samples as s_assemblyai } from "./assemblyai.js"; +import { samples as s_attio } from "./attio.js"; +import { samples as s_auth0 } from "./auth0.js"; +import { samples as s_brex } from "./brex.js"; +import { samples as s_cal_com } from "./cal-com.js"; +import { samples as s_calendly } from "./calendly.js"; +import { samples as s_clerk } from "./clerk.js"; +import { samples as s_close_crm } from "./close-crm.js"; +import { samples as s_deepgram } from "./deepgram.js"; +import { samples as s_discord } from "./discord.js"; +import { samples as s_docusign } from "./docusign.js"; +import { samples as s_elevenlabs } from "./elevenlabs.js"; +import { samples as s_gitlab } from "./gitlab.js"; +import { samples as s_intercom } from "./intercom.js"; +import { samples as s_jira } from "./jira.js"; +import { samples as s_linear } from "./linear.js"; +import { samples as s_notion } from "./notion.js"; +import { samples as s_openai } from "./openai.js"; +import { samples as s_pagerduty } from "./pagerduty.js"; +import { samples as s_plaid } from "./plaid.js"; +import { samples as s_postmark } from "./postmark.js"; +import { samples as s_recall_ai } from "./recall-ai.js"; +import { samples as s_replicate } from "./replicate.js"; +import { samples as s_resend } from "./resend.js"; +import { samples as s_retell } from "./retell.js"; +import { samples as s_sendgrid } from "./sendgrid.js"; +import { samples as s_sentry } from "./sentry.js"; +import { samples as s_slack } from "./slack.js"; +import { samples as s_square } from "./square.js"; +import { samples as s_supabase } from "./supabase.js"; +import { samples as s_telegram } from "./telegram.js"; +import { samples as s_twilio } from "./twilio.js"; +import { samples as s_typeform } from "./typeform.js"; +import { samples as s_vapi } from "./vapi.js"; +import { samples as s_vercel } from "./vercel.js"; +import { samples as s_whatsapp } from "./whatsapp.js"; +import { samples as s_workos } from "./workos.js"; +import { samples as s_zendesk } from "./zendesk.js"; +import { samples as s_zoom } from "./zoom.js"; + +export const handAuthoredSamples: SampleRecord[] = [ + ...s_anthropic, + ...s_assemblyai, + ...s_attio, + ...s_auth0, + ...s_brex, + ...s_cal_com, + ...s_calendly, + ...s_clerk, + ...s_close_crm, + ...s_deepgram, + ...s_discord, + ...s_docusign, + ...s_elevenlabs, + ...s_gitlab, + ...s_intercom, + ...s_jira, + ...s_linear, + ...s_notion, + ...s_openai, + ...s_pagerduty, + ...s_plaid, + ...s_postmark, + ...s_recall_ai, + ...s_replicate, + ...s_resend, + ...s_retell, + ...s_sendgrid, + ...s_sentry, + ...s_slack, + ...s_square, + ...s_supabase, + ...s_telegram, + ...s_twilio, + ...s_typeform, + ...s_vapi, + ...s_vercel, + ...s_whatsapp, + ...s_workos, + ...s_zendesk, + ...s_zoom, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/intercom.ts b/internal-packages/webhook-sources/src/handAuthored/intercom.ts new file mode 100644 index 0000000000..c4d3923485 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/intercom.ts @@ -0,0 +1,388 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Intercom webhook notification samples. Every delivery shares the same envelope + * (`type: "notification_event"`, `topic`, `app_id`, `delivery_attempts`, `first_sent_at`, + * `data.item`); `eventType` mirrors `topic`, the real discriminant. No preset applies (Intercom + * signs with HMAC-SHA1 over `X-Hub-Signature`, not one of our supported schemes), so no + * `presetId` is set. + */ +export const samples: SampleRecord[] = [ + { + provider: "intercom", + providerLabel: "Intercom", + eventType: "conversation.user.created", + name: "Conversation created by user", + description: "A contact started a new conversation from the Messenger.", + body: { + type: "notification_event", + topic: "conversation.user.created", + id: "notif_5f27a9e0-0e33-4a1e-8f7c-2b6d4a9c11f0", + app_id: "a86dr8yl", + created_at: 1781524800, + delivery_attempts: 1, + first_sent_at: 1781524800, + data: { + item: { + type: "conversation", + id: "195208945002873", + created_at: 1781524800, + updated_at: 1781524800, + waiting_since: 1781524800, + snoozed_until: null, + source: { + type: "conversation", + id: "403982210", + delivered_as: "customer_initiated", + subject: "", + body: "

Hi, I'm having trouble connecting my Slack workspace to notifications.

", + author: { + type: "user", + id: "6620fa1e8f2b3c4d5e6f7890", + name: "Priya Desai", + email: "priya@northwind.example", + }, + attachments: [], + url: null, + redacted: false, + }, + contacts: { + type: "contact.list", + contacts: [{ type: "contact", id: "6620fa1e8f2b3c4d5e6f7890" }], + }, + teammates: null, + admin_assignee_id: null, + team_assignee_id: "5017691", + open: true, + state: "open", + read: false, + tags: { type: "tag.list", tags: [] }, + priority: "not_priority", + conversation_rating: null, + statistics: { + time_to_assignment: null, + time_to_admin_reply: null, + time_to_first_close: null, + time_to_last_close: null, + median_time_to_reply: null, + first_contact_reply_at: 1781524800, + first_assignment_at: null, + first_admin_reply_at: null, + first_close_at: null, + last_assignment_at: null, + last_contact_reply_at: 1781524800, + last_admin_reply_at: null, + last_close_at: null, + count_reopens: 0, + count_assignments: 0, + count_conversation_parts: 0, + }, + conversation_parts: { + type: "conversation_part.list", + conversation_parts: [], + total_count: 0, + }, + }, + }, + }, + docsUrl: "https://developers.intercom.com/docs/references/webhooks/webhook-models", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "intercom", + providerLabel: "Intercom", + eventType: "conversation.user.replied", + name: "User replied to a conversation", + description: "The contact who opened the conversation sent a follow-up reply.", + body: { + type: "notification_event", + topic: "conversation.user.replied", + id: "notif_8a41c6d2-4b8e-4f2a-9c3d-7e0f5a8b2d61", + app_id: "a86dr8yl", + created_at: 1781528440, + delivery_attempts: 1, + first_sent_at: 1781528440, + data: { + item: { + type: "conversation", + id: "195208945002873", + created_at: 1781524800, + updated_at: 1781528440, + waiting_since: 1781528440, + snoozed_until: null, + source: { + type: "conversation", + id: "403982210", + delivered_as: "customer_initiated", + subject: "", + body: "

Hi, I'm having trouble connecting my Slack workspace to notifications.

", + author: { + type: "user", + id: "6620fa1e8f2b3c4d5e6f7890", + name: "Priya Desai", + email: "priya@northwind.example", + }, + attachments: [], + url: null, + redacted: false, + }, + contacts: { + type: "contact.list", + contacts: [{ type: "contact", id: "6620fa1e8f2b3c4d5e6f7890" }], + }, + teammates: null, + admin_assignee_id: null, + team_assignee_id: "5017691", + open: true, + state: "open", + read: false, + tags: { type: "tag.list", tags: [] }, + priority: "not_priority", + conversation_rating: null, + statistics: { + time_to_assignment: null, + time_to_admin_reply: null, + time_to_first_close: null, + time_to_last_close: null, + median_time_to_reply: null, + first_contact_reply_at: 1781524800, + first_assignment_at: null, + first_admin_reply_at: null, + first_close_at: null, + last_assignment_at: null, + last_contact_reply_at: 1781528440, + last_admin_reply_at: null, + last_close_at: null, + count_reopens: 0, + count_assignments: 0, + count_conversation_parts: 1, + }, + conversation_parts: { + type: "conversation_part.list", + conversation_parts: [ + { + type: "conversation_part", + id: "403982298", + part_type: "comment", + body: "

Just to add, it fails right after I click Authorize.

", + created_at: 1781528440, + updated_at: 1781528440, + notified_at: 1781528440, + assigned_to: null, + author: { + type: "user", + id: "6620fa1e8f2b3c4d5e6f7890", + name: "Priya Desai", + email: "priya@northwind.example", + }, + attachments: [], + external_id: null, + redacted: false, + }, + ], + total_count: 1, + }, + }, + }, + }, + docsUrl: "https://developers.intercom.com/docs/references/webhooks/webhook-models", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "intercom", + providerLabel: "Intercom", + eventType: "conversation.admin.replied", + name: "Admin replied to a conversation", + description: "A teammate answered from the Intercom inbox.", + body: { + type: "notification_event", + topic: "conversation.admin.replied", + id: "notif_1c9e7b53-2d6a-49f0-8e5c-3a7d9f1b6c04", + app_id: "a86dr8yl", + created_at: 1781529100, + delivery_attempts: 1, + first_sent_at: 1781529100, + data: { + item: { + type: "conversation", + id: "195208945002873", + created_at: 1781524800, + updated_at: 1781529100, + waiting_since: null, + snoozed_until: null, + source: { + type: "conversation", + id: "403982210", + delivered_as: "customer_initiated", + subject: "", + body: "

Hi, I'm having trouble connecting my Slack workspace to notifications.

", + author: { + type: "user", + id: "6620fa1e8f2b3c4d5e6f7890", + name: "Priya Desai", + email: "priya@northwind.example", + }, + attachments: [], + url: null, + redacted: false, + }, + contacts: { + type: "contact.list", + contacts: [{ type: "contact", id: "6620fa1e8f2b3c4d5e6f7890" }], + }, + teammates: { type: "admin.list", admins: [{ id: "814860", type: "admin" }] }, + admin_assignee_id: 814860, + team_assignee_id: "5017691", + open: true, + state: "open", + read: true, + tags: { type: "tag.list", tags: [] }, + priority: "not_priority", + conversation_rating: null, + statistics: { + time_to_assignment: 300, + time_to_admin_reply: 4300, + time_to_first_close: null, + time_to_last_close: null, + median_time_to_reply: 4300, + first_contact_reply_at: 1781524800, + first_assignment_at: 1781525100, + first_admin_reply_at: 1781529100, + first_close_at: null, + last_assignment_at: 1781525100, + last_contact_reply_at: 1781528440, + last_admin_reply_at: 1781529100, + last_close_at: null, + count_reopens: 0, + count_assignments: 1, + count_conversation_parts: 2, + }, + conversation_parts: { + type: "conversation_part.list", + conversation_parts: [ + { + type: "conversation_part", + id: "403982355", + part_type: "comment", + body: "

Thanks for the extra detail, can you confirm which browser you're using?

", + created_at: 1781529100, + updated_at: 1781529100, + notified_at: 1781529100, + assigned_to: null, + author: { + type: "admin", + id: "814860", + name: "Jordan Mills", + email: "jordan@acme.example", + }, + attachments: [], + external_id: null, + redacted: false, + }, + ], + total_count: 2, + }, + }, + }, + }, + docsUrl: "https://developers.intercom.com/docs/references/webhooks/webhook-models", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "intercom", + providerLabel: "Intercom", + eventType: "contact.created", + name: "Contact created", + description: "A new contact was created in the workspace.", + body: { + type: "notification_event", + topic: "contact.created", + id: "notif_a3d81f6e-9c02-4b7a-b1e4-5f8d0c2a6b93", + app_id: "a86dr8yl", + created_at: 1781524800, + delivery_attempts: 1, + first_sent_at: 1781524800, + data: { + item: { + type: "contact", + id: "6620fa1e8f2b3c4d5e6f7890", + workspace_id: "a86dr8yl", + external_id: "cust_48213", + role: "user", + email: "priya@northwind.example", + email_domain: "northwind.example", + phone: null, + name: "Priya Desai", + avatar: null, + owner_id: null, + social_profiles: { type: "list", data: [] }, + has_hard_bounced: false, + marked_email_as_spam: false, + unsubscribed_from_emails: false, + created_at: 1781524800, + updated_at: 1781524800, + signed_up_at: 1781524800, + last_seen_at: 1781524800, + last_replied_at: null, + last_contacted_at: null, + last_email_opened_at: null, + last_email_clicked_at: null, + language_override: null, + browser: "chrome", + browser_version: "126.0.0.0", + browser_language: "en", + os: "Mac OS X", + location: { + type: "location", + country: "United States", + region: "California", + city: "San Francisco", + }, + custom_attributes: {}, + tags: { + type: "list", + data: [], + url: "/contacts/6620fa1e8f2b3c4d5e6f7890/tags", + total_count: 0, + has_more: false, + }, + companies: { + type: "list", + data: [], + url: "/contacts/6620fa1e8f2b3c4d5e6f7890/companies", + total_count: 0, + has_more: false, + }, + }, + }, + }, + docsUrl: "https://developers.intercom.com/docs/references/webhooks/webhook-models", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "intercom", + providerLabel: "Intercom", + eventType: "contact.deleted", + name: "Contact deleted", + description: "A contact was permanently deleted from the workspace.", + body: { + type: "notification_event", + topic: "contact.deleted", + id: "notif_6e2b4d81-7a5f-4c3e-9d0a-1f6b8c2e4a70", + app_id: "a86dr8yl", + created_at: 1781612900, + delivery_attempts: 1, + first_sent_at: 1781612900, + data: { + item: { + type: "contact", + id: "5ba682d23d7cf92bef87bfd4", + external_id: "cust_10248", + deleted: true, + }, + }, + }, + docsUrl: "https://developers.intercom.com/docs/references/webhooks/webhook-models", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/jira.ts b/internal-packages/webhook-sources/src/handAuthored/jira.ts new file mode 100644 index 0000000000..d0a3a5d55e --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/jira.ts @@ -0,0 +1,235 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Jira Cloud samples. Jira signs webhooks with an `X-Hub-Signature` header (no `-256`), formatted as + * `sha256=` HMAC over the raw body - this does not match our `github` preset (which requires the + * `x-hub-signature-256` header name), so these ship without a `presetId`. + */ +export const samples: SampleRecord[] = [ + { + provider: "jira", + providerLabel: "Jira", + eventType: "jira:issue_created", + name: "Issue created", + description: "A new issue was created in a Jira Cloud project.", + body: { + timestamp: 1784065200000, + webhookEvent: "jira:issue_created", + issue_event_type_name: "issue_created", + user: { + self: "https://example.atlassian.net/rest/api/2/user?accountId=5f8a1b2c3d4e5f0a1b2c3d4e", + accountId: "5f8a1b2c3d4e5f0a1b2c3d4e", + accountType: "atlassian", + displayName: "Jordan Lee", + active: true, + timeZone: "America/New_York", + }, + issue: { + id: "10142", + self: "https://example.atlassian.net/rest/api/2/issue/10142", + key: "ENG-482", + fields: { + summary: "Webhook retries silently drop after 3 attempts", + created: "2026-07-13T09:20:00.000-0400", + issuetype: { id: "10001", name: "Bug" }, + status: { id: "10000", name: "To Do" }, + priority: { id: "3", name: "Medium" }, + project: { id: "10005", key: "ENG", name: "Engineering" }, + labels: ["webhooks", "reliability"], + assignee: null, + reporter: { + accountId: "5f8a1b2c3d4e5f0a1b2c3d4e", + displayName: "Jordan Lee", + }, + }, + }, + }, + docsUrl: "https://developer.atlassian.com/cloud/jira/platform/webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "jira", + providerLabel: "Jira", + eventType: "jira:issue_updated", + name: "Issue updated", + description: "An issue's status and priority changed; `changelog.items` lists the field diffs.", + body: { + timestamp: 1784068800000, + webhookEvent: "jira:issue_updated", + issue_event_type_name: "issue_generic", + user: { + self: "https://example.atlassian.net/rest/api/2/user?accountId=6a9b2c3d4e5f6a1b2c3d4e5f", + accountId: "6a9b2c3d4e5f6a1b2c3d4e5f", + accountType: "atlassian", + displayName: "Priya Nair", + active: true, + timeZone: "Europe/London", + }, + issue: { + id: "10142", + self: "https://example.atlassian.net/rest/api/2/issue/10142", + key: "ENG-482", + fields: { + summary: "Webhook retries silently drop after 3 attempts", + created: "2026-07-13T09:20:00.000-0400", + issuetype: { id: "10001", name: "Bug" }, + status: { id: "10001", name: "In Progress" }, + priority: { id: "2", name: "High" }, + project: { id: "10005", key: "ENG", name: "Engineering" }, + assignee: { + accountId: "6a9b2c3d4e5f6a1b2c3d4e5f", + displayName: "Priya Nair", + }, + }, + }, + changelog: { + id: "10391", + items: [ + { + field: "status", + fieldtype: "jira", + from: "10000", + fromString: "To Do", + to: "10001", + toString: "In Progress", + }, + { + field: "priority", + fieldtype: "jira", + from: "3", + fromString: "Medium", + to: "2", + toString: "High", + }, + ], + }, + }, + docsUrl: "https://developer.atlassian.com/cloud/jira/platform/webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "jira", + providerLabel: "Jira", + eventType: "jira:issue_deleted", + name: "Issue deleted", + description: "An issue was permanently deleted from a Jira Cloud project.", + body: { + timestamp: 1784072400000, + webhookEvent: "jira:issue_deleted", + issue_event_type_name: "issue_deleted", + user: { + self: "https://example.atlassian.net/rest/api/2/user?accountId=6a9b2c3d4e5f6a1b2c3d4e5f", + accountId: "6a9b2c3d4e5f6a1b2c3d4e5f", + accountType: "atlassian", + displayName: "Priya Nair", + active: true, + timeZone: "Europe/London", + }, + issue: { + id: "10098", + self: "https://example.atlassian.net/rest/api/2/issue/10098", + key: "ENG-417", + fields: { + summary: "Duplicate of ENG-482", + issuetype: { id: "10001", name: "Bug" }, + status: { id: "10002", name: "Done" }, + project: { id: "10005", key: "ENG", name: "Engineering" }, + }, + }, + }, + docsUrl: "https://developer.atlassian.com/cloud/jira/platform/webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "jira", + providerLabel: "Jira", + eventType: "comment_created", + name: "Comment created", + description: "A comment was added to an issue.", + body: { + timestamp: 1784076000000, + webhookEvent: "comment_created", + issue_event_type_name: "comment_created", + user: { + self: "https://example.atlassian.net/rest/api/2/user?accountId=7b0c3d4e5f6a1b2c3d4e5f6a", + accountId: "7b0c3d4e5f6a1b2c3d4e5f6a", + accountType: "atlassian", + displayName: "Sam Okafor", + active: true, + timeZone: "Africa/Lagos", + }, + issue: { + id: "10142", + self: "https://example.atlassian.net/rest/api/2/issue/10142", + key: "ENG-482", + fields: { + summary: "Webhook retries silently drop after 3 attempts", + issuetype: { id: "10001", name: "Bug" }, + status: { id: "10001", name: "In Progress" }, + project: { id: "10005", key: "ENG", name: "Engineering" }, + }, + }, + comment: { + self: "https://example.atlassian.net/rest/api/3/issue/ENG-482/comment/48213", + id: "48213", + author: { + accountId: "7b0c3d4e5f6a1b2c3d4e5f6a", + displayName: "Sam Okafor", + }, + body: "I can reproduce this - the retry counter resets on redeploy, not per delivery.", + created: "2026-07-13T11:20:00.000-0400", + updated: "2026-07-13T11:20:00.000-0400", + }, + }, + docsUrl: "https://developer.atlassian.com/cloud/jira/platform/webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "jira", + providerLabel: "Jira", + eventType: "comment_updated", + name: "Comment updated", + description: "An existing comment was edited; `updateAuthor` and `updated` reflect the edit.", + body: { + timestamp: 1784079600000, + webhookEvent: "comment_updated", + issue_event_type_name: "comment_updated", + user: { + self: "https://example.atlassian.net/rest/api/2/user?accountId=7b0c3d4e5f6a1b2c3d4e5f6a", + accountId: "7b0c3d4e5f6a1b2c3d4e5f6a", + accountType: "atlassian", + displayName: "Sam Okafor", + active: true, + timeZone: "Africa/Lagos", + }, + issue: { + id: "10142", + self: "https://example.atlassian.net/rest/api/2/issue/10142", + key: "ENG-482", + fields: { + summary: "Webhook retries silently drop after 3 attempts", + issuetype: { id: "10001", name: "Bug" }, + status: { id: "10001", name: "In Progress" }, + project: { id: "10005", key: "ENG", name: "Engineering" }, + }, + }, + comment: { + self: "https://example.atlassian.net/rest/api/3/issue/ENG-482/comment/48213", + id: "48213", + author: { + accountId: "7b0c3d4e5f6a1b2c3d4e5f6a", + displayName: "Sam Okafor", + }, + updateAuthor: { + accountId: "7b0c3d4e5f6a1b2c3d4e5f6a", + displayName: "Sam Okafor", + }, + body: "I can reproduce this - the retry counter resets on redeploy, not per delivery. Filed as ENG-483.", + created: "2026-07-13T11:20:00.000-0400", + updated: "2026-07-13T12:05:00.000-0400", + }, + }, + docsUrl: "https://developer.atlassian.com/cloud/jira/platform/webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/linear.ts b/internal-packages/webhook-sources/src/handAuthored/linear.ts new file mode 100644 index 0000000000..4ecc68af6e --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/linear.ts @@ -0,0 +1,197 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Linear samples. Linear signs webhooks with a custom `Linear-Signature` HMAC-SHA256 scheme (not one + * of our presets), so no `presetId` is set on any sample here. `eventType` joins the body's top-level + * `type` and `action` fields (e.g. "Issue.create") since `type` alone ("Issue") doesn't distinguish + * create/update/remove. + */ +export const samples: SampleRecord[] = [ + { + provider: "linear", + providerLabel: "Linear", + eventType: "Issue.create", + name: "Issue created", + description: "A new issue was created on a team.", + body: { + action: "create", + type: "Issue", + createdAt: "2026-07-01T14:32:18.084Z", + webhookTimestamp: 1751380338084, + webhookId: "3e9c1b2a-8f47-4c6d-9a1e-7b5d2c4f8e91", + organizationId: "b6a1d4e2-9c3f-4a7b-8e2d-1f6c9a4b7d3e", + actor: { + id: "d4f8a2c1-6e9b-4d3a-8f1c-2b7e5a9d4c6f", + type: "user", + name: "Priya Nair", + email: "priya@acme.dev", + }, + data: { + id: "7c2e4f1a-9b3d-4a6e-8c5f-1d7b3e9a2c4f", + identifier: "ENG-483", + number: 483, + title: "Webhook signature verification fails for rotated secrets", + description: + "When a signing secret is rotated mid-flight, in-progress deliveries fail verification.", + priority: 2, + priorityLabel: "High", + estimate: 3, + stateId: "1a9d3f7c-5b2e-4d8a-9c6f-3e7b1a4d9c2f", + assigneeId: "d4f8a2c1-6e9b-4d3a-8f1c-2b7e5a9d4c6f", + teamId: "5e8b2d4a-7c1f-4a9e-8d3b-6f2c9a5e7d1b", + projectId: "9f3a7c5e-1d8b-4f6a-9c2e-7b5d3a8f1c6e", + labelIds: ["2c6e9b4d-8a1f-4c7e-9d3b-5a8f2c6e9b4d"], + subscriberIds: ["d4f8a2c1-6e9b-4d3a-8f1c-2b7e5a9d4c6f"], + createdAt: "2026-07-01T14:32:18.076Z", + updatedAt: "2026-07-01T14:32:18.076Z", + archivedAt: null, + }, + url: "https://linear.app/acme/issue/ENG-483/webhook-signature-verification-fails-for-rotated-secrets", + }, + docsUrl: "https://linear.app/developers/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "linear", + providerLabel: "Linear", + eventType: "Issue.update", + name: "Issue updated", + description: "An issue moved state and was reassigned; `updatedFrom` carries the prior values.", + body: { + action: "update", + type: "Issue", + createdAt: "2026-07-01T16:45:22.391Z", + webhookTimestamp: 1751388322391, + webhookId: "3e9c1b2a-8f47-4c6d-9a1e-7b5d2c4f8e91", + organizationId: "b6a1d4e2-9c3f-4a7b-8e2d-1f6c9a4b7d3e", + actor: { + id: "6b1e9d3a-4c7f-4b2e-8a9d-1c6f3b8e5a2d", + type: "user", + name: "Marcus Webb", + email: "marcus@acme.dev", + }, + data: { + id: "7c2e4f1a-9b3d-4a6e-8c5f-1d7b3e9a2c4f", + identifier: "ENG-483", + number: 483, + stateId: "4d8a2f6c-9b1e-4d7a-8c3f-6e2b9d4a7c1f", + assigneeId: "6b1e9d3a-4c7f-4b2e-8a9d-1c6f3b8e5a2d", + startedAt: "2026-07-01T16:45:22.384Z", + completedAt: null, + updatedAt: "2026-07-01T16:45:22.391Z", + }, + updatedFrom: { + stateId: "1a9d3f7c-5b2e-4d8a-9c6f-3e7b1a4d9c2f", + assigneeId: "d4f8a2c1-6e9b-4d3a-8f1c-2b7e5a9d4c6f", + startedAt: null, + updatedAt: "2026-07-01T14:32:18.076Z", + }, + url: "https://linear.app/acme/issue/ENG-483/webhook-signature-verification-fails-for-rotated-secrets", + }, + docsUrl: "https://linear.app/developers/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "linear", + providerLabel: "Linear", + eventType: "Comment.create", + name: "Comment created", + description: "A new comment was posted on an issue.", + body: { + action: "create", + type: "Comment", + actor: { + id: "6b1e9d3a-4c7f-4b2e-8a9d-1c6f3b8e5a2d", + type: "user", + name: "Marcus Webb", + email: "marcus@acme.dev", + url: "https://linear.app/acme/profiles/marcus", + }, + data: { + id: "8d4f2a6c-1b9e-4d7a-8c3f-6e2b9d4a7c1f", + createdAt: "2026-07-01T17:02:09.514Z", + updatedAt: "2026-07-01T17:02:09.514Z", + archivedAt: null, + body: "I've reproduced this against the staging secret rotation, fix incoming.", + edited: false, + issueId: "7c2e4f1a-9b3d-4a6e-8c5f-1d7b3e9a2c4f", + userId: "6b1e9d3a-4c7f-4b2e-8a9d-1c6f3b8e5a2d", + }, + url: "https://linear.app/acme/issue/ENG-483/webhook-signature-verification-fails-for-rotated-secrets#comment-8d4f2a6c-1b9e-4d7a-8c3f-6e2b9d4a7c1f", + createdAt: "2026-07-01T17:02:09.514Z", + organizationId: "b6a1d4e2-9c3f-4a7b-8e2d-1f6c9a4b7d3e", + webhookTimestamp: 1751389329514, + webhookId: "3e9c1b2a-8f47-4c6d-9a1e-7b5d2c4f8e91", + }, + docsUrl: "https://linear.app/developers/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "linear", + providerLabel: "Linear", + eventType: "IssueLabel.create", + name: "Issue label created", + description: "A new team label was created.", + body: { + action: "create", + type: "IssueLabel", + createdAt: "2026-07-01T09:15:44.201Z", + webhookTimestamp: 1751361344201, + webhookId: "3e9c1b2a-8f47-4c6d-9a1e-7b5d2c4f8e91", + organizationId: "b6a1d4e2-9c3f-4a7b-8e2d-1f6c9a4b7d3e", + actor: { + id: "d4f8a2c1-6e9b-4d3a-8f1c-2b7e5a9d4c6f", + type: "user", + name: "Priya Nair", + email: "priya@acme.dev", + }, + data: { + id: "2c6e9b4d-8a1f-4c7e-9d3b-5a8f2c6e9b4d", + name: "webhooks", + color: "#26b5ce", + description: "Work related to inbound/outbound webhook delivery.", + teamId: "5e8b2d4a-7c1f-4a9e-8d3b-6f2c9a5e7d1b", + creatorId: "d4f8a2c1-6e9b-4d3a-8f1c-2b7e5a9d4c6f", + createdAt: "2026-07-01T09:15:44.192Z", + updatedAt: "2026-07-01T09:15:44.192Z", + archivedAt: null, + }, + url: "https://linear.app/acme/team/ENG/labels", + }, + docsUrl: "https://linear.app/developers/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "linear", + providerLabel: "Linear", + eventType: "Reaction.create", + name: "Reaction created", + description: "An emoji reaction was added to a comment.", + body: { + action: "create", + type: "Reaction", + createdAt: "2026-07-01T17:05:33.802Z", + webhookTimestamp: 1751389533802, + webhookId: "3e9c1b2a-8f47-4c6d-9a1e-7b5d2c4f8e91", + organizationId: "b6a1d4e2-9c3f-4a7b-8e2d-1f6c9a4b7d3e", + actor: { + id: "d4f8a2c1-6e9b-4d3a-8f1c-2b7e5a9d4c6f", + type: "user", + name: "Priya Nair", + email: "priya@acme.dev", + }, + data: { + id: "9e1c4f7a-2b8d-4e6c-9a3f-7d5b2e8c1a4f", + emoji: "🚀", + commentId: "8d4f2a6c-1b9e-4d7a-8c3f-6e2b9d4a7c1f", + userId: "d4f8a2c1-6e9b-4d3a-8f1c-2b7e5a9d4c6f", + createdAt: "2026-07-01T17:05:33.792Z", + updatedAt: "2026-07-01T17:05:33.792Z", + archivedAt: null, + }, + url: "https://linear.app/acme/issue/ENG-483/webhook-signature-verification-fails-for-rotated-secrets#comment-8d4f2a6c-1b9e-4d7a-8c3f-6e2b9d4a7c1f", + }, + docsUrl: "https://linear.app/developers/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/notion.ts b/internal-packages/webhook-sources/src/handAuthored/notion.ts new file mode 100644 index 0000000000..dbf2e03315 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/notion.ts @@ -0,0 +1,136 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Notion samples. No preset: Notion signs with `X-Notion-Signature: sha256=`, an HMAC-SHA256 of + * the raw JSON body keyed by the subscription's `verification_token`. The header name and lack of a + * `t=`/`v1=` component don't match our stripe, github, svix, square, or discord presets. + */ +export const samples: SampleRecord[] = [ + { + provider: "notion", + providerLabel: "Notion", + eventType: "page.created", + name: "Page created (in database)", + description: "A new page was added to a database the integration is subscribed to.", + body: { + id: "6b6f6b8a-8e0d-4f2a-9f3d-2a6b1c4e7d9f", + timestamp: "2026-06-30T14:02:11.000Z", + workspace_id: "13950b26-c203-4f3b-b97d-93ec06319565", + workspace_name: "Acme Product", + subscription_id: "29d75c0d-5546-4414-8459-7b7a92f1fc4b", + integration_id: "0ef2e755-4912-8096-91c1-00376a88a5ca", + type: "page.created", + authors: [{ id: "c7c11cca-1d73-471d-9b6e-bdef51470190", type: "person" }], + attempt_number: 1, + entity: { id: "0ef104cd-477e-80e1-8571-cfd10e92339a", type: "page" }, + data: { + parent: { id: "3d8b9a2c-5e1f-4a6d-8c3b-7e9f2d5a1b6c", type: "database" }, + }, + }, + docsUrl: "https://developers.notion.com/reference/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "notion", + providerLabel: "Notion", + eventType: "page.content_updated", + name: "Page content updated (block added)", + description: + "A block was added to a page's content; delivery is aggregated over a short window.", + body: { + id: "56c3e00c-4f0c-4566-9676-4b058a50a03d", + timestamp: "2026-07-01T19:49:36.997Z", + workspace_id: "13950b26-c203-4f3b-b97d-93ec06319565", + workspace_name: "Acme Product", + subscription_id: "29d75c0d-5546-4414-8459-7b7a92f1fc4b", + integration_id: "0ef2e755-4912-8096-91c1-00376a88a5ca", + type: "page.content_updated", + authors: [{ id: "c7c11cca-1d73-471d-9b6e-bdef51470190", type: "person" }], + attempt_number: 1, + entity: { id: "0ef104cd-477e-80e1-8571-cfd10e92339a", type: "page" }, + data: { + updated_blocks: [{ id: "153104cd-477e-80ec-a87d-f7ff0236d35c", type: "block" }], + parent: { id: "0ef104cd-477e-80e1-8571-cfd10e92339a", type: "page" }, + }, + }, + docsUrl: "https://developers.notion.com/reference/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "notion", + providerLabel: "Notion", + eventType: "page.properties_updated", + name: "Page properties updated (status changed)", + description: "One or more database properties on a page changed value, e.g. a status field.", + body: { + id: "8a4d1c7e-2b5f-4d9a-9e3c-6f0a8b2d5c1e", + timestamp: "2026-07-02T09:15:47.000Z", + workspace_id: "13950b26-c203-4f3b-b97d-93ec06319565", + workspace_name: "Acme Product", + subscription_id: "29d75c0d-5546-4414-8459-7b7a92f1fc4b", + integration_id: "0ef2e755-4912-8096-91c1-00376a88a5ca", + type: "page.properties_updated", + authors: [{ id: "c7c11cca-1d73-471d-9b6e-bdef51470190", type: "person" }], + attempt_number: 1, + entity: { id: "5e1f9a3c-6d2b-4f8e-8a1c-3b6d9f2e5a8c", type: "page" }, + data: { + parent: { id: "3d8b9a2c-5e1f-4a6d-8c3b-7e9f2d5a1b6c", type: "database" }, + updated_properties: ["a1b2%3Ac4d5", "Status"], + }, + }, + docsUrl: "https://developers.notion.com/reference/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "notion", + providerLabel: "Notion", + eventType: "database.content_updated", + name: "Database content updated", + description: + "A page belonging to the database changed. Deprecated in favor of data_source.content_updated as of Notion API version 2025-09-03, but still emitted for integrations pinned to older versions.", + body: { + id: "2f5a8d1c-7e3b-4a6f-9c2d-8b1e5a3f7c9d", + timestamp: "2026-07-03T11:32:05.000Z", + workspace_id: "13950b26-c203-4f3b-b97d-93ec06319565", + workspace_name: "Acme Product", + subscription_id: "29d75c0d-5546-4414-8459-7b7a92f1fc4b", + integration_id: "0ef2e755-4912-8096-91c1-00376a88a5ca", + type: "database.content_updated", + authors: [{ id: "c7c11cca-1d73-471d-9b6e-bdef51470190", type: "person" }], + attempt_number: 1, + entity: { id: "3d8b9a2c-5e1f-4a6d-8c3b-7e9f2d5a1b6c", type: "database" }, + data: { + updated_blocks: [{ id: "9f2d5a8c-1e6b-4d3f-9a7c-2b5e8d1f4a6c", type: "block" }], + parent: { id: "7c1e4b9d-2a6f-4e8c-9d3b-5f1a8c2e6d9b", type: "workspace" }, + }, + }, + docsUrl: "https://developers.notion.com/reference/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "notion", + providerLabel: "Notion", + eventType: "comment.created", + name: "Comment created (on a page)", + description: + "A new top-level comment was added to a page; requires the integration's comment-read capability.", + body: { + id: "4b7e1a9d-6c3f-4e8b-8a2d-9f1c5b7e3a6d", + timestamp: "2026-07-05T16:20:59.000Z", + workspace_id: "13950b26-c203-4f3b-b97d-93ec06319565", + workspace_name: "Acme Product", + subscription_id: "29d75c0d-5546-4414-8459-7b7a92f1fc4b", + integration_id: "0ef2e755-4912-8096-91c1-00376a88a5ca", + type: "comment.created", + authors: [{ id: "1e6b9c3f-8d2a-4c7e-9b1d-6a3f8c2e5d9b", type: "person" }], + attempt_number: 1, + entity: { id: "6d9c2a5e-7f1b-4a3d-9e6c-2b5f8a1d4c7e", type: "comment" }, + data: { + page_id: "0ef104cd-477e-80e1-8571-cfd10e92339a", + parent: { id: "0ef104cd-477e-80e1-8571-cfd10e92339a", type: "page" }, + }, + }, + docsUrl: "https://developers.notion.com/reference/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/openai.ts b/internal-packages/webhook-sources/src/handAuthored/openai.ts new file mode 100644 index 0000000000..a2d31be8d2 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/openai.ts @@ -0,0 +1,135 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * OpenAI samples. OpenAI signs webhooks with Svix, so `presetId: "svix"` keeps them under the + * round-trip guarantee. Every event is a thin envelope (`id`, `object: "event"`, `created_at`, + * `type`, `data`) where `data` carries only the resource id (or, for the Realtime SIP event, the + * call id + SIP headers) - integrators call back the relevant API with that id to fetch full state. + */ +export const samples: SampleRecord[] = [ + { + provider: "openai", + providerLabel: "OpenAI", + presetId: "svix", + eventType: "batch.completed", + name: "Batch completed", + description: + "An async Batch API job finished successfully. Fetch the batch with `data.id` to get the output file id.", + body: { + id: "evt_685343a1381c819085d44c354e1b330e", + object: "event", + created_at: 1784065200, + type: "batch.completed", + data: { + id: "batch_67d1e3d640848190a3f3f19c74e9d9de", + }, + }, + docsUrl: "https://platform.openai.com/docs/guides/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "openai", + providerLabel: "OpenAI", + presetId: "svix", + eventType: "batch.failed", + name: "Batch failed", + description: + "An async Batch API job failed. Fetch the batch with `data.id` for the error file and failure reason.", + body: { + id: "evt_5f2c9a7e6d1b4c3a9e0f7d2b8c6a1e4f", + object: "event", + created_at: 1784068800, + type: "batch.failed", + data: { + id: "batch_a1b2c3d4e5f647890a1b2c3d4e5f6789", + }, + }, + docsUrl: "https://platform.openai.com/docs/guides/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "openai", + providerLabel: "OpenAI", + presetId: "svix", + eventType: "fine_tuning.job.succeeded", + name: "Fine-tuning job succeeded", + description: + "A fine-tuning job finished training. Fetch the job with `data.id` to get the fine-tuned model name.", + body: { + id: "evt_3d8e1f6a9c2b4d7e0f5a8c1b6d9e2f4a", + object: "event", + created_at: 1784072400, + type: "fine_tuning.job.succeeded", + data: { + id: "ftjob-3sPygNaFf0EBAJoATNqFrOca", + }, + }, + docsUrl: "https://platform.openai.com/docs/guides/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "openai", + providerLabel: "OpenAI", + presetId: "svix", + eventType: "response.completed", + name: "Response completed", + description: + "A background Responses API request finished. Fetch it with `data.id` to get the full output.", + body: { + id: "evt_685343a1381c819085d44c354e1b330e", + object: "event", + created_at: 1784076000, + type: "response.completed", + data: { + id: "resp_67ccd6a5da548190a97a9ff8b48e1d6c", + }, + }, + docsUrl: "https://platform.openai.com/docs/guides/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "openai", + providerLabel: "OpenAI", + presetId: "svix", + eventType: "eval.run.succeeded", + name: "Eval run succeeded", + description: + "An Evals API run finished. Fetch it with `data.id` to get result counts and per-model usage.", + body: { + id: "evt_9b4d7f2a5c8e1b3d6f9a2c5e8b1d4f7a", + object: "event", + created_at: 1784079600, + type: "eval.run.succeeded", + data: { + id: "evalrun_67abd54d60ec8190832b46859da808f7", + }, + }, + docsUrl: "https://platform.openai.com/docs/guides/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "openai", + providerLabel: "OpenAI", + presetId: "svix", + eventType: "realtime.call.incoming", + name: "Incoming Realtime SIP call", + description: + "A SIP call arrived for the Realtime API. Use `data.call_id` to accept or reject it before it rings out.", + body: { + id: "evt_685343a1381c819085d44c354e1b330e", + object: "event", + created_at: 1784083200, + type: "realtime.call.incoming", + data: { + call_id: "call_C9RyJt3H0IXXCCUmr1cxoQZ5aB2", + sip_headers: [ + { name: "From", value: "sip:+14255551212@sip.example.com" }, + { name: "To", value: "sip:+18005551212@sip.example.com" }, + { name: "Call-ID", value: "03782086-4ce9-44bf-8b0d-4e303d2cc590" }, + ], + }, + }, + docsUrl: "https://platform.openai.com/docs/guides/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/pagerduty.ts b/internal-packages/webhook-sources/src/handAuthored/pagerduty.ts new file mode 100644 index 0000000000..e1ac5c0f7a --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/pagerduty.ts @@ -0,0 +1,355 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * PagerDuty v3 webhook samples. Every delivery wraps a single `event` envelope + * (`id`, `event_type`, `resource_type`, `occurred_at`, `agent`, `client`, `data`); `data` is the full + * incident object at the moment of the event. `eventType` here matches `event.event_type`, the real + * discriminant. No `presetId` is set (PagerDuty's `X-PagerDuty-Signature: v1=` is a bespoke + * HMAC-SHA256 over the raw body with no matching preset). + */ +export const samples: SampleRecord[] = [ + { + provider: "pagerduty", + providerLabel: "PagerDuty", + eventType: "incident.triggered", + name: "Incident triggered", + description: "A monitoring integration opened a new incident via the Events API.", + body: { + event: { + id: "01J2Y3ZK9M4Q7R8T2VBN5X0F1A", + event_type: "incident.triggered", + resource_type: "incident", + occurred_at: "2026-07-14T02:11:03Z", + agent: null, + client: null, + data: { + id: "PT4KHXT", + type: "incident", + self: "https://api.pagerduty.com/incidents/PT4KHXT", + html_url: "https://acme-corp.pagerduty.com/incidents/PT4KHXT", + number: 4821, + status: "triggered", + incident_key: "prod-api-5xx-spike", + created_at: "2026-07-14T02:11:03Z", + title: "Elevated 5xx error rate on prod-api-gateway", + service: { + id: "PSVCACME1", + type: "service_reference", + summary: "prod-api-gateway", + self: "https://api.pagerduty.com/services/PSVCACME1", + html_url: "https://acme-corp.pagerduty.com/services/PSVCACME1", + }, + assignees: [ + { + id: "PUSRJDOE1", + type: "user_reference", + summary: "Jamie Chen", + self: "https://api.pagerduty.com/users/PUSRJDOE1", + html_url: "https://acme-corp.pagerduty.com/users/PUSRJDOE1", + }, + ], + escalation_policy: { + id: "PEPACME01", + type: "escalation_policy_reference", + summary: "Platform On-Call", + self: "https://api.pagerduty.com/escalation_policies/PEPACME01", + html_url: "https://acme-corp.pagerduty.com/escalation_policies/PEPACME01", + }, + teams: [], + priority: { + id: "PRIP1XXX", + type: "priority_reference", + summary: "P2", + self: "https://api.pagerduty.com/priorities/PRIP1XXX", + html_url: "https://acme-corp.pagerduty.com/account/incident_priorities", + }, + urgency: "high", + conference_bridge: null, + resolve_reason: null, + }, + }, + }, + docsUrl: "https://developer.pagerduty.com/docs/webhooks/webhook-signatures/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "pagerduty", + providerLabel: "PagerDuty", + eventType: "incident.acknowledged", + name: "Incident acknowledged", + description: "The on-call responder acknowledged the incident.", + body: { + event: { + id: "01J2Y40D3H6K8N1P5WCM7Y2G3B", + event_type: "incident.acknowledged", + resource_type: "incident", + occurred_at: "2026-07-14T02:13:47Z", + agent: { + id: "PUSRJDOE1", + type: "user_reference", + summary: "Jamie Chen", + self: "https://api.pagerduty.com/users/PUSRJDOE1", + html_url: "https://acme-corp.pagerduty.com/users/PUSRJDOE1", + }, + client: null, + data: { + id: "PT4KHXT", + type: "incident", + self: "https://api.pagerduty.com/incidents/PT4KHXT", + html_url: "https://acme-corp.pagerduty.com/incidents/PT4KHXT", + number: 4821, + status: "acknowledged", + incident_key: "prod-api-5xx-spike", + created_at: "2026-07-14T02:11:03Z", + title: "Elevated 5xx error rate on prod-api-gateway", + service: { + id: "PSVCACME1", + type: "service_reference", + summary: "prod-api-gateway", + self: "https://api.pagerduty.com/services/PSVCACME1", + html_url: "https://acme-corp.pagerduty.com/services/PSVCACME1", + }, + assignees: [ + { + id: "PUSRJDOE1", + type: "user_reference", + summary: "Jamie Chen", + self: "https://api.pagerduty.com/users/PUSRJDOE1", + html_url: "https://acme-corp.pagerduty.com/users/PUSRJDOE1", + }, + ], + acknowledgements: [ + { + at: "2026-07-14T02:13:47Z", + acknowledger: { + id: "PUSRJDOE1", + type: "user_reference", + summary: "Jamie Chen", + self: "https://api.pagerduty.com/users/PUSRJDOE1", + html_url: "https://acme-corp.pagerduty.com/users/PUSRJDOE1", + }, + }, + ], + escalation_policy: { + id: "PEPACME01", + type: "escalation_policy_reference", + summary: "Platform On-Call", + self: "https://api.pagerduty.com/escalation_policies/PEPACME01", + html_url: "https://acme-corp.pagerduty.com/escalation_policies/PEPACME01", + }, + teams: [], + priority: { + id: "PRIP1XXX", + type: "priority_reference", + summary: "P2", + self: "https://api.pagerduty.com/priorities/PRIP1XXX", + html_url: "https://acme-corp.pagerduty.com/account/incident_priorities", + }, + urgency: "high", + conference_bridge: null, + last_status_change_at: "2026-07-14T02:13:47Z", + }, + }, + }, + docsUrl: "https://developer.pagerduty.com/docs/webhooks/webhook-signatures/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "pagerduty", + providerLabel: "PagerDuty", + eventType: "incident.escalated", + name: "Incident escalated", + description: "Acknowledgement timed out and the incident escalated to the next on-call level.", + body: { + event: { + id: "01J2Y41V7Q3W9M2N6XDP8Z4H5C", + event_type: "incident.escalated", + resource_type: "incident", + occurred_at: "2026-07-14T02:23:47Z", + agent: null, + client: null, + data: { + id: "PT4KHXT", + type: "incident", + self: "https://api.pagerduty.com/incidents/PT4KHXT", + html_url: "https://acme-corp.pagerduty.com/incidents/PT4KHXT", + number: 4821, + status: "triggered", + incident_key: "prod-api-5xx-spike", + created_at: "2026-07-14T02:11:03Z", + title: "Elevated 5xx error rate on prod-api-gateway", + service: { + id: "PSVCACME1", + type: "service_reference", + summary: "prod-api-gateway", + self: "https://api.pagerduty.com/services/PSVCACME1", + html_url: "https://acme-corp.pagerduty.com/services/PSVCACME1", + }, + assignees: [ + { + id: "PUSRMRYS2", + type: "user_reference", + summary: "Morgan Reyes", + self: "https://api.pagerduty.com/users/PUSRMRYS2", + html_url: "https://acme-corp.pagerduty.com/users/PUSRMRYS2", + }, + ], + escalation_policy: { + id: "PEPACME01", + type: "escalation_policy_reference", + summary: "Platform On-Call", + self: "https://api.pagerduty.com/escalation_policies/PEPACME01", + html_url: "https://acme-corp.pagerduty.com/escalation_policies/PEPACME01", + }, + teams: [], + priority: { + id: "PRIP1XXX", + type: "priority_reference", + summary: "P2", + self: "https://api.pagerduty.com/priorities/PRIP1XXX", + html_url: "https://acme-corp.pagerduty.com/account/incident_priorities", + }, + urgency: "high", + conference_bridge: null, + last_status_change_at: "2026-07-14T02:23:47Z", + }, + }, + }, + docsUrl: "https://developer.pagerduty.com/docs/webhooks/webhook-signatures/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "pagerduty", + providerLabel: "PagerDuty", + eventType: "incident.reassigned", + name: "Incident reassigned", + description: "The first responder reassigned the incident to a teammate.", + body: { + event: { + id: "01J2Y42F5T8V1M3P7YCQ9A6J7D", + event_type: "incident.reassigned", + resource_type: "incident", + occurred_at: "2026-07-14T02:25:10Z", + agent: { + id: "PUSRJDOE1", + type: "user_reference", + summary: "Jamie Chen", + self: "https://api.pagerduty.com/users/PUSRJDOE1", + html_url: "https://acme-corp.pagerduty.com/users/PUSRJDOE1", + }, + client: null, + data: { + id: "PT4KHXT", + type: "incident", + self: "https://api.pagerduty.com/incidents/PT4KHXT", + html_url: "https://acme-corp.pagerduty.com/incidents/PT4KHXT", + number: 4821, + status: "triggered", + incident_key: "prod-api-5xx-spike", + created_at: "2026-07-14T02:11:03Z", + title: "Elevated 5xx error rate on prod-api-gateway", + service: { + id: "PSVCACME1", + type: "service_reference", + summary: "prod-api-gateway", + self: "https://api.pagerduty.com/services/PSVCACME1", + html_url: "https://acme-corp.pagerduty.com/services/PSVCACME1", + }, + assignees: [ + { + id: "PUSRMRYS2", + type: "user_reference", + summary: "Morgan Reyes", + self: "https://api.pagerduty.com/users/PUSRMRYS2", + html_url: "https://acme-corp.pagerduty.com/users/PUSRMRYS2", + }, + ], + escalation_policy: { + id: "PEPACME01", + type: "escalation_policy_reference", + summary: "Platform On-Call", + self: "https://api.pagerduty.com/escalation_policies/PEPACME01", + html_url: "https://acme-corp.pagerduty.com/escalation_policies/PEPACME01", + }, + teams: [], + priority: { + id: "PRIP1XXX", + type: "priority_reference", + summary: "P2", + self: "https://api.pagerduty.com/priorities/PRIP1XXX", + html_url: "https://acme-corp.pagerduty.com/account/incident_priorities", + }, + urgency: "high", + conference_bridge: null, + last_status_change_at: "2026-07-14T02:25:10Z", + }, + }, + }, + docsUrl: "https://developer.pagerduty.com/docs/webhooks/webhook-signatures/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "pagerduty", + providerLabel: "PagerDuty", + eventType: "incident.resolved", + name: "Incident resolved", + description: "The responder resolved the incident after rolling back the bad deploy.", + body: { + event: { + id: "01J2Y43R9W2Y4N6Q8ZDS1B7K9E", + event_type: "incident.resolved", + resource_type: "incident", + occurred_at: "2026-07-14T02:41:52Z", + agent: { + id: "PUSRMRYS2", + type: "user_reference", + summary: "Morgan Reyes", + self: "https://api.pagerduty.com/users/PUSRMRYS2", + html_url: "https://acme-corp.pagerduty.com/users/PUSRMRYS2", + }, + client: null, + data: { + id: "PT4KHXT", + type: "incident", + self: "https://api.pagerduty.com/incidents/PT4KHXT", + html_url: "https://acme-corp.pagerduty.com/incidents/PT4KHXT", + number: 4821, + status: "resolved", + incident_key: "prod-api-5xx-spike", + created_at: "2026-07-14T02:11:03Z", + title: "Elevated 5xx error rate on prod-api-gateway", + service: { + id: "PSVCACME1", + type: "service_reference", + summary: "prod-api-gateway", + self: "https://api.pagerduty.com/services/PSVCACME1", + html_url: "https://acme-corp.pagerduty.com/services/PSVCACME1", + }, + assignees: [], + escalation_policy: { + id: "PEPACME01", + type: "escalation_policy_reference", + summary: "Platform On-Call", + self: "https://api.pagerduty.com/escalation_policies/PEPACME01", + html_url: "https://acme-corp.pagerduty.com/escalation_policies/PEPACME01", + }, + teams: [], + priority: { + id: "PRIP1XXX", + type: "priority_reference", + summary: "P2", + self: "https://api.pagerduty.com/priorities/PRIP1XXX", + html_url: "https://acme-corp.pagerduty.com/account/incident_priorities", + }, + urgency: "high", + conference_bridge: null, + resolve_reason: null, + resolved_at: "2026-07-14T02:41:52Z", + last_status_change_at: "2026-07-14T02:41:52Z", + }, + }, + }, + docsUrl: "https://developer.pagerduty.com/docs/webhooks/webhook-signatures/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/plaid.ts b/internal-packages/webhook-sources/src/handAuthored/plaid.ts new file mode 100644 index 0000000000..eb534bafe9 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/plaid.ts @@ -0,0 +1,121 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Plaid samples. No `presetId` on any sample: Plaid's `Plaid-Verification` scheme is a JWT (ES256) + * over a body hash, verified against a key fetched from `/webhook_verification_key/get`, not one of + * our HMAC-shared-secret presets. `eventType` joins `webhook_type` and `webhook_code` + * (e.g. "TRANSACTIONS.SYNC_UPDATES_AVAILABLE") to match `eventTypeSource`'s coarser `webhook_type` + * pointer. Plaid's real API delivers "login required" as `webhook_code: "ERROR"` with a nested + * `error.error_code: "ITEM_LOGIN_REQUIRED"` - there is no distinct top-level `LOGIN_REQUIRED` + * webhook_code - so that sample's `eventType` further appends the nested error code to stay + * distinguishable from the other, unrelated ITEM ERROR sample below. + */ +export const samples: SampleRecord[] = [ + { + provider: "plaid", + providerLabel: "Plaid", + eventType: "TRANSACTIONS.SYNC_UPDATES_AVAILABLE", + name: "Transactions sync update available", + description: + "Fires after `/transactions/sync` has new changes to fetch. `initial_update_complete` flips true once the first 30 days are ready; `historical_update_complete` flips true once up to 24 months of history has backfilled.", + body: { + webhook_type: "TRANSACTIONS", + webhook_code: "SYNC_UPDATES_AVAILABLE", + item_id: "gY6VmvpXQPfBn8DjJ4koSA5rZ1MezyCWQ98po", + initial_update_complete: true, + historical_update_complete: false, + environment: "production", + }, + docsUrl: "https://plaid.com/docs/api/products/transactions/#sync_updates_available", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "plaid", + providerLabel: "Plaid", + eventType: "TRANSACTIONS.TRANSACTIONS_REMOVED", + name: "Transactions removed", + description: + "Fires when previously-sent transactions are no longer valid, most commonly when a pending transaction is canceled rather than posting. `removed_transactions` lists the affected transaction ids.", + body: { + webhook_type: "TRANSACTIONS", + webhook_code: "TRANSACTIONS_REMOVED", + removed_transactions: [ + "a8N7bQeWnDx3fKpL9tYoI2vEcJmZ5RsAugTHb", + "j3F1mXsPqRkV6bYtNc8oLwZdEgA0hUiCsxKMe", + ], + item_id: "gY6VmvpXQPfBn8DjJ4koSA5rZ1MezyCWQ98po", + environment: "production", + }, + docsUrl: "https://plaid.com/docs/transactions/webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "plaid", + providerLabel: "Plaid", + eventType: "ITEM.ERROR", + name: "Item error: institution not responding", + description: + "A generic Item-level error unrelated to the user's credentials; the institution's servers stopped responding to requests for this Item.", + body: { + webhook_type: "ITEM", + webhook_code: "ERROR", + item_id: "gY6VmvpXQPfBn8DjJ4koSA5rZ1MezyCWQ98po", + user_id: "usr_4hT9dNwXqYb2Lc7fVoAsJk1rGz6PmEuS", + error: { + error_type: "ITEM_ERROR", + error_code: "INSTITUTION_NOT_RESPONDING", + error_message: "the institution is not responding to requests for this item", + display_message: + "We're having trouble connecting to your bank's servers right now. We're working with them to fix it.", + status: 400, + }, + environment: "production", + }, + docsUrl: "https://plaid.com/docs/api/items/#item-error-webhook", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "plaid", + providerLabel: "Plaid", + eventType: "ITEM.ERROR.ITEM_LOGIN_REQUIRED", + name: "Item error: login required", + description: + "The user's credentials at the institution changed (password rotation, revoked OAuth consent, etc.) and Link's update mode is needed to restore the Item. Delivered as `webhook_code: ERROR` with `error.error_code: ITEM_LOGIN_REQUIRED` - Plaid has no separate top-level `LOGIN_REQUIRED` webhook_code.", + body: { + webhook_type: "ITEM", + webhook_code: "ERROR", + item_id: "gY6VmvpXQPfBn8DjJ4koSA5rZ1MezyCWQ98po", + user_id: "usr_4hT9dNwXqYb2Lc7fVoAsJk1rGz6PmEuS", + error: { + error_type: "ITEM_ERROR", + error_code: "ITEM_LOGIN_REQUIRED", + error_code_reason: "OAUTH_INVALID_TOKEN", + error_message: + "the login details of this item have changed (credentials, MFA, or required user action) and a user login is required to update this information. use Link's update mode to restore the item to a good state", + display_message: "The user's OAuth connection to this institution has been invalidated.", + status: 400, + }, + environment: "production", + }, + docsUrl: "https://plaid.com/docs/api/items/#item-error-webhook", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "plaid", + providerLabel: "Plaid", + eventType: "ITEM.PENDING_EXPIRATION", + name: "Item pending expiration", + description: + "Warns that the user's OAuth consent to the institution will expire soon; `consent_expiration_time` (ISO 8601) is when access is cut off unless the user re-authenticates via update mode.", + body: { + webhook_type: "ITEM", + webhook_code: "PENDING_EXPIRATION", + item_id: "gY6VmvpXQPfBn8DjJ4koSA5rZ1MezyCWQ98po", + user_id: "usr_4hT9dNwXqYb2Lc7fVoAsJk1rGz6PmEuS", + consent_expiration_time: "2026-08-01T13:25:17.000Z", + environment: "production", + }, + docsUrl: "https://plaid.com/docs/api/items/#item-pending_expiration-webhook", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/postmark.ts b/internal-packages/webhook-sources/src/handAuthored/postmark.ts new file mode 100644 index 0000000000..e78f6ce4d7 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/postmark.ts @@ -0,0 +1,192 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Postmark samples. Postmark has no HMAC signature scheme - webhook URLs are secured with HTTP Basic + * Auth credentials embedded in the URL, so no `presetId` is set on any sample here. The Delivery, Bounce, + * SpamComplaint, and Open event webhooks each carry a real `RecordType` field matching the registry's + * `eventTypeSource`. The Inbound webhook is a separate, older feature whose documented payload has no + * `RecordType` (or any other discriminant) field at all; its `eventType` below is a synthetic label kept + * for picker/grouping purposes, not a value read from the body. + */ +export const samples: SampleRecord[] = [ + { + provider: "postmark", + providerLabel: "Postmark", + eventType: "Delivery", + name: "Delivery", + description: "A message was successfully delivered to the recipient's mail server.", + body: { + RecordType: "Delivery", + MessageStream: "outbound", + MessageID: "883953f4-6105-42a2-a16a-77a8eac79483", + Recipient: "jordan@example.com", + DeliveredAt: "2026-07-14T16:33:54.907Z", + Details: + "smtp;250 2.0.0 OK 1752510834 a1-20020a17090a5a0100b002b0c1b1c1a1si1234567pjd.1 - gsmtp", + Tag: "welcome-email", + ServerID: 23, + Metadata: { + order_id: "ord_4f9c2b1a", + }, + }, + docsUrl: "https://postmarkapp.com/developer/webhooks/delivery-webhook", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "postmark", + providerLabel: "Postmark", + eventType: "Bounce", + name: "Hard bounce", + description: "The recipient's mail server permanently rejected the message (unknown user).", + body: { + RecordType: "Bounce", + MessageStream: "outbound", + ID: 42323002, + Type: "HardBounce", + TypeCode: 1, + Name: "Hard bounce", + Tag: "welcome-email", + MessageID: "883953f4-6105-42a2-a16a-77a8eac79483", + Metadata: { + order_id: "ord_4f9c2b1a", + }, + ServerID: 23, + Description: + "The server was unable to deliver your message (ex: unknown user, mailbox not found).", + Details: "smtp;550 5.1.1 The email account that you tried to reach does not exist.", + Email: "morgan@example.com", + From: "notifications@acme.dev", + BouncedAt: "2026-07-14T16:41:02.113Z", + DumpAvailable: true, + Inactive: true, + CanActivate: true, + Subject: "Welcome to Acme", + }, + docsUrl: "https://postmarkapp.com/developer/webhooks/bounce-webhook", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "postmark", + providerLabel: "Postmark", + eventType: "SpamComplaint", + name: "Spam complaint", + description: "A recipient marked the message as spam via their mail provider's feedback loop.", + body: { + RecordType: "SpamComplaint", + MessageStream: "outbound", + ID: 8675309, + Type: "SpamComplaint", + TypeCode: 512, + Name: "Spam complaint", + Tag: "newsletter", + MessageID: "7c3e9a52-6f18-4d2b-b9a7-1e5c8f3d2a64", + Metadata: { + campaign_id: "camp_2026_07", + }, + ServerID: 23, + Description: "", + Details: "Feedback loop report from recipient's mailbox provider.", + Email: "sam@example.com", + From: "news@acme.dev", + BouncedAt: "2026-07-14T17:02:19.442Z", + DumpAvailable: true, + Inactive: true, + CanActivate: false, + Subject: "Your July product update", + }, + docsUrl: "https://postmarkapp.com/developer/webhooks/spam-complaint-webhook", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "postmark", + providerLabel: "Postmark", + eventType: "Open", + name: "Email opened", + description: "The recipient's mail client rendered the tracking pixel for a sent message.", + body: { + RecordType: "Open", + MessageStream: "outbound", + FirstOpen: true, + Client: { + Name: "Chrome 126.0.0.0", + Company: "Google", + Family: "Chrome", + }, + OS: { + Name: "macOS 14", + Company: "Apple Computer, Inc.", + Family: "macOS", + }, + Platform: "WebMail", + UserAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + Geo: { + CountryISOCode: "US", + Country: "United States", + RegionISOCode: "CA", + Region: "California", + City: "San Francisco", + Zip: "94103", + Coords: "37.7749,-122.4194", + IP: "203.0.113.42", + }, + MessageID: "883953f4-6105-42a2-a16a-77a8eac79483", + Metadata: { + order_id: "ord_4f9c2b1a", + }, + ReceivedAt: "2026-07-14T16:35:10.221Z", + Tag: "welcome-email", + Recipient: "jordan@example.com", + }, + docsUrl: "https://postmarkapp.com/developer/webhooks/open-tracking-webhook", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "postmark", + providerLabel: "Postmark", + eventType: "Inbound", + name: "Inbound email received", + description: + "An email sent to a Postmark inbound address was parsed and posted as JSON. Unlike the event webhooks above, this payload has no `RecordType` field.", + body: { + FromName: "Priya Nair", + MessageStream: "inbound", + From: "priya@example.com", + FromFull: { + Email: "priya@example.com", + Name: "Priya Nair", + MailboxHash: "", + }, + To: '"Acme Support" ', + ToFull: [ + { + Email: "a1b2c3d4e5f6@inbound.postmarkapp.com", + Name: "Acme Support", + MailboxHash: "", + }, + ], + Cc: "", + CcFull: [], + Bcc: "", + BccFull: [], + OriginalRecipient: "a1b2c3d4e5f6@inbound.postmarkapp.com", + Subject: "Trouble connecting my webhook endpoint", + MessageID: "3b2d1a9e-2f4c-4e51-9a7d-6c8f2e1b4a90", + ReplyTo: "priya@example.com", + MailboxHash: "", + Date: "Tue, 14 Jul 2026 11:20:00 -0400", + TextBody: "Hi, our webhook endpoint keeps returning a 401. Can you check the delivery logs?", + HtmlBody: + "

Hi, our webhook endpoint keeps returning a 401. Can you check the delivery logs?

", + StrippedTextReply: "", + Tag: "", + Headers: [ + { Name: "X-Spam-Status", Value: "No" }, + { Name: "X-Spam-Score", Value: "-0.3" }, + ], + Attachments: [], + }, + docsUrl: "https://postmarkapp.com/developer/webhooks/inbound-webhook", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/recall-ai.ts b/internal-packages/webhook-sources/src/handAuthored/recall-ai.ts new file mode 100644 index 0000000000..92d3d2d885 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/recall-ai.ts @@ -0,0 +1,141 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Recall.ai samples. Bot and recording/transcript artifact webhooks share one envelope shape + * `{ event, data: { data: { code, sub_code, updated_at }, ...refs } }`. Recall.ai signs with Svix, so + * `presetId: "svix"` keeps them under the round-trip guarantee. + */ +export const samples: SampleRecord[] = [ + { + provider: "recall-ai", + providerLabel: "Recall.ai", + presetId: "svix", + eventType: "bot.status_change", + name: "Bot recording", + description: "A meeting bot has joined the call and started recording.", + body: { + event: "bot.status_change", + data: { + data: { + code: "in_call_recording", + sub_code: null, + updated_at: "2026-07-14T18:22:03.104Z", + }, + bot: { + id: "6f2b1a3e-9c4d-4b7a-8e2f-1d5c9a7b3e21", + metadata: {}, + }, + }, + }, + docsUrl: "https://docs.recall.ai/docs/bot-status-change-events", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "recall-ai", + providerLabel: "Recall.ai", + presetId: "svix", + eventType: "bot.status_change", + name: "Bot call ended by host", + description: "The meeting host ended the call, so the bot's call has ended.", + body: { + event: "bot.status_change", + data: { + data: { + code: "call_ended", + sub_code: "call_ended_by_host", + updated_at: "2026-07-14T18:47:11.882Z", + }, + bot: { + id: "6f2b1a3e-9c4d-4b7a-8e2f-1d5c9a7b3e21", + metadata: {}, + }, + }, + }, + docsUrl: "https://docs.recall.ai/docs/bot-status-change-events", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "recall-ai", + providerLabel: "Recall.ai", + presetId: "svix", + eventType: "bot.status_change", + name: "Bot fatal error", + description: "The bot ran into an unexpected, unrecoverable error before it could join.", + body: { + event: "bot.status_change", + data: { + data: { + code: "fatal", + sub_code: "bot_errored", + updated_at: "2026-07-14T18:15:47.221Z", + }, + bot: { + id: "a184e6d7-2f3b-4c9e-8a11-6b2d9f4e7c30", + metadata: {}, + }, + }, + }, + docsUrl: "https://docs.recall.ai/docs/sub-codes", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "recall-ai", + providerLabel: "Recall.ai", + presetId: "svix", + eventType: "recording.done", + name: "Recording done", + description: "A bot's recording finished processing and is ready to fetch.", + body: { + event: "recording.done", + data: { + data: { + code: "done", + sub_code: null, + updated_at: "2026-07-14T18:47:22.556Z", + }, + recording: { + id: "3d9e6a2f-7b4c-4e1a-9f8d-2c5b8e1a4d67", + metadata: {}, + }, + bot: { + id: "6f2b1a3e-9c4d-4b7a-8e2f-1d5c9a7b3e21", + metadata: {}, + }, + }, + }, + docsUrl: "https://docs.recall.ai/docs/recording-webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "recall-ai", + providerLabel: "Recall.ai", + presetId: "svix", + eventType: "transcript.done", + name: "Transcript done", + description: "A bot's transcript finished processing and is ready to fetch.", + body: { + event: "transcript.done", + data: { + data: { + code: "done", + sub_code: null, + updated_at: "2026-07-14T18:47:45.913Z", + }, + transcript: { + id: "9b3f5e7c-1a2d-4c8e-b6f9-3e7d1c9a5b42", + metadata: {}, + }, + recording: { + id: "3d9e6a2f-7b4c-4e1a-9f8d-2c5b8e1a4d67", + metadata: {}, + }, + bot: { + id: "6f2b1a3e-9c4d-4b7a-8e2f-1d5c9a7b3e21", + metadata: {}, + }, + }, + }, + docsUrl: "https://docs.recall.ai/docs/bot-async-transcription", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/replicate.ts b/internal-packages/webhook-sources/src/handAuthored/replicate.ts new file mode 100644 index 0000000000..2918d87ec6 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/replicate.ts @@ -0,0 +1,191 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Replicate samples. Replicate signs webhooks with a Standard Webhooks compatible scheme (`webhook-id` + * / `webhook-timestamp` / `webhook-signature` headers, HMAC-SHA256 over `{id}.{timestamp}.{rawBody}`, + * `whsec_`-prefixed secret), so `presetId: "svix"` keeps them under the round-trip guarantee. The body + * is the prediction object itself; there is no separate event envelope, so `eventType` is read from the + * prediction's own `status` field. + */ +export const samples: SampleRecord[] = [ + { + provider: "replicate", + providerLabel: "Replicate", + presetId: "svix", + eventType: "starting", + name: "Prediction starting", + description: "A prediction has been created and queued but has not started running yet.", + body: { + id: "qz7mfxlkw3vdpygt1o8hbnsuae", + model: "black-forest-labs/flux-schnell", + version: "a3f1c9d2b7e4809f61a2d5c8e0b3f7a41d6c9b2e5f80a3d17c4b9e2f6a0d3c85", + input: { + prompt: "a studio photo of a golden retriever wearing sunglasses", + num_outputs: 1, + aspect_ratio: "1:1", + output_format: "webp", + }, + logs: null, + output: null, + error: null, + status: "starting", + created_at: "2026-07-14T09:12:03.221000Z", + source: "api", + data_removed: false, + started_at: null, + completed_at: null, + metrics: {}, + urls: { + web: "https://replicate.com/p/qz7mfxlkw3vdpygt1o8hbnsuae", + get: "https://api.replicate.com/v1/predictions/qz7mfxlkw3vdpygt1o8hbnsuae", + cancel: "https://api.replicate.com/v1/predictions/qz7mfxlkw3vdpygt1o8hbnsuae/cancel", + }, + }, + docsUrl: "https://replicate.com/docs/topics/webhooks/receive-webhook", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "replicate", + providerLabel: "Replicate", + presetId: "svix", + eventType: "processing", + name: "Prediction processing", + description: "The model has started running; intermediate logs are streaming in.", + body: { + id: "n5tqorvfy28mzbklwdshxc1gpu", + model: "black-forest-labs/flux-schnell", + version: "a3f1c9d2b7e4809f61a2d5c8e0b3f7a41d6c9b2e5f80a3d17c4b9e2f6a0d3c85", + input: { + prompt: "a watercolor painting of a lighthouse at sunset", + num_outputs: 1, + aspect_ratio: "16:9", + output_format: "webp", + }, + logs: "Using seed: 48219\nGenerating image...\n", + output: null, + error: null, + status: "processing", + created_at: "2026-07-14T09:13:41.009000Z", + source: "api", + data_removed: false, + started_at: "2026-07-14T09:13:42.552000Z", + completed_at: null, + metrics: {}, + urls: { + web: "https://replicate.com/p/n5tqorvfy28mzbklwdshxc1gpu", + get: "https://api.replicate.com/v1/predictions/n5tqorvfy28mzbklwdshxc1gpu", + cancel: "https://api.replicate.com/v1/predictions/n5tqorvfy28mzbklwdshxc1gpu/cancel", + }, + }, + docsUrl: "https://replicate.com/docs/topics/webhooks/receive-webhook", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "replicate", + providerLabel: "Replicate", + presetId: "svix", + eventType: "succeeded", + name: "Prediction succeeded", + description: "The prediction finished and produced output.", + body: { + id: "x9blkfqm37ydrpwnztgohsuic", + model: "black-forest-labs/flux-schnell", + version: "a3f1c9d2b7e4809f61a2d5c8e0b3f7a41d6c9b2e5f80a3d17c4b9e2f6a0d3c85", + input: { + prompt: "a studio photo of a golden retriever wearing sunglasses", + num_outputs: 1, + aspect_ratio: "1:1", + output_format: "webp", + }, + logs: "Using seed: 17734\nGenerating image...\nDone in 0.94s\n", + output: ["https://replicate.delivery/pbxt/QuAty1fakeExampleHash000/out-0.webp"], + error: null, + status: "succeeded", + created_at: "2026-07-14T09:14:55.114000Z", + source: "api", + data_removed: false, + started_at: "2026-07-14T09:14:55.930000Z", + completed_at: "2026-07-14T09:14:56.871000Z", + metrics: { predict_time: 0.941263 }, + urls: { + web: "https://replicate.com/p/x9blkfqm37ydrpwnztgohsuic", + get: "https://api.replicate.com/v1/predictions/x9blkfqm37ydrpwnztgohsuic", + cancel: "https://api.replicate.com/v1/predictions/x9blkfqm37ydrpwnztgohsuic/cancel", + }, + }, + docsUrl: "https://replicate.com/docs/topics/webhooks/receive-webhook", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "replicate", + providerLabel: "Replicate", + presetId: "svix", + eventType: "failed", + name: "Prediction failed", + description: "The model raised an error while running and the prediction did not complete.", + body: { + id: "f3wgzqmnyt8blkdrpxhcvos19u", + model: "black-forest-labs/flux-schnell", + version: "a3f1c9d2b7e4809f61a2d5c8e0b3f7a41d6c9b2e5f80a3d17c4b9e2f6a0d3c85", + input: { + prompt: "an ultra high resolution 8k panorama of a mountain range", + num_outputs: 4, + aspect_ratio: "21:9", + output_format: "webp", + }, + logs: "Using seed: 90271\nGenerating image...\n", + output: null, + error: "CUDA out of memory. Tried to allocate 2.00 GiB (GPU 0; 39.39 GiB total capacity)", + status: "failed", + created_at: "2026-07-14T09:16:02.447000Z", + source: "api", + data_removed: false, + started_at: "2026-07-14T09:16:03.018000Z", + completed_at: "2026-07-14T09:16:07.335000Z", + metrics: { predict_time: 4.317092 }, + urls: { + web: "https://replicate.com/p/f3wgzqmnyt8blkdrpxhcvos19u", + get: "https://api.replicate.com/v1/predictions/f3wgzqmnyt8blkdrpxhcvos19u", + cancel: "https://api.replicate.com/v1/predictions/f3wgzqmnyt8blkdrpxhcvos19u/cancel", + }, + }, + docsUrl: "https://replicate.com/docs/topics/webhooks/receive-webhook", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "replicate", + providerLabel: "Replicate", + presetId: "svix", + eventType: "canceled", + name: "Prediction canceled", + description: "The prediction was canceled via the cancel endpoint before it finished running.", + body: { + id: "h7ldyxqmnzt3fgbkwrpc850osu", + model: "black-forest-labs/flux-schnell", + version: "a3f1c9d2b7e4809f61a2d5c8e0b3f7a41d6c9b2e5f80a3d17c4b9e2f6a0d3c85", + input: { + prompt: "a neon-lit cyberpunk street market at night", + num_outputs: 1, + aspect_ratio: "3:2", + output_format: "webp", + }, + logs: "Using seed: 55810\nGenerating image...\n", + output: null, + error: null, + status: "canceled", + created_at: "2026-07-14T09:17:20.663000Z", + source: "api", + data_removed: false, + started_at: "2026-07-14T09:17:21.204000Z", + completed_at: "2026-07-14T09:17:23.489000Z", + metrics: {}, + urls: { + web: "https://replicate.com/p/h7ldyxqmnzt3fgbkwrpc850osu", + get: "https://api.replicate.com/v1/predictions/h7ldyxqmnzt3fgbkwrpc850osu", + cancel: "https://api.replicate.com/v1/predictions/h7ldyxqmnzt3fgbkwrpc850osu/cancel", + }, + }, + docsUrl: "https://replicate.com/docs/topics/webhooks/receive-webhook", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/resend.ts b/internal-packages/webhook-sources/src/handAuthored/resend.ts new file mode 100644 index 0000000000..39cc66e789 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/resend.ts @@ -0,0 +1,165 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Resend samples. Resend signs webhooks with Svix (`svix-id`/`svix-timestamp`/`svix-signature` + * headers, HMAC-SHA256 base64 over `{id}.{timestamp}.{rawBody}`, `whsec_`-prefixed secret), so + * `presetId: "svix"` keeps them under the round-trip guarantee. `broadcast_id`/`template_id` only + * appear on `data` when the email was sent as part of a Broadcast or with a Template. + */ +export const samples: SampleRecord[] = [ + { + provider: "resend", + providerLabel: "Resend", + presetId: "svix", + eventType: "email.sent", + name: "Email sent", + description: + "Resend accepted the API request and is attempting delivery to the recipient's mail server.", + body: { + type: "email.sent", + created_at: "2026-06-18T09:12:03.041Z", + data: { + created_at: "2026-06-18T09:12:02.884Z", + email_id: "4ef9a417-02e9-4d39-8e9d-3f1c6a2b9c11", + message_id: "", + from: "Acme ", + to: ["jamie@example.com"], + subject: "Welcome to Acme", + tags: { category: "welcome_email" }, + }, + }, + docsUrl: "https://resend.com/docs/dashboard/webhooks/introduction", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "resend", + providerLabel: "Resend", + presetId: "svix", + eventType: "email.delivered", + name: "Email delivered", + description: "The recipient's mail server accepted the email.", + body: { + type: "email.delivered", + created_at: "2026-06-18T09:12:41.317Z", + data: { + broadcast_id: "8f2c6d7a-1b3e-4a9c-9d5f-6e2a8b4c7f10", + created_at: "2026-06-18T09:12:40.955Z", + email_id: "7c3e9a52-6f18-4d2b-b9a7-1e5c8f3d2a64", + message_id: "", + from: "Acme ", + to: ["morgan@example.com"], + subject: "Your July product update", + template_id: "d4e5f6a7-8b9c-4d1e-af23-456789012bcd", + tags: { category: "newsletter" }, + }, + }, + docsUrl: "https://resend.com/docs/dashboard/webhooks/introduction", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "resend", + providerLabel: "Resend", + presetId: "svix", + eventType: "email.bounced", + name: "Email bounced", + description: "The recipient's mail server permanently rejected the email.", + body: { + type: "email.bounced", + created_at: "2026-06-18T09:13:05.662Z", + data: { + created_at: "2026-06-18T09:13:05.201Z", + email_id: "9b1d4f7a-2c5e-4b8d-a1f3-6c9e2b5d8a17", + message_id: "", + from: "Acme ", + to: ["taylor@example.com"], + subject: "Confirm your email address", + bounce: { + type: "Permanent", + subType: "Suppressed", + message: + "The recipient's email address is on the suppression list because it has a recent history of producing hard bounces.", + }, + tags: { category: "confirm_email" }, + }, + }, + docsUrl: "https://resend.com/docs/dashboard/webhooks/introduction", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "resend", + providerLabel: "Resend", + presetId: "svix", + eventType: "email.complained", + name: "Email complained", + description: "The email was delivered, but the recipient marked it as spam.", + body: { + type: "email.complained", + created_at: "2026-06-18T09:14:22.108Z", + data: { + created_at: "2026-06-18T09:14:21.774Z", + email_id: "2a5c8e1b-4d7f-4a3c-9b6e-8f1c4a7d2e59", + message_id: "", + from: "Acme ", + to: ["riley@example.com"], + subject: "Flash sale ends tonight", + tags: { category: "promo" }, + }, + }, + docsUrl: "https://resend.com/docs/dashboard/webhooks/introduction", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "resend", + providerLabel: "Resend", + presetId: "svix", + eventType: "email.opened", + name: "Email opened", + description: "The recipient opened the email.", + body: { + type: "email.opened", + created_at: "2026-06-18T09:20:11.933Z", + data: { + created_at: "2026-06-18T09:12:02.884Z", + email_id: "4ef9a417-02e9-4d39-8e9d-3f1c6a2b9c11", + message_id: "", + from: "Acme ", + to: ["jamie@example.com"], + subject: "Welcome to Acme", + tags: { category: "welcome_email" }, + }, + }, + docsUrl: "https://resend.com/docs/dashboard/webhooks/introduction", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "resend", + providerLabel: "Resend", + presetId: "svix", + eventType: "email.clicked", + name: "Email link clicked", + description: "The recipient clicked a link inside the email.", + body: { + type: "email.clicked", + created_at: "2026-06-18T09:25:47.502Z", + data: { + created_at: "2026-06-18T09:12:40.955Z", + email_id: "7c3e9a52-6f18-4d2b-b9a7-1e5c8f3d2a64", + message_id: "", + from: "Acme ", + to: ["morgan@example.com"], + subject: "Your July product update", + template_id: "d4e5f6a7-8b9c-4d1e-af23-456789012bcd", + click: { + link: "https://acme.dev/pricing", + ipAddress: "203.0.113.42", + timestamp: "2026-06-18T09:25:47.502Z", + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15", + }, + tags: { category: "newsletter" }, + }, + }, + docsUrl: "https://resend.com/docs/dashboard/webhooks/introduction", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/retell.ts b/internal-packages/webhook-sources/src/handAuthored/retell.ts new file mode 100644 index 0000000000..0d0bc48574 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/retell.ts @@ -0,0 +1,192 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Retell AI samples. Retell signs with `x-retell-signature: v={timestamp_ms},d={hex_digest}` + * (HMAC-SHA256 over `rawBody + timestamp`), which does not match any of our verifier presets, so + * these ship sample-only (no `presetId`). Every event is a thin envelope (`event`, `call`) where + * `call` grows richer across the lifecycle: `call_started` carries basic call info, `call_ended` + * adds timing/transcript/disconnection details, and `call_analyzed` adds the `call_analysis` object. + */ +export const samples: SampleRecord[] = [ + { + provider: "retell", + providerLabel: "Retell AI", + eventType: "call_started", + name: "Call started (phone)", + description: + "An inbound phone call was answered by the agent. `call.call_status` is `ongoing`.", + body: { + event: "call_started", + call: { + call_type: "phone_call", + from_number: "+12137771234", + to_number: "+12137771235", + direction: "inbound", + call_id: "Jabr9TXYYJHfvl6Syypi88rdAHYHmcq6", + agent_id: "oBeDLoLOeuAbiuaMFXRtDOLriTJ5tSxD", + call_status: "ongoing", + metadata: {}, + retell_llm_dynamic_variables: { + customer_name: "John Doe", + }, + start_timestamp: 1752562800000, + opt_out_sensitive_data_storage: false, + }, + }, + docsUrl: "https://docs.retellai.com/features/webhook-overview", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "retell", + providerLabel: "Retell AI", + eventType: "call_started", + name: "Call started (web)", + description: + "A browser-based web call began. `call.call_type` is `web_call` and there are no phone numbers.", + body: { + event: "call_started", + call: { + call_type: "web_call", + call_id: "kNc4mQZLPQfrTv8XoypA55rdYHmqTXcb", + agent_id: "oBeDLoLOeuAbiuaMFXRtDOLriTJ5tSxD", + call_status: "ongoing", + metadata: { + session_source: "docs-widget", + }, + retell_llm_dynamic_variables: { + customer_name: "Priya Nair", + }, + start_timestamp: 1752563400000, + opt_out_sensitive_data_storage: false, + }, + }, + docsUrl: "https://docs.retellai.com/features/webhook-overview", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "retell", + providerLabel: "Retell AI", + eventType: "call_ended", + name: "Call ended (user hangup)", + description: + "The call finished normally. `call.disconnection_reason` is `user_hangup`; `call.transcript` and `call.transcript_object` hold the full conversation.", + body: { + event: "call_ended", + call: { + call_type: "phone_call", + from_number: "+12137771234", + to_number: "+12137771235", + direction: "inbound", + call_id: "Jabr9TXYYJHfvl6Syypi88rdAHYHmcq6", + agent_id: "oBeDLoLOeuAbiuaMFXRtDOLriTJ5tSxD", + call_status: "ended", + metadata: {}, + retell_llm_dynamic_variables: { + customer_name: "John Doe", + }, + start_timestamp: 1752562800000, + end_timestamp: 1752562875000, + disconnection_reason: "user_hangup", + transcript: + "Agent: Thanks for calling Acme Support, how can I help?\nUser: I need to reschedule my delivery.\nAgent: Sure, what date works best for you?\nUser: Next Tuesday please.", + transcript_object: [ + { role: "agent", content: "Thanks for calling Acme Support, how can I help?", words: [] }, + { role: "user", content: "I need to reschedule my delivery.", words: [] }, + { role: "agent", content: "Sure, what date works best for you?", words: [] }, + { role: "user", content: "Next Tuesday please.", words: [] }, + ], + recording_url: "https://retell-recordings.example.com/Jabr9TXYYJHfvl6Syypi88rdAHYHmcq6.wav", + opt_out_sensitive_data_storage: false, + }, + }, + docsUrl: "https://docs.retellai.com/features/webhook-overview", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "retell", + providerLabel: "Retell AI", + eventType: "call_ended", + name: "Call ended (voicemail)", + description: + "The call reached voicemail and Retell hung up. `call.disconnection_reason` is `voicemail_reached`.", + body: { + event: "call_ended", + call: { + call_type: "phone_call", + from_number: "+14155550111", + to_number: "+14155550199", + direction: "outbound", + call_id: "wRq2vBXNPLmztK9GhopY73rdCHmvTScf", + agent_id: "oBeDLoLOeuAbiuaMFXRtDOLriTJ5tSxD", + call_status: "ended", + metadata: { + campaign_id: "appt-reminder-2026-07", + }, + retell_llm_dynamic_variables: { + customer_name: "Mateo Alvarez", + }, + start_timestamp: 1752564000000, + end_timestamp: 1752564018000, + disconnection_reason: "voicemail_reached", + transcript: "Agent: Hi, this is a reminder about your appointment tomorrow at 10am.", + transcript_object: [ + { + role: "agent", + content: "Hi, this is a reminder about your appointment tomorrow at 10am.", + words: [], + }, + ], + recording_url: "https://retell-recordings.example.com/wRq2vBXNPLmztK9GhopY73rdCHmvTScf.wav", + opt_out_sensitive_data_storage: false, + }, + }, + docsUrl: "https://docs.retellai.com/features/webhook-overview", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "retell", + providerLabel: "Retell AI", + eventType: "call_analyzed", + name: "Call analyzed", + description: + "Post-call analysis finished. `call.call_analysis` carries the summary, sentiment, and success flag.", + body: { + event: "call_analyzed", + call: { + call_type: "phone_call", + from_number: "+12137771234", + to_number: "+12137771235", + direction: "inbound", + call_id: "Jabr9TXYYJHfvl6Syypi88rdAHYHmcq6", + agent_id: "oBeDLoLOeuAbiuaMFXRtDOLriTJ5tSxD", + call_status: "ended", + metadata: {}, + retell_llm_dynamic_variables: { + customer_name: "John Doe", + }, + start_timestamp: 1752562800000, + end_timestamp: 1752562875000, + duration_ms: 75000, + disconnection_reason: "user_hangup", + transcript: + "Agent: Thanks for calling Acme Support, how can I help?\nUser: I need to reschedule my delivery.\nAgent: Sure, what date works best for you?\nUser: Next Tuesday please.", + recording_url: "https://retell-recordings.example.com/Jabr9TXYYJHfvl6Syypi88rdAHYHmcq6.wav", + public_log_url: "https://retell-logs.example.com/Jabr9TXYYJHfvl6Syypi88rdAHYHmcq6/log.json", + call_analysis: { + call_summary: + "Customer called to reschedule a delivery to next Tuesday; agent confirmed the new date.", + user_sentiment: "Positive", + call_successful: true, + in_voicemail: false, + custom_analysis_data: { + reschedule_requested: true, + new_delivery_date: "2026-07-21", + }, + }, + opt_out_sensitive_data_storage: false, + }, + }, + docsUrl: "https://docs.retellai.com/features/webhook-overview", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/sendgrid.ts b/internal-packages/webhook-sources/src/handAuthored/sendgrid.ts new file mode 100644 index 0000000000..9152511a1b --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/sendgrid.ts @@ -0,0 +1,125 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * SendGrid Event Webhook samples. SendGrid signs with ECDSA (`X-Twilio-Email-Event-Webhook-Signature` + * + `-Timestamp` headers, verified with an EC public key SendGrid issues), which does not match any of + * our verifier presets, so these ship sample-only (no `presetId`). SendGrid always POSTs a JSON ARRAY + * of event objects in one batch; each sample here is a single-element array holding one representative + * event, and `eventType` is that event's `event` value. + */ +export const samples: SampleRecord[] = [ + { + provider: "sendgrid", + providerLabel: "SendGrid", + eventType: "delivered", + name: "Email delivered", + description: "SendGrid accepted and delivered the message to the receiving server.", + body: [ + { + email: "jordan@example.com", + timestamp: 1752562800, + "smtp-id": "<14c5d75ce93.dfd.64b469@ismtpd-555>", + event: "delivered", + category: ["welcome-series"], + sg_event_id: "rWVYmVk90MjZJ9iohOBa3w_fake==", + sg_message_id: "14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0", + response: "250 OK", + asm_group_id: 1, + }, + ], + docsUrl: "https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/event", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "sendgrid", + providerLabel: "SendGrid", + eventType: "open", + name: "Email opened", + description: + "The recipient opened the message. `sg_machine_open` flags automated/prefetch opens.", + body: [ + { + email: "jordan@example.com", + timestamp: 1752563400, + event: "open", + sg_machine_open: false, + category: ["welcome-series"], + sg_event_id: "FOTFFO0ecsBE-zxFXfs6WA_fake==", + sg_message_id: "14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0", + useragent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15", + ip: "198.51.100.23", + }, + ], + docsUrl: "https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/event", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "sendgrid", + providerLabel: "SendGrid", + eventType: "click", + name: "Link clicked", + description: "The recipient clicked a tracked link in the message.", + body: [ + { + email: "jordan@example.com", + timestamp: 1752564000, + event: "click", + category: ["welcome-series"], + sg_event_id: "sg-click-event-id-fake-0001", + sg_message_id: "14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0", + useragent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X)", + ip: "198.51.100.23", + url: "https://example.com/welcome", + url_offset: { index: 0, type: "html" }, + }, + ], + docsUrl: "https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/event", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "sendgrid", + providerLabel: "SendGrid", + eventType: "bounce", + name: "Email bounced", + description: + "The receiving server permanently rejected the message. `reason` and `status` carry the SMTP diagnostic.", + body: [ + { + email: "unknown@example.com", + timestamp: 1752564600, + "smtp-id": "<14c5d75ce93.dfd.64b470@ismtpd-555>", + bounce_classification: "Invalid Address", + event: "bounce", + category: ["welcome-series"], + sg_event_id: "6g4ZI7SA-xmRDv57GoPIPw_fake==", + sg_message_id: "14c5d75ce93.dfd.64b470.filter0001.16648.5515E0B88.0", + reason: "550 5.1.1 The email account that you tried to reach does not exist", + status: "5.1.1", + type: "bounce", + tls: true, + }, + ], + docsUrl: "https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/event", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "sendgrid", + providerLabel: "SendGrid", + eventType: "spamreport", + name: "Marked as spam", + description: "The recipient reported the message as spam via their mail client.", + body: [ + { + email: "jordan@example.com", + timestamp: 1752565200, + "smtp-id": "<14c5d75ce93.dfd.64b471@ismtpd-555>", + event: "spamreport", + category: ["welcome-series"], + sg_event_id: "37nvH5QBz858KGVYCM4uOA_fake==", + sg_message_id: "14c5d75ce93.dfd.64b471.filter0001.16648.5515E0B88.0", + }, + ], + docsUrl: "https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/event", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/sentry.ts b/internal-packages/webhook-sources/src/handAuthored/sentry.ts new file mode 100644 index 0000000000..e813be6666 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/sentry.ts @@ -0,0 +1,260 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Sentry Integration Platform webhook samples. The resource type (issue/error) travels in the + * `Sentry-Hook-Resource` header, not the body, so `eventType` here is the synthetic + * `.` string and each sample carries a matching `extraHeaders["sentry-hook-resource"]` + * so the type survives loading. No preset applies (Sentry's signature scheme is bespoke), so no + * `presetId` is set. + */ +export const samples: SampleRecord[] = [ + { + provider: "sentry", + providerLabel: "Sentry", + eventType: "issue.created", + name: "Issue created", + description: "A new issue was created from an ingested error event.", + body: { + action: "created", + installation: { + uuid: "24b397fc-a86e-43ef-9297-949e21b82480", + }, + data: { + issue: { + id: "4509877862268928", + url: "https://sentry.io/api/0/organizations/acme-co/issues/4509877862268928/", + web_url: "https://acme-co.sentry.io/issues/4509877862268928/", + project_url: "https://acme-co.sentry.io/issues/?project=4509866123456789", + shortId: "NODE-API-7", + title: "TypeError: Cannot read properties of undefined (reading 'id')", + culprit: "processOrder(app/services/orders.ts)", + permalink: "https://acme-co.sentry.io/issues/4509877862268928/", + logger: null, + level: "error", + status: "unresolved", + substatus: "new", + statusDetails: {}, + issueType: "error", + issueCategory: "error", + priority: "high", + count: "1", + userCount: 1, + firstSeen: "2026-07-14T18:56:00.679000+00:00", + lastSeen: "2026-07-14T18:56:00.738000+00:00", + project: { + id: "4509866123456789", + name: "node-api", + slug: "node-api", + platform: "node", + }, + }, + }, + actor: { + type: "application", + id: "sentry", + name: "Sentry", + }, + }, + extraHeaders: { + "sentry-hook-resource": "issue.created", + }, + docsUrl: "https://docs.sentry.io/product/integrations/integration-platform/webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "sentry", + providerLabel: "Sentry", + eventType: "issue.resolved", + name: "Issue resolved", + description: "A team member resolved an issue from the Sentry UI.", + body: { + action: "resolved", + installation: { + uuid: "24b397fc-a86e-43ef-9297-949e21b82480", + }, + data: { + issue: { + id: "4509877862268928", + url: "https://sentry.io/api/0/organizations/acme-co/issues/4509877862268928/", + web_url: "https://acme-co.sentry.io/issues/4509877862268928/", + project_url: "https://acme-co.sentry.io/issues/?project=4509866123456789", + shortId: "NODE-API-7", + title: "TypeError: Cannot read properties of undefined (reading 'id')", + culprit: "processOrder(app/services/orders.ts)", + permalink: "https://acme-co.sentry.io/issues/4509877862268928/", + logger: null, + level: "error", + status: "resolved", + substatus: null, + statusDetails: { + inNextRelease: true, + }, + issueType: "error", + issueCategory: "error", + priority: "high", + count: "14", + userCount: 6, + firstSeen: "2026-07-12T09:12:44.201000+00:00", + lastSeen: "2026-07-14T18:56:00.738000+00:00", + project: { + id: "4509866123456789", + name: "node-api", + slug: "node-api", + platform: "node", + }, + }, + }, + actor: { + type: "user", + id: "56789", + name: "Morgan Reyes", + email: "morgan@example.com", + }, + }, + extraHeaders: { + "sentry-hook-resource": "issue.resolved", + }, + docsUrl: "https://docs.sentry.io/product/integrations/integration-platform/webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "sentry", + providerLabel: "Sentry", + eventType: "issue.assigned", + name: "Issue assigned", + description: "An issue was assigned to a team member.", + body: { + action: "assigned", + installation: { + uuid: "24b397fc-a86e-43ef-9297-949e21b82480", + }, + data: { + issue: { + id: "4509877862299001", + url: "https://sentry.io/api/0/organizations/acme-co/issues/4509877862299001/", + web_url: "https://acme-co.sentry.io/issues/4509877862299001/", + project_url: "https://acme-co.sentry.io/issues/?project=4509866123456789", + shortId: "NODE-API-11", + title: "Timeout connecting to redis://cache-primary:6379", + culprit: "RedisClient.connect(app/lib/cache.ts)", + permalink: "https://acme-co.sentry.io/issues/4509877862299001/", + logger: null, + level: "warning", + status: "unresolved", + substatus: "ongoing", + statusDetails: {}, + issueType: "error", + issueCategory: "error", + priority: "medium", + count: "42", + userCount: 9, + firstSeen: "2026-07-10T03:41:19.442000+00:00", + lastSeen: "2026-07-14T17:03:52.114000+00:00", + project: { + id: "4509866123456789", + name: "node-api", + slug: "node-api", + platform: "node", + }, + }, + assignee: { + type: "user", + id: "56789", + name: "Morgan Reyes", + email: "morgan@example.com", + }, + }, + actor: { + type: "user", + id: "56789", + name: "Morgan Reyes", + email: "morgan@example.com", + }, + }, + extraHeaders: { + "sentry-hook-resource": "issue.assigned", + }, + docsUrl: "https://docs.sentry.io/product/integrations/integration-platform/webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "sentry", + providerLabel: "Sentry", + eventType: "error.created", + name: "Error event created", + description: "A new error event was ingested and matched to an issue.", + body: { + action: "created", + installation: { + uuid: "24b397fc-a86e-43ef-9297-949e21b82480", + }, + data: { + error: { + event_id: "f2f4b3c8e1a94d6f9b3d2c7a5e8f1b40", + issue_id: "4509877862268928", + url: "https://sentry.io/api/0/projects/acme-co/node-api/events/f2f4b3c8e1a94d6f9b3d2c7a5e8f1b40/", + web_url: + "https://acme-co.sentry.io/issues/4509877862268928/events/f2f4b3c8e1a94d6f9b3d2c7a5e8f1b40/", + issue_url: "https://sentry.io/api/0/organizations/acme-co/issues/4509877862268928/", + project: "node-api", + type: "error", + title: "TypeError: Cannot read properties of undefined (reading 'id')", + message: "Cannot read properties of undefined (reading 'id')", + level: "error", + culprit: "processOrder(app/services/orders.ts)", + platform: "node", + sdk: { + name: "sentry.javascript.node", + version: "8.42.0", + }, + exception: { + values: [ + { + type: "TypeError", + value: "Cannot read properties of undefined (reading 'id')", + mechanism: { type: "onunhandledrejection", handled: false }, + stacktrace: { + frames: [ + { + filename: "app/services/orders.ts", + function: "processOrder", + lineno: 42, + colno: 18, + in_app: true, + }, + ], + }, + }, + ], + }, + contexts: { + os: { name: "Linux", version: "6.2.0" }, + runtime: { name: "node", version: "20.14.0" }, + }, + tags: [ + ["environment", "production"], + ["level", "error"], + ["release", "node-api@2026.7.3"], + ], + user: { + id: "38214", + email: "customer@example.com", + }, + datetime: "2026-07-14T18:56:00.679000Z", + timestamp: 1786820160.679, + received: 1786820160.812, + }, + }, + actor: { + type: "application", + id: "sentry", + name: "Sentry", + }, + }, + extraHeaders: { + "sentry-hook-resource": "error.created", + }, + docsUrl: "https://docs.sentry.io/product/integrations/integration-platform/webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/slack.ts b/internal-packages/webhook-sources/src/handAuthored/slack.ts new file mode 100644 index 0000000000..0ed0185a58 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/slack.ts @@ -0,0 +1,196 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Slack Events API samples. Slack wraps every event in an `event_callback` envelope and nests the + * actual event under `event`, so `eventType` here mirrors `event.type`, not the outer `type`. Note + * that channel-scoped subscriptions like `message.channels` still carry `event.type === "message"` + * on the wire, so there is no separate "message.channels" sample. + */ +export const samples: SampleRecord[] = [ + { + provider: "slack", + providerLabel: "Slack", + eventType: "app_mention", + name: "App mentioned", + description: "The bot was @-mentioned in a channel.", + body: { + token: "Jhj5dZrVaK7ZwHHjRyZWjbDl", + team_id: "T024BE7LD", + api_app_id: "A0MDYCDME", + event: { + type: "app_mention", + user: "U061F7AUR", + text: "<@U0LAN0Z89> can you deploy the app?", + ts: "1515449522.000016", + channel: "C0T2GACDT", + event_ts: "1515449522000016", + }, + type: "event_callback", + authorizations: [ + { + team_id: "T024BE7LD", + user_id: "U0LAN0Z89", + is_bot: true, + is_enterprise_install: false, + }, + ], + event_id: "Ev0MDYA7HL", + event_time: 1515449522, + }, + docsUrl: "https://docs.slack.dev/reference/events/app_mention/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "slack", + providerLabel: "Slack", + eventType: "message", + name: "Message posted in a channel", + description: "A plain message was posted to a public channel the bot is a member of.", + body: { + token: "Jhj5dZrVaK7ZwHHjRyZWjbDl", + team_id: "T024BE7LD", + api_app_id: "A0MDYCDME", + event: { + type: "message", + user: "U061F7AUR", + text: "Deploy finished, all green.", + ts: "1355517523.000005", + channel: "C0T2GACDT", + channel_type: "channel", + event_ts: "1355517523.000005", + }, + type: "event_callback", + authorizations: [ + { + team_id: "T024BE7LD", + user_id: "U0LAN0Z89", + is_bot: true, + is_enterprise_install: false, + }, + ], + event_id: "Ev0MDYA7HM", + event_time: 1355517523, + }, + docsUrl: "https://docs.slack.dev/reference/events/message/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "slack", + providerLabel: "Slack", + eventType: "reaction_added", + name: "Reaction added", + description: "A user added an emoji reaction to a message.", + body: { + token: "Jhj5dZrVaK7ZwHHjRyZWjbDl", + team_id: "T024BE7LD", + api_app_id: "A0MDYCDME", + event: { + type: "reaction_added", + user: "U061F7AUR", + reaction: "thumbsup", + item_user: "U0LAN0Z89", + item: { + type: "message", + channel: "C0T2GACDT", + ts: "1360782400.498405", + channel_type: "channel", + }, + event_ts: "1360782804.083113", + }, + type: "event_callback", + authorizations: [ + { + team_id: "T024BE7LD", + user_id: "U0LAN0Z89", + is_bot: true, + is_enterprise_install: false, + }, + ], + event_id: "Ev0MDYA7HN", + event_time: 1360782804, + }, + docsUrl: "https://docs.slack.dev/reference/events/reaction_added/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "slack", + providerLabel: "Slack", + eventType: "member_joined_channel", + name: "Member joined channel", + description: "A user joined a channel the bot is watching.", + body: { + token: "Jhj5dZrVaK7ZwHHjRyZWjbDl", + team_id: "T024BE7LD", + api_app_id: "A0MDYCDME", + event: { + type: "member_joined_channel", + user: "W061F7AUR", + channel: "C0T2GACDT", + channel_type: "C", + team: "T024BE7LD", + inviter: "U0LAN0Z89", + }, + type: "event_callback", + authorizations: [ + { + team_id: "T024BE7LD", + user_id: "U0LAN0Z89", + is_bot: true, + is_enterprise_install: false, + }, + ], + event_id: "Ev0MDYA7HP", + event_time: 1515500000, + }, + docsUrl: "https://docs.slack.dev/reference/events/member_joined_channel/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "slack", + providerLabel: "Slack", + eventType: "app_home_opened", + name: "App home opened", + description: "A user opened the bot's Home tab in the Slack app.", + body: { + token: "Jhj5dZrVaK7ZwHHjRyZWjbDl", + team_id: "T024BE7LD", + api_app_id: "A0MDYCDME", + event: { + type: "app_home_opened", + user: "U061F7AUR", + channel: "D0T2GACDT", + event_ts: "1515449522000016", + tab: "home", + view: { + id: "V0MDYA7HQ", + team_id: "T024BE7LD", + type: "home", + blocks: [], + private_metadata: "", + callback_id: "", + hash: "1231232323.12321312", + clear_on_close: false, + notify_on_close: false, + root_view_id: "V0MDYA7HQ", + app_id: "A0MDYCDME", + external_id: "", + app_installed_team_id: "T024BE7LD", + bot_id: "B0MDYCDMF", + }, + }, + type: "event_callback", + authorizations: [ + { + team_id: "T024BE7LD", + user_id: "U0LAN0Z89", + is_bot: true, + is_enterprise_install: false, + }, + ], + event_id: "Ev0MDYA7HQ", + event_time: 1515449522, + }, + docsUrl: "https://docs.slack.dev/reference/events/app_home_opened/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/square.ts b/internal-packages/webhook-sources/src/handAuthored/square.ts new file mode 100644 index 0000000000..a9594ab58a --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/square.ts @@ -0,0 +1,203 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Square samples. Square signs webhooks with an HMAC-SHA-256 signature over the notification URL + * concatenated with the raw body, base64-encoded, in the `x-square-hmacsha256-signature` header - + * this matches our `square` preset exactly, so `presetId: "square"` keeps them under the round-trip + * guarantee. + */ +export const samples: SampleRecord[] = [ + { + provider: "square", + providerLabel: "Square", + presetId: "square", + eventType: "payment.created", + name: "Payment created", + description: "A new payment was created, in an APPROVED (not yet captured) state.", + body: { + merchant_id: "6SSW7HV8K2ST5", + type: "payment.created", + event_id: "13b867cf-db3d-4b1c-90b6-2f32a9d78124", + created_at: "2026-07-01T21:27:30.792Z", + data: { + type: "payment", + id: "hYy9pRFVxpDsO1FB05SunFWUe9JZY", + object: { + payment: { + id: "hYy9pRFVxpDsO1FB05SunFWUe9JZY", + created_at: "2026-07-01T21:16:51.086Z", + updated_at: "2026-07-01T21:16:51.198Z", + amount_money: { amount: 100, currency: "USD" }, + status: "APPROVED", + delay_duration: "PT168H", + source_type: "CARD", + card_details: { + status: "AUTHORIZED", + card: { + card_brand: "MASTERCARD", + last_4: "9029", + exp_month: 11, + exp_year: 2028, + fingerprint: + "sq-1-Tvruf3vPQxlvI6n0IcKYfBukrcv6IqWr8UyBdViWXU2yzGn5VMJvrsHMKpINMhPmVg", + card_type: "CREDIT", + prepaid_type: "NOT_PREPAID", + bin: "540988", + }, + entry_method: "KEYED", + cvv_status: "CVV_ACCEPTED", + avs_status: "AVS_ACCEPTED", + statement_description: "SQ *DEFAULT TEST ACCOUNT", + card_payment_timeline: { authorized_at: "2026-07-01T21:16:51.198Z" }, + }, + location_id: "S8GWD5R9QB376", + order_id: "03O3USaPaAaFnI6kkwB1JxGgBsUZY", + risk_evaluation: { + created_at: "2026-07-01T21:16:51.198Z", + risk_level: "NORMAL", + }, + total_money: { amount: 100, currency: "USD" }, + approved_money: { amount: 100, currency: "USD" }, + capabilities: ["EDIT_TIP_AMOUNT", "EDIT_TIP_AMOUNT_UP", "EDIT_TIP_AMOUNT_DOWN"], + receipt_number: "hYy9", + delay_action: "CANCEL", + delayed_until: "2026-07-08T21:16:51.086Z", + version_token: "FfQhQJf9r3VSQIgyWBk1oqhIwiznLwVwJbVVA0bdyEv6o", + }, + }, + }, + }, + docsUrl: + "https://developer.squareup.com/reference/square/payments-api/webhooks/payment.created", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "square", + providerLabel: "Square", + presetId: "square", + eventType: "payment.updated", + name: "Payment updated", + description: "A payment transitioned to COMPLETED after capture.", + body: { + merchant_id: "6SSW7HV8K2ST5", + type: "payment.updated", + event_id: "6a8f5f28-54a1-4eb0-a98a-3111513fd4fc", + created_at: "2026-07-01T21:27:34.308Z", + data: { + type: "payment", + id: "hYy9pRFVxpDsO1FB05SunFWUe9JZY", + object: { + payment: { + id: "hYy9pRFVxpDsO1FB05SunFWUe9JZY", + created_at: "2026-07-01T21:16:51.086Z", + updated_at: "2026-07-01T21:19:00.831Z", + amount_money: { amount: 100, currency: "USD" }, + status: "COMPLETED", + delay_duration: "PT168H", + source_type: "CARD", + card_details: { + status: "CAPTURED", + card: { + card_brand: "MASTERCARD", + last_4: "9029", + exp_month: 11, + exp_year: 2028, + fingerprint: + "sq-1-Tvruf3vPQxlvI6n0IcKYfBukrcv6IqWr8UyBdViWXU2yzGn5VMJvrsHMKpINMhPmVg", + card_type: "CREDIT", + prepaid_type: "NOT_PREPAID", + bin: "540988", + }, + entry_method: "KEYED", + cvv_status: "CVV_ACCEPTED", + avs_status: "AVS_ACCEPTED", + statement_description: "SQ *DEFAULT TEST ACCOUNT", + card_payment_timeline: { + authorized_at: "2026-07-01T21:16:51.198Z", + captured_at: "2026-07-01T21:19:00.832Z", + }, + }, + location_id: "S8GWD5R9QB376", + order_id: "03O3USaPaAaFnI6kkwB1JxGgBsUZY", + risk_evaluation: { + created_at: "2026-07-01T21:16:51.198Z", + risk_level: "NORMAL", + }, + total_money: { amount: 100, currency: "USD" }, + approved_money: { amount: 100, currency: "USD" }, + receipt_number: "hYy9", + receipt_url: "https://squareup.com/receipt/preview/hYy9pRFVxpDsO1FB05SunFWUe9JZY", + delay_action: "CANCEL", + delayed_until: "2026-07-08T21:16:51.086Z", + version_token: "bhC3b8qKJvNDdxqKzXaeDsAjS1oMFuAKxGgT32HbE6S6o", + }, + }, + }, + }, + docsUrl: + "https://developer.squareup.com/reference/square/payments-api/webhooks/payment.updated", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "square", + providerLabel: "Square", + presetId: "square", + eventType: "order.created", + name: "Order created", + description: "A new order was created via the Orders API.", + body: { + merchant_id: "5S9MXCS9Y99KK", + type: "order.created", + event_id: "116038d3-2948-439f-8679-fc86dbf80f69", + created_at: "2026-07-01T23:14:26.129Z", + data: { + type: "order_created", + id: "eA3vssLHKJrv9H0IdJCM3gNqfdcZY", + object: { + order_created: { + created_at: "2026-07-01T23:14:26.129Z", + location_id: "FPYCBCHYMXFK1", + order_id: "eA3vssLHKJrv9H0IdJCM3gNqfdcZY", + state: "OPEN", + version: 1, + }, + }, + }, + }, + docsUrl: "https://developer.squareup.com/reference/square/orders-api/webhooks/order.created", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "square", + providerLabel: "Square", + presetId: "square", + eventType: "refund.created", + name: "Refund created", + description: "A refund was created against a prior payment and is pending settlement.", + body: { + merchant_id: "6SSW7HV8K2ST5", + type: "refund.created", + event_id: "bc316346-6691-4243-88ed-6d651a0d0c47", + created_at: "2026-07-01T21:27:41.852Z", + data: { + type: "refund", + id: "KkAkhdMsgzn59SM8A89WgKwekxLZY_ptNBVqHYxt5gAdfcobBe4u1AZsXhoz06KTtuq9Ls24P", + object: { + refund: { + id: "KkAkhdMsgzn59SM8A89WgKwekxLZY_ptNBVqHYxt5gAdfcobBe4u1AZsXhoz06KTtuq9Ls24P", + created_at: "2026-07-01T21:27:41.836Z", + updated_at: "2026-07-01T21:27:41.846Z", + amount_money: { amount: 1000, currency: "USD" }, + status: "PENDING", + location_id: "NAQ1FHV6ZJ8YV", + order_id: "haOyDuHiqtAXMk0d8pDKXpL7Jg4F", + payment_id: "KkAkhdMsgzn59SM8A89WgKwekxLZY", + version: 7, + }, + }, + }, + }, + docsUrl: "https://developer.squareup.com/reference/square/refunds-api/webhooks/refund.created", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/supabase.ts b/internal-packages/webhook-sources/src/handAuthored/supabase.ts new file mode 100644 index 0000000000..1cad07522e --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/supabase.ts @@ -0,0 +1,147 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Supabase Database Webhook samples. Supabase has no built-in signing scheme (verification is + * whatever custom header the integrator wires into the underlying `net.http_post` trigger), so no + * `presetId` is set on any sample here. `eventType` is the body's top-level `type` field + * (`INSERT` / `UPDATE` / `DELETE`); `record` holds the new row and `old_record` the previous row, + * with one or the other `null` depending on the operation. + */ +export const samples: SampleRecord[] = [ + { + provider: "supabase", + providerLabel: "Supabase", + eventType: "INSERT", + name: "Order created", + description: "A new row was inserted into the public.orders table.", + body: { + type: "INSERT", + table: "orders", + schema: "public", + record: { + id: 4821, + customer_id: "b1a2c3d4-5e6f-47a8-9b0c-1d2e3f4a5b6c", + status: "pending", + total_cents: 8999, + currency: "usd", + created_at: "2026-07-14T18:22:03.511Z", + updated_at: "2026-07-14T18:22:03.511Z", + }, + old_record: null, + }, + docsUrl: "https://supabase.com/docs/guides/database/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "supabase", + providerLabel: "Supabase", + eventType: "UPDATE", + name: "Order status updated", + description: + "An existing public.orders row moved from pending to shipped. `old_record` requires REPLICA IDENTITY FULL on the table, otherwise it is null.", + body: { + type: "UPDATE", + table: "orders", + schema: "public", + record: { + id: 4821, + customer_id: "b1a2c3d4-5e6f-47a8-9b0c-1d2e3f4a5b6c", + status: "shipped", + total_cents: 8999, + currency: "usd", + created_at: "2026-07-14T18:22:03.511Z", + updated_at: "2026-07-15T09:41:27.204Z", + }, + old_record: { + id: 4821, + customer_id: "b1a2c3d4-5e6f-47a8-9b0c-1d2e3f4a5b6c", + status: "pending", + total_cents: 8999, + currency: "usd", + created_at: "2026-07-14T18:22:03.511Z", + updated_at: "2026-07-14T18:22:03.511Z", + }, + }, + docsUrl: "https://supabase.com/docs/guides/database/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "supabase", + providerLabel: "Supabase", + eventType: "DELETE", + name: "Order deleted", + description: + "A public.orders row was deleted; `record` is null and `old_record` is the removed row.", + body: { + type: "DELETE", + table: "orders", + schema: "public", + record: null, + old_record: { + id: 4790, + customer_id: "f3e2d1c0-8b7a-4693-8c5d-2a1b0c9d8e7f", + status: "cancelled", + total_cents: 4500, + currency: "usd", + created_at: "2026-07-11T12:05:44.883Z", + updated_at: "2026-07-13T07:18:52.036Z", + }, + }, + docsUrl: "https://supabase.com/docs/guides/database/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "supabase", + providerLabel: "Supabase", + eventType: "INSERT", + name: "Profile created", + description: + "A new row was inserted into public.profiles, a common pattern for mirroring auth.users signups into an app-owned table.", + body: { + type: "INSERT", + table: "profiles", + schema: "public", + record: { + id: "c7d8e9f0-1a2b-43c4-8d5e-6f7a8b9c0d1e", + username: "jordan_lee", + full_name: "Jordan Lee", + avatar_url: + "https://xyzcompany.supabase.co/storage/v1/object/public/avatars/jordan_lee.png", + created_at: "2026-07-15T08:03:11.902Z", + }, + old_record: null, + }, + docsUrl: "https://supabase.com/docs/guides/database/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "supabase", + providerLabel: "Supabase", + eventType: "UPDATE", + name: "Profile updated", + description: "A public.profiles row changed its username.", + body: { + type: "UPDATE", + table: "profiles", + schema: "public", + record: { + id: "c7d8e9f0-1a2b-43c4-8d5e-6f7a8b9c0d1e", + username: "jordan.lee", + full_name: "Jordan Lee", + avatar_url: + "https://xyzcompany.supabase.co/storage/v1/object/public/avatars/jordan_lee.png", + created_at: "2026-07-15T08:03:11.902Z", + }, + old_record: { + id: "c7d8e9f0-1a2b-43c4-8d5e-6f7a8b9c0d1e", + username: "jordan_lee", + full_name: "Jordan Lee", + avatar_url: + "https://xyzcompany.supabase.co/storage/v1/object/public/avatars/jordan_lee.png", + created_at: "2026-07-15T08:03:11.902Z", + }, + }, + docsUrl: "https://supabase.com/docs/guides/database/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/telegram.ts b/internal-packages/webhook-sources/src/handAuthored/telegram.ts new file mode 100644 index 0000000000..11cf970699 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/telegram.ts @@ -0,0 +1,146 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Telegram Bot API Update samples. An Update carries exactly one of several optional keys (message, + * edited_message, callback_query, inline_query, ...) and that key name IS the event type - there is no + * separate `type` field. Auth is a static shared secret compared against the + * X-Telegram-Bot-Api-Secret-Token header (set via `secret_token` on `setWebhook`), not an HMAC/Ed25519 + * signature, so these ship sample-only (no `presetId`). + */ +export const samples: SampleRecord[] = [ + { + provider: "telegram", + providerLabel: "Telegram", + eventType: "message", + name: "Message", + description: "An incoming text message in a private chat.", + body: { + update_id: 397587257, + message: { + message_id: 486, + from: { + id: 927485618, + is_bot: false, + first_name: "Priya", + last_name: "Nair", + username: "priyanair", + language_code: "en", + }, + chat: { + id: 927485618, + first_name: "Priya", + last_name: "Nair", + username: "priyanair", + type: "private", + }, + date: 1752562800, + text: "Hey, is the shipment on track for Friday?", + }, + }, + docsUrl: "https://core.telegram.org/bots/api#setwebhook", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "telegram", + providerLabel: "Telegram", + eventType: "edited_message", + name: "Edited message", + description: "The sender edited a previously sent message. `edit_date` marks when.", + body: { + update_id: 397587258, + edited_message: { + message_id: 486, + from: { + id: 927485618, + is_bot: false, + first_name: "Priya", + last_name: "Nair", + username: "priyanair", + language_code: "en", + }, + chat: { + id: 927485618, + first_name: "Priya", + last_name: "Nair", + username: "priyanair", + type: "private", + }, + date: 1752562800, + edit_date: 1752562845, + text: "Hey, is the shipment on track for next Friday?", + }, + }, + docsUrl: "https://core.telegram.org/bots/api#setwebhook", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "telegram", + providerLabel: "Telegram", + eventType: "callback_query", + name: "Callback query", + description: + "A user tapped an inline keyboard button. `data` is the button's opaque payload; `chat_instance` scopes the tap to the chat it happened in.", + body: { + update_id: 397587259, + callback_query: { + id: "4382174950123456789", + from: { + id: 927485618, + is_bot: false, + first_name: "Priya", + last_name: "Nair", + username: "priyanair", + language_code: "en", + }, + message: { + message_id: 490, + from: { + id: 6473920185, + is_bot: true, + first_name: "OrderStatusBot", + username: "order_status_bot", + }, + chat: { + id: 927485618, + first_name: "Priya", + last_name: "Nair", + username: "priyanair", + type: "private", + }, + date: 1752563100, + text: "Choose a shipment to track:", + }, + chat_instance: "-4827193650918273645", + data: "track_shipment:SHP-58213", + }, + }, + docsUrl: "https://core.telegram.org/bots/api#setwebhook", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "telegram", + providerLabel: "Telegram", + eventType: "inline_query", + name: "Inline query", + description: + "The user typed `@botname ...` in any chat; `query` is the text typed after the mention.", + body: { + update_id: 397587260, + inline_query: { + id: "1836452901847362910", + from: { + id: 927485618, + is_bot: false, + first_name: "Priya", + last_name: "Nair", + username: "priyanair", + language_code: "en", + }, + query: "shipment SHP-58213", + offset: "", + }, + }, + docsUrl: "https://core.telegram.org/bots/api#setwebhook", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/twilio.ts b/internal-packages/webhook-sources/src/handAuthored/twilio.ts new file mode 100644 index 0000000000..a57fba5482 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/twilio.ts @@ -0,0 +1,152 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Twilio samples. Requests are `application/x-www-form-urlencoded`, decoded here as a flat string + * key/value object (Twilio's own wire format has no nesting and every value is a string, including + * numeric-looking ones like `NumMedia`/`CallDuration`). No `presetId`: Twilio's `X-Twilio-Signature` + * scheme (HMAC-SHA1 over the webhook URL + sorted params, base64) does not match any of our verifier + * presets. Covers the two products that hit a Twilio webhook URL: inbound Messaging (SMS/MMS) and Voice + * (the initial call request plus status-callback requests). + */ +export const samples: SampleRecord[] = [ + { + provider: "twilio", + providerLabel: "Twilio", + eventType: "message.inbound", + name: "Inbound SMS", + description: + "A text message received by a Twilio number. `Body` is the message text; `SmsStatus` is always `received` for inbound messages.", + body: { + ToCountry: "US", + ToState: "CA", + SmsMessageSid: "SMe1e2b7c8f5a34d6d8b8c1a2b3c4d5e60", + NumMedia: "0", + ToCity: "SAN FRANCISCO", + FromZip: "94107", + FromState: "CA", + SmsStatus: "received", + FromCity: "SAN FRANCISCO", + Body: "Running about 10 min late, on my way!", + FromCountry: "US", + To: "+14155550142", + MessagingServiceSid: "MG9f8e7d6c5b4a3928f1e0d9c8b7a6f5e4", + ToZip: "94107", + NumSegments: "1", + MessageSid: "SMe1e2b7c8f5a34d6d8b8c1a2b3c4d5e60", + AccountSid: "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + SmsSid: "SMe1e2b7c8f5a34d6d8b8c1a2b3c4d5e60", + From: "+14155550198", + ApiVersion: "2010-04-01", + }, + docsUrl: "https://www.twilio.com/docs/messaging/guides/webhook-request", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "twilio", + providerLabel: "Twilio", + eventType: "message.inbound-mms", + name: "Inbound MMS", + description: + "A picture message received by a Twilio number. `NumMedia` is non-zero and each attachment is described by an indexed `MediaUrl{N}`/`MediaContentType{N}` pair.", + body: { + ToCountry: "US", + ToState: "CA", + SmsMessageSid: "MM2b3c4d5e6f7890a1b2c3d4e5f6071829", + NumMedia: "1", + ToCity: "SAN FRANCISCO", + FromZip: "94107", + FromState: "CA", + SmsStatus: "received", + FromCity: "SAN FRANCISCO", + Body: "Here's the receipt", + FromCountry: "US", + To: "+14155550142", + MessagingServiceSid: "MG9f8e7d6c5b4a3928f1e0d9c8b7a6f5e4", + ToZip: "94107", + NumSegments: "1", + MessageSid: "MM2b3c4d5e6f7890a1b2c3d4e5f6071829", + AccountSid: "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + SmsSid: "MM2b3c4d5e6f7890a1b2c3d4e5f6071829", + From: "+14155550198", + ApiVersion: "2010-04-01", + MediaContentType0: "image/jpeg", + MediaUrl0: + "https://api.twilio.com/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Messages/MM2b3c4d5e6f7890a1b2c3d4e5f6071829/Media/ME3c4d5e6f7890a1b2c3d4e5f60718293", + }, + docsUrl: "https://www.twilio.com/docs/messaging/guides/webhook-request", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "twilio", + providerLabel: "Twilio", + eventType: "call.inbound", + name: "Incoming call", + description: + "Twilio's request to the voice webhook URL when a call comes in. `CallStatus` is `ringing`; the app answers with TwiML in the response.", + body: { + CallSid: "CA3f9e8d7c6b5a4938271605f4e3d2c1b0a", + AccountSid: "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + From: "+14155550198", + To: "+14155550142", + CallStatus: "ringing", + ApiVersion: "2010-04-01", + Direction: "inbound", + FromCity: "SAN FRANCISCO", + FromState: "CA", + FromZip: "94107", + FromCountry: "US", + ToCity: "SAN FRANCISCO", + ToState: "CA", + ToZip: "94107", + ToCountry: "US", + }, + docsUrl: "https://www.twilio.com/docs/voice/twiml#twilios-request-to-your-application", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "twilio", + providerLabel: "Twilio", + eventType: "call.status-callback", + name: "Call status callback: completed", + description: + "A voice `StatusCallback` request after the call ended normally. `CallDuration` (seconds) is only present once `CallStatus` reaches a terminal value.", + body: { + CallSid: "CA3f9e8d7c6b5a4938271605f4e3d2c1b0a", + AccountSid: "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + From: "+14155550198", + To: "+14155550142", + CallStatus: "completed", + ApiVersion: "2010-04-01", + Direction: "inbound", + CallDuration: "187", + Timestamp: "Wed, 15 Jul 2026 14:32:18 +0000", + CallbackSource: "call-progress-events", + SequenceNumber: "3", + SipResponseCode: "200", + }, + docsUrl: "https://www.twilio.com/docs/usage/webhooks/voice-webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "twilio", + providerLabel: "Twilio", + eventType: "call.status-callback", + name: "Call status callback: no-answer", + description: + "A voice `StatusCallback` request for an outbound call nobody picked up. No `CallDuration` is sent for non-`completed` terminal statuses.", + body: { + CallSid: "CA7c6b5a4938271605f4e3d2c1b0a9f8e7d", + AccountSid: "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + From: "+14155550142", + To: "+14155550287", + CallStatus: "no-answer", + ApiVersion: "2010-04-01", + Direction: "outbound-api", + Timestamp: "Wed, 15 Jul 2026 09:04:52 +0000", + CallbackSource: "call-progress-events", + SequenceNumber: "2", + }, + docsUrl: "https://www.twilio.com/docs/usage/webhooks/voice-webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/typeform.ts b/internal-packages/webhook-sources/src/handAuthored/typeform.ts new file mode 100644 index 0000000000..e230fc70d4 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/typeform.ts @@ -0,0 +1,321 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Typeform samples. Typeform has a single webhook event, `form_response`, fired whenever a respondent + * completes a form; these vary the form shape (field types, hidden fields, calculated score) to cover + * the range of `definition.fields` / `answers` combinations integrators actually see. Typeform signs + * with a `Typeform-Signature: sha256=` header over the raw body, which doesn't match any of our + * verifier presets, so no `presetId` is set. + */ +export const samples: SampleRecord[] = [ + { + provider: "typeform", + providerLabel: "Typeform", + eventType: "form_response", + name: "Contact form submission", + description: "A respondent completed a basic contact form (name, email, message).", + body: { + event_id: "01HXQ3ZK9BCONTACT01", + event_type: "form_response", + form_response: { + form_id: "A1b2C3", + token: "6f3e8a1c9d2b47a0b6e5f1a2c3d4e5f6", + response_id: "6f3e8a1c9d2b47a0b6e5f1a2c3d4e5f6", + response_url: + "https://admin.typeform.com/form/A1b2C3/results?responseId=6f3e8a1c9d2b47a0b6e5f1a2c3d4e5f6", + landed_at: "2026-07-08T14:02:11Z", + submitted_at: "2026-07-08T14:04:47Z", + hidden: { + utm_source: "newsletter", + }, + definition: { + id: "A1b2C3", + title: "Contact us", + fields: [ + { + id: "JwWggjAKtOk1", + title: "What's your full name?", + type: "short_text", + ref: "full_name", + }, + { + id: "JwWggjAKtOk2", + title: "What's your email address?", + type: "email", + ref: "email", + }, + { id: "JwWggjAKtOk3", title: "How can we help?", type: "long_text", ref: "message" }, + ], + }, + answers: [ + { + type: "text", + text: "Priya Nair", + field: { id: "JwWggjAKtOk1", type: "short_text", ref: "full_name" }, + }, + { + type: "email", + email: "priya.nair@example.com", + field: { id: "JwWggjAKtOk2", type: "email", ref: "email" }, + }, + { + type: "text", + text: "Hi, I'd like a quote for the annual plan for a 40-person team.", + field: { id: "JwWggjAKtOk3", type: "long_text", ref: "message" }, + }, + ], + }, + }, + docsUrl: "https://www.typeform.com/developers/webhooks/secure-your-webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "typeform", + providerLabel: "Typeform", + eventType: "form_response", + name: "Customer satisfaction survey", + description: + "A CSAT/NPS survey response with a scored opinion-scale answer; `calculated.score` reflects the form's scoring configuration.", + body: { + event_id: "01HXQ3ZK9BCSAT0001", + event_type: "form_response", + form_response: { + form_id: "D4e5F6", + token: "9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d", + response_id: "9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d", + response_url: + "https://admin.typeform.com/form/D4e5F6/results?responseId=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d", + landed_at: "2026-07-09T09:11:02Z", + submitted_at: "2026-07-09T09:12:30Z", + calculated: { + score: 9, + }, + hidden: { + user_id: "cus_9f8e7d6c5b4a", + plan: "growth", + }, + definition: { + id: "D4e5F6", + title: "How are we doing?", + fields: [ + { + id: "Q7M2XAwY04d1", + title: "How likely are you to recommend us to a friend?", + type: "opinion_scale", + ref: "nps_score", + }, + { + id: "Q7M2XAwY04d2", + title: "Anything you'd like us to improve?", + type: "long_text", + ref: "improve_feedback", + }, + ], + }, + answers: [ + { + type: "number", + number: 9, + field: { id: "Q7M2XAwY04d1", type: "opinion_scale", ref: "nps_score" }, + }, + { + type: "text", + text: "The mobile app could load a bit faster on older devices.", + field: { id: "Q7M2XAwY04d2", type: "long_text", ref: "improve_feedback" }, + }, + ], + }, + }, + docsUrl: "https://www.typeform.com/developers/webhooks/secure-your-webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "typeform", + providerLabel: "Typeform", + eventType: "form_response", + name: "Job application", + description: + "A job application with a phone number, a file upload, and a single-choice referral field.", + body: { + event_id: "01HXQ3ZK9BJOB00001", + event_type: "form_response", + form_response: { + form_id: "G7h8I9", + token: "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d", + response_id: "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d", + response_url: + "https://admin.typeform.com/form/G7h8I9/results?responseId=1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d", + landed_at: "2026-07-10T16:40:05Z", + submitted_at: "2026-07-10T16:48:22Z", + hidden: { + req_id: "req_284910", + }, + definition: { + id: "G7h8I9", + title: "Senior Support Engineer application", + fields: [ + { id: "KpNvQ2rTsUx1", title: "Full name", type: "short_text", ref: "full_name" }, + { id: "KpNvQ2rTsUx2", title: "Phone number", type: "phone_number", ref: "phone" }, + { id: "KpNvQ2rTsUx3", title: "Upload your resume", type: "file_upload", ref: "resume" }, + { + id: "KpNvQ2rTsUx4", + title: "How did you hear about us?", + type: "multiple_choice", + ref: "referral_source", + }, + ], + }, + answers: [ + { + type: "text", + text: "Sam Okafor", + field: { id: "KpNvQ2rTsUx1", type: "short_text", ref: "full_name" }, + }, + { + type: "phone_number", + phone_number: "+15551234567", + field: { id: "KpNvQ2rTsUx2", type: "phone_number", ref: "phone" }, + }, + { + type: "file_url", + file_url: + "https://api.typeform.com/forms/G7h8I9/responses/1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d/files/KpNvQ2rTsUx3/0/samokafor-resume.pdf", + field: { id: "KpNvQ2rTsUx3", type: "file_upload", ref: "resume" }, + }, + { + type: "choice", + choice: { label: "LinkedIn" }, + field: { id: "KpNvQ2rTsUx4", type: "multiple_choice", ref: "referral_source" }, + }, + ], + }, + }, + docsUrl: "https://www.typeform.com/developers/webhooks/secure-your-webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "typeform", + providerLabel: "Typeform", + eventType: "form_response", + name: "Event registration", + description: + "An event RSVP with a single-choice ticket field, a multi-select dietary field, and a legal consent checkbox.", + body: { + event_id: "01HXQ3ZK9BEVT00001", + event_type: "form_response", + form_response: { + form_id: "J1k2L3", + token: "5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", + response_id: "5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", + response_url: + "https://admin.typeform.com/form/J1k2L3/results?responseId=5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", + landed_at: "2026-07-11T11:03:44Z", + submitted_at: "2026-07-11T11:06:19Z", + hidden: { + campaign: "summit-2026", + }, + definition: { + id: "J1k2L3", + title: "Register for the 2026 Summit", + fields: [ + { + id: "MqRwT4vXyZa1", + title: "Ticket type", + type: "multiple_choice", + ref: "ticket_type", + }, + { + id: "MqRwT4vXyZa2", + title: "Any dietary restrictions?", + type: "multiple_choice", + ref: "dietary_restrictions", + }, + { + id: "MqRwT4vXyZa3", + title: "I agree to the event terms", + type: "legal", + ref: "agree_terms", + }, + ], + }, + answers: [ + { + type: "choice", + choice: { label: "General admission" }, + field: { id: "MqRwT4vXyZa1", type: "multiple_choice", ref: "ticket_type" }, + }, + { + type: "choices", + choices: { labels: ["Vegetarian", "Nut allergy"] }, + field: { id: "MqRwT4vXyZa2", type: "multiple_choice", ref: "dietary_restrictions" }, + }, + { + type: "boolean", + boolean: true, + field: { id: "MqRwT4vXyZa3", type: "legal", ref: "agree_terms" }, + }, + ], + }, + }, + docsUrl: "https://www.typeform.com/developers/webhooks/secure-your-webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "typeform", + providerLabel: "Typeform", + eventType: "form_response", + name: "Product waitlist signup", + description: + "A waitlist signup with an email, a dropdown for company size, and a target-launch date.", + body: { + event_id: "01HXQ3ZK9BWAIT0001", + event_type: "form_response", + form_response: { + form_id: "N4o5P6", + token: "3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f", + response_id: "3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f", + response_url: + "https://admin.typeform.com/form/N4o5P6/results?responseId=3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f", + landed_at: "2026-07-12T08:20:00Z", + submitted_at: "2026-07-12T08:21:15Z", + hidden: { + utm_source: "product-hunt", + utm_campaign: "launch-waitlist", + }, + definition: { + id: "N4o5P6", + title: "Join the waitlist", + fields: [ + { id: "RtYuI7oPaSd1", title: "Work email", type: "email", ref: "work_email" }, + { id: "RtYuI7oPaSd2", title: "Company size", type: "dropdown", ref: "company_size" }, + { + id: "RtYuI7oPaSd3", + title: "When do you want to launch?", + type: "date", + ref: "target_launch_date", + }, + ], + }, + answers: [ + { + type: "email", + email: "jordan.lee@example.com", + field: { id: "RtYuI7oPaSd1", type: "email", ref: "work_email" }, + }, + { + type: "choice", + choice: { label: "11-50" }, + field: { id: "RtYuI7oPaSd2", type: "dropdown", ref: "company_size" }, + }, + { + type: "date", + date: "2026-09-01", + field: { id: "RtYuI7oPaSd3", type: "date", ref: "target_launch_date" }, + }, + ], + }, + }, + docsUrl: "https://www.typeform.com/developers/webhooks/secure-your-webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/vapi.ts b/internal-packages/webhook-sources/src/handAuthored/vapi.ts new file mode 100644 index 0000000000..00df728001 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/vapi.ts @@ -0,0 +1,160 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Vapi server webhook samples. Every message is wrapped in a top-level `message` object whose `type` + * field is the event discriminant (`eventType` mirrors it here). Vapi signs with a bespoke + * `x-vapi-signature` header (raw HMAC-SHA256 hex digest of the raw body, integrator-configured secret, + * no timestamp/prefix), which does not match any of our verifier presets, so these ship sample-only + * with no `presetId`. + */ +export const samples: SampleRecord[] = [ + { + provider: "vapi", + providerLabel: "Vapi", + eventType: "status-update", + name: "Status update", + description: "The call transitioned to a new status, here from ringing to in-progress.", + body: { + message: { + type: "status-update", + status: "in-progress", + timestamp: 1783533600123, + call: { + id: "call_29a1f6a0-6b3d-4e2a-9c7d-8f5e6a2b1c40", + orgId: "org_4f6e2a91-7c3b-4d5a-9e1f-2b8c6d4a3f90", + type: "outboundPhoneCall", + status: "in-progress", + createdAt: "2026-07-14T18:59:57.201Z", + updatedAt: "2026-07-14T19:00:00.123Z", + assistantId: "asst_7d4c2b91-3a6e-4f8d-b1c5-9e2a7d6c4b30", + phoneNumberId: "pn_5b3d8a92-1c4e-4a7f-9d6b-2e8c1a4f7b60", + customer: { + number: "+14155550142", + }, + }, + }, + }, + docsUrl: "https://docs.vapi.ai/server-url/events", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "vapi", + providerLabel: "Vapi", + eventType: "transcript", + name: "Transcript", + description: "A final transcript chunk from the caller's turn.", + body: { + message: { + type: "transcript", + role: "user", + transcriptType: "final", + transcript: "I need to reschedule my appointment to next Tuesday afternoon.", + call: { + id: "call_29a1f6a0-6b3d-4e2a-9c7d-8f5e6a2b1c40", + orgId: "org_4f6e2a91-7c3b-4d5a-9e1f-2b8c6d4a3f90", + type: "outboundPhoneCall", + status: "in-progress", + assistantId: "asst_7d4c2b91-3a6e-4f8d-b1c5-9e2a7d6c4b30", + customer: { + number: "+14155550142", + }, + }, + }, + }, + docsUrl: "https://docs.vapi.ai/server-url/events", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "vapi", + providerLabel: "Vapi", + eventType: "tool-calls", + name: "Tool calls", + description: + "The assistant invoked a configured function tool; the server is expected to respond with the tool's result.", + body: { + message: { + type: "tool-calls", + toolCallList: [ + { + id: "tool_call_8c2d6a91-4b7e-4f3a-9d1c-6e8a2b4f7c50", + type: "function", + function: { + name: "reschedule_appointment", + arguments: '{"appointmentId":"appt_5120","newTime":"2026-07-21T14:00:00-07:00"}', + }, + }, + ], + call: { + id: "call_29a1f6a0-6b3d-4e2a-9c7d-8f5e6a2b1c40", + orgId: "org_4f6e2a91-7c3b-4d5a-9e1f-2b8c6d4a3f90", + type: "outboundPhoneCall", + status: "in-progress", + assistantId: "asst_7d4c2b91-3a6e-4f8d-b1c5-9e2a7d6c4b30", + customer: { + number: "+14155550142", + }, + }, + }, + }, + docsUrl: "https://docs.vapi.ai/server-url/events", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "vapi", + providerLabel: "Vapi", + eventType: "end-of-call-report", + name: "End of call report", + description: + "Sent once the call ends, carrying the ended reason plus the full artifact (transcript, messages, recording).", + body: { + message: { + type: "end-of-call-report", + endedReason: "customer-ended-call", + durationSeconds: 187.4, + cost: 0.0932, + call: { + id: "call_29a1f6a0-6b3d-4e2a-9c7d-8f5e6a2b1c40", + orgId: "org_4f6e2a91-7c3b-4d5a-9e1f-2b8c6d4a3f90", + type: "outboundPhoneCall", + status: "ended", + createdAt: "2026-07-14T18:59:57.201Z", + updatedAt: "2026-07-14T19:03:04.612Z", + assistantId: "asst_7d4c2b91-3a6e-4f8d-b1c5-9e2a7d6c4b30", + customer: { + number: "+14155550142", + }, + }, + artifact: { + transcript: + "AI: Hi, this is Riley from Sunrise Dental. How can I help?\nUser: I need to reschedule my appointment to next Tuesday afternoon.\nAI: Sure, I've moved your appointment to Tuesday at 2pm.", + messages: [ + { + role: "assistant", + message: "Hi, this is Riley from Sunrise Dental. How can I help?", + time: 1783533601400, + endTime: 1783533604100, + }, + { + role: "user", + message: "I need to reschedule my appointment to next Tuesday afternoon.", + time: 1783533605200, + endTime: 1783533608700, + }, + { + role: "assistant", + message: "Sure, I've moved your appointment to Tuesday at 2pm.", + time: 1783533609300, + endTime: 1783533612100, + }, + ], + recording: { + stereoUrl: + "https://storage.vapi.ai/recordings/call_29a1f6a0-6b3d-4e2a-9c7d-8f5e6a2b1c40.wav", + }, + }, + }, + }, + docsUrl: "https://docs.vapi.ai/server-url/events", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/vercel.ts b/internal-packages/webhook-sources/src/handAuthored/vercel.ts new file mode 100644 index 0000000000..43254a9bad --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/vercel.ts @@ -0,0 +1,192 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Vercel account/integration webhook samples. Every event shares the envelope + * `{ id, type, createdAt, region, payload }`; `type` is the discriminant. Vercel signs with a bespoke + * `x-vercel-signature` HMAC-SHA1-over-raw-body scheme (no preset match), so no `presetId` is set on + * any sample here. `payload.deployment.meta` is documented only as an opaque metadata map; the values + * below model the common git-integration keys Vercel populates for a GitHub-linked project. + */ +export const samples: SampleRecord[] = [ + { + provider: "vercel", + providerLabel: "Vercel", + eventType: "deployment.created", + name: "Deployment created", + description: "A new deployment was initiated, e.g. from a git push or `vercel deploy`.", + body: { + id: "5FQ3bE5aQhZ1Vv2c4vXYzP1LhqoT", + type: "deployment.created", + createdAt: 1752520920123, + region: null, + payload: { + team: { id: "team_LLHUOMOoDlqOp8wPE4kFo9pE" }, + user: { id: "K4amb7K9dhQC1c3Vg6M4VZur" }, + alias: ["storefront.vercel.app", "storefront-git-main-acme.vercel.app"], + deployment: { + id: "dpl_5m3gYqz6JhKGLnvGGqSpVsXQ2vHz", + meta: { + githubCommitAuthorLogin: "priyanair", + githubCommitMessage: "fix: correct cart subtotal rounding", + githubCommitOrg: "acme", + githubCommitRef: "main", + githubCommitRepo: "storefront", + githubCommitRepoId: "612345678", + githubCommitSha: "8f3a1c2e9b4d7f6a2c8e1b5d9a3f7c2e6b8d4a1f", + githubDeployment: "1", + githubOrg: "acme", + githubRepo: "storefront", + githubRepoOwnerType: "organization", + }, + url: "storefront-git-main-acme.vercel.app", + name: "storefront", + }, + links: { + deployment: "https://vercel.com/acme/storefront/5m3gYqz6JhKGLnvGGqSpVsXQ2vHz", + project: "https://vercel.com/acme/storefront", + }, + target: "production", + project: { id: "prj_8Xc1kQnR3vTz9YbW2fA6mLhE" }, + plan: "pro", + regions: ["iad1"], + }, + }, + docsUrl: "https://vercel.com/docs/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "vercel", + providerLabel: "Vercel", + eventType: "deployment.succeeded", + name: "Deployment succeeded", + description: "A deployment finished building and all blocking checks passed.", + body: { + id: "9Kd2wR7fXmQ3Vv8c5bYpZ4LhqrN", + type: "deployment.succeeded", + createdAt: 1752520998456, + region: null, + payload: { + team: { id: "team_LLHUOMOoDlqOp8wPE4kFo9pE" }, + user: { id: "K4amb7K9dhQC1c3Vg6M4VZur" }, + deployment: { + id: "dpl_5m3gYqz6JhKGLnvGGqSpVsXQ2vHz", + meta: { + githubCommitAuthorLogin: "priyanair", + githubCommitMessage: "fix: correct cart subtotal rounding", + githubCommitOrg: "acme", + githubCommitRef: "main", + githubCommitRepo: "storefront", + githubCommitRepoId: "612345678", + githubCommitSha: "8f3a1c2e9b4d7f6a2c8e1b5d9a3f7c2e6b8d4a1f", + githubDeployment: "1", + githubOrg: "acme", + githubRepo: "storefront", + githubRepoOwnerType: "organization", + }, + url: "storefront-git-main-acme.vercel.app", + name: "storefront", + }, + links: { + deployment: "https://vercel.com/acme/storefront/5m3gYqz6JhKGLnvGGqSpVsXQ2vHz", + project: "https://vercel.com/acme/storefront", + }, + target: "production", + project: { id: "prj_8Xc1kQnR3vTz9YbW2fA6mLhE" }, + plan: "pro", + regions: ["iad1"], + }, + }, + docsUrl: "https://vercel.com/docs/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "vercel", + providerLabel: "Vercel", + eventType: "deployment.error", + name: "Deployment error", + description: "A deployment failed to build.", + body: { + id: "2Hn8pL4dWkR6Tz1c9vXmA3JhqsE", + type: "deployment.error", + createdAt: 1752521045789, + region: null, + payload: { + team: { id: "team_LLHUOMOoDlqOp8wPE4kFo9pE" }, + user: { id: "6b1e9d3a4c7f4b2e8a9d1c6f3b8e" }, + deployment: { + id: "dpl_7qXn2vBpKzGjWm4cRfLtYs9AeHd6", + meta: { + githubCommitAuthorLogin: "marcuswebb", + githubCommitMessage: "feat: add checkout address autocomplete", + githubCommitOrg: "acme", + githubCommitRef: "feature/checkout-autocomplete", + githubCommitRepo: "storefront", + githubCommitRepoId: "612345678", + githubCommitSha: "3c7e9b1a5d8f2c6e4a9b7d3f1c8e5a2b6d9f4c1e", + githubDeployment: "1", + githubOrg: "acme", + githubRepo: "storefront", + githubRepoOwnerType: "organization", + }, + url: "storefront-git-checkout-autocomplete-acme.vercel.app", + name: "storefront", + }, + links: { + deployment: "https://vercel.com/acme/storefront/7qXn2vBpKzGjWm4cRfLtYs9AeHd6", + project: "https://vercel.com/acme/storefront", + }, + target: null, + project: { id: "prj_8Xc1kQnR3vTz9YbW2fA6mLhE" }, + plan: "pro", + regions: ["iad1"], + }, + }, + docsUrl: "https://vercel.com/docs/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "vercel", + providerLabel: "Vercel", + eventType: "deployment.canceled", + name: "Deployment canceled", + description: "A deployment was canceled, e.g. superseded by a newer push to the same branch.", + body: { + id: "4Mp6rT9fYnQ2Vx3c7bZoW5LhqtF", + type: "deployment.canceled", + createdAt: 1752521102345, + region: null, + payload: { + team: { id: "team_LLHUOMOoDlqOp8wPE4kFo9pE" }, + user: { id: "6b1e9d3a4c7f4b2e8a9d1c6f3b8e" }, + deployment: { + id: "dpl_1bYw6qGkPzRjNm8cVfEtLs4AeXd9", + meta: { + githubCommitAuthorLogin: "marcuswebb", + githubCommitMessage: "feat: add checkout address autocomplete (v2)", + githubCommitOrg: "acme", + githubCommitRef: "feature/checkout-autocomplete", + githubCommitRepo: "storefront", + githubCommitRepoId: "612345678", + githubCommitSha: "9a4f2c8e6b1d3a7f5c9e2b8d4a6f1c3e7b9d5a2f", + githubDeployment: "1", + githubOrg: "acme", + githubRepo: "storefront", + githubRepoOwnerType: "organization", + }, + url: "storefront-git-checkout-autocomplete-acme.vercel.app", + name: "storefront", + }, + links: { + deployment: "https://vercel.com/acme/storefront/1bYw6qGkPzRjNm8cVfEtLs4AeXd9", + project: "https://vercel.com/acme/storefront", + }, + target: null, + project: { id: "prj_8Xc1kQnR3vTz9YbW2fA6mLhE" }, + plan: "pro", + regions: ["iad1"], + }, + }, + docsUrl: "https://vercel.com/docs/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/whatsapp.ts b/internal-packages/webhook-sources/src/handAuthored/whatsapp.ts new file mode 100644 index 0000000000..43c67e9e45 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/whatsapp.ts @@ -0,0 +1,298 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * WhatsApp Cloud API samples. Meta signs webhooks with `X-Hub-Signature-256: sha256=` + * HMAC-SHA256 over the raw body, matching `presetId: "github"` exactly. `eventType` is set to + * "messages" or "statuses" per the row hypothesis, but note this is derived from which key + * (`value.messages` vs `value.statuses`) is present in the body, not from `changes[].field` - + * that field is always the literal string "messages" in real Meta payloads, for both families. + */ +export const samples: SampleRecord[] = [ + { + provider: "whatsapp", + providerLabel: "WhatsApp (Meta)", + presetId: "github", + eventType: "messages", + name: "Inbound text message", + description: "A text message sent by a WhatsApp user to a business phone number.", + body: { + object: "whatsapp_business_account", + entry: [ + { + id: "102290129340398", + changes: [ + { + value: { + messaging_product: "whatsapp", + metadata: { + display_phone_number: "15550783881", + phone_number_id: "106540352242922", + }, + contacts: [{ profile: { name: "Sheena Nelson" }, wa_id: "16505551234" }], + messages: [ + { + from: "16505551234", + id: "wamid.HBgLMTY1MDM4Nzk0MzkVAgASGBQzQTRBNjU5OUFFRTAzODEwMTQ0RgA=", + timestamp: "1751416383", + type: "text", + text: { body: "Does it come in another color?" }, + }, + ], + }, + field: "messages", + }, + ], + }, + ], + }, + docsUrl: + "https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "whatsapp", + providerLabel: "WhatsApp (Meta)", + presetId: "github", + eventType: "messages", + name: "Inbound image message", + description: "An image message with a caption sent by a WhatsApp user.", + body: { + object: "whatsapp_business_account", + entry: [ + { + id: "102290129340398", + changes: [ + { + value: { + messaging_product: "whatsapp", + metadata: { + display_phone_number: "15550783881", + phone_number_id: "106540352242922", + }, + contacts: [{ profile: { name: "Priya Shah" }, wa_id: "16505552222" }], + messages: [ + { + from: "16505552222", + id: "wamid.HBgLMTY1MDM4Nzk0MzkVAgASGBQzQTZBQzg0MzQ4QjRCM0NGNkVGOQA=", + timestamp: "1751426500", + type: "image", + image: { + id: "1284772819503647", + mime_type: "image/jpeg", + sha256: "k7f2mA9wQeR3vN0pLxT8jZ4hC1sD6bU5nY2iG8oV7yE=", + caption: "Here's a photo of the item", + }, + }, + ], + }, + field: "messages", + }, + ], + }, + ], + }, + docsUrl: + "https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/media", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "whatsapp", + providerLabel: "WhatsApp (Meta)", + presetId: "github", + eventType: "messages", + name: "Inbound interactive button reply", + description: "A user tapping a quick-reply button from a previous business-initiated message.", + body: { + object: "whatsapp_business_account", + entry: [ + { + id: "102290129340398", + changes: [ + { + value: { + messaging_product: "whatsapp", + metadata: { + display_phone_number: "15550783881", + phone_number_id: "106540352242922", + }, + contacts: [{ profile: { name: "Sheena Nelson" }, wa_id: "16505551234" }], + messages: [ + { + context: { + from: "15550783881", + id: "wamid.HBgLMTY1MDM4Nzk0MzkVAgARGBI3MEM2RUJFNkI0RENGQTVDRjUA", + }, + from: "16505551234", + id: "wamid.HBgLMTY1MDM4Nzk0MzkVAgASGBQzQTZBQzg0MzQ4QjRCM0NGNkVGOAA=", + timestamp: "1751425136", + type: "interactive", + interactive: { + type: "button_reply", + button_reply: { id: "cancel-order-button", title: "Cancel order" }, + }, + }, + ], + }, + field: "messages", + }, + ], + }, + ], + }, + docsUrl: + "https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/interactive", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "whatsapp", + providerLabel: "WhatsApp (Meta)", + presetId: "github", + eventType: "statuses", + name: "Message status: sent", + description: + "Delivery status update after Meta accepts a business-initiated message for sending.", + body: { + object: "whatsapp_business_account", + entry: [ + { + id: "102290129340398", + changes: [ + { + value: { + messaging_product: "whatsapp", + metadata: { + display_phone_number: "15550783881", + phone_number_id: "106540352242922", + }, + statuses: [ + { + id: "wamid.HBgLMTY1MDM4Nzk0MzkVAgASGBQzQUFERjg0NDEzNDdFODU3MUMxMQA=", + status: "sent", + timestamp: "1751430000", + recipient_id: "16505551234", + conversation: { + id: "82c14d6bd5407799e66f64d1b338e568", + expiration_timestamp: "1751516400", + origin: { type: "service" }, + }, + pricing: { + billable: true, + pricing_model: "PMP", + type: "regular", + category: "service", + }, + }, + ], + }, + field: "messages", + }, + ], + }, + ], + }, + docsUrl: + "https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/status", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "whatsapp", + providerLabel: "WhatsApp (Meta)", + presetId: "github", + eventType: "statuses", + name: "Message status: delivered", + description: "Delivery status update once the message reaches the recipient's device.", + body: { + object: "whatsapp_business_account", + entry: [ + { + id: "102290129340398", + changes: [ + { + value: { + messaging_product: "whatsapp", + metadata: { + display_phone_number: "15550783881", + phone_number_id: "106540352242922", + }, + statuses: [ + { + id: "wamid.HBgLMTY1MDM4Nzk0MzkVAgASGBQzQUFERjg0NDEzNDdFODU3MUMxMAA=", + status: "delivered", + timestamp: "1751430074", + recipient_id: "16505553333", + conversation: { + id: "72b14d6bd5407799e66f64d1b338e567", + expiration_timestamp: "1751516480", + origin: { type: "service" }, + }, + pricing: { + billable: true, + pricing_model: "PMP", + type: "regular", + category: "service", + }, + }, + ], + }, + field: "messages", + }, + ], + }, + ], + }, + docsUrl: + "https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/status", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "whatsapp", + providerLabel: "WhatsApp (Meta)", + presetId: "github", + eventType: "statuses", + name: "Message status: failed", + description: "Delivery status update when a message fails to send, with error detail.", + body: { + object: "whatsapp_business_account", + entry: [ + { + id: "102290129340398", + changes: [ + { + value: { + messaging_product: "whatsapp", + metadata: { + display_phone_number: "15550783881", + phone_number_id: "106540352242922", + }, + statuses: [ + { + id: "wamid.HBgLMTY1MDM4Nzk0MzkVAgASGBQzQUFERjg0NDEzNDdFODU3MUMxMgA=", + status: "failed", + timestamp: "1751431301", + recipient_id: "16505554444", + errors: [ + { + code: 131026, + title: "Message undeliverable", + message: "Message undeliverable", + error_data: { + details: + "Message failed to send because more than 24 hours have passed since the customer last replied to this number.", + }, + href: "https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/", + }, + ], + }, + ], + }, + field: "messages", + }, + ], + }, + ], + }, + docsUrl: + "https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/status", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/workos.ts b/internal-packages/webhook-sources/src/handAuthored/workos.ts new file mode 100644 index 0000000000..8dff19f550 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/workos.ts @@ -0,0 +1,167 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * WorkOS samples. WorkOS signs webhooks with its own `WorkOS-Signature: t=,v1=` scheme (HMAC-SHA256 + * over `{t}.{rawBody}`), which does not match our `stripe` preset's header name, so these ship without + * a `presetId`. Every event shares the envelope `{ id, event, data, created_at, context }`. + */ +export const samples: SampleRecord[] = [ + { + provider: "workos", + providerLabel: "WorkOS", + eventType: "dsync.user.created", + name: "Directory user created", + description: "A new user appeared in a connected Directory Sync (SCIM) provider.", + body: { + id: "event_01J4QF7R2N8T6R3P0M5NDS8WYA", + event: "dsync.user.created", + data: { + object: "directory_user", + id: "directory_user_01J4QF6VN8T6R3P0M5NDS8WYB", + directory_id: "directory_01J4QF5PM7S5R2P9M4NDS6WVC", + organization_id: "org_01J4QF4ZK6R4Q1P8M3NDS5WUD", + idp_id: "2839573", + email: "priya.nair@example.com", + first_name: "Priya", + last_name: "Nair", + emails: [{ type: "work", value: "priya.nair@example.com", primary: true }], + job_title: "Staff Engineer", + username: "priya.nair", + state: "active", + custom_attributes: { department: "Engineering" }, + raw_attributes: { + schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], + userName: "priya.nair", + active: true, + }, + role: { slug: "member" }, + roles: [{ slug: "member" }], + created_at: "2026-07-13T09:20:00.000Z", + updated_at: "2026-07-13T09:20:00.000Z", + }, + created_at: "2026-07-13T09:20:01.104Z", + context: { client_id: "client_01J4QF3XH5Q3P0M9NDS4WTYE" }, + }, + docsUrl: "https://workos.com/docs/events/data-syncing/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "workos", + providerLabel: "WorkOS", + eventType: "dsync.group.created", + name: "Directory group created", + description: "A new group appeared in a connected Directory Sync provider.", + body: { + id: "event_01J4QFB9P9U7S4Q1N6PDT9XZF", + event: "dsync.group.created", + data: { + object: "directory_group", + id: "directory_group_01J4QFA3Q0V8T5R2P7PDT0YAG", + idp_id: "9284756", + directory_id: "directory_01J4QF5PM7S5R2P9M4NDS6WVC", + organization_id: "org_01J4QF4ZK6R4Q1P8M3NDS5WUD", + name: "Engineering", + raw_attributes: { + schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"], + displayName: "Engineering", + }, + created_at: "2026-07-13T09:21:15.000Z", + updated_at: "2026-07-13T09:21:15.000Z", + }, + created_at: "2026-07-13T09:21:15.892Z", + context: { client_id: "client_01J4QF3XH5Q3P0M9NDS4WTYE" }, + }, + docsUrl: "https://workos.com/docs/events/data-syncing/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "workos", + providerLabel: "WorkOS", + eventType: "connection.activated", + name: "SSO connection activated", + description: "An SSO connection finished setup and is now able to authenticate users.", + body: { + id: "event_01J4QFD2R1W9T6S3P8QEU1ZBH", + event: "connection.activated", + data: { + object: "connection", + id: "conn_01J4QFC5S2X0U7T4Q9QEU2ACJ", + organization_id: "org_01J4QF4ZK6R4Q1P8M3NDS5WUD", + connection_type: "OktaSAML", + name: "Acme Corp Okta", + state: "active", + domains: [ + { + object: "connection_domain", + id: "conn_domain_01J4QFBWT3Y1V8U5R0RFV3BDK", + domain: "acmecorp.com", + }, + ], + created_at: "2026-07-10T16:04:22.000Z", + updated_at: "2026-07-13T09:22:40.000Z", + }, + created_at: "2026-07-13T09:22:40.331Z", + context: { client_id: "client_01J4QF3XH5Q3P0M9NDS4WTYE" }, + }, + docsUrl: "https://workos.com/docs/events/data-syncing/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "workos", + providerLabel: "WorkOS", + eventType: "user.created", + name: "User created", + description: "A new AuthKit / User Management user signed up or was invited.", + body: { + id: "event_01J4QFF6T4Z2V9U6S1RFV4CEM", + event: "user.created", + data: { + object: "user", + id: "user_01J4QFE9U5A3W0V7T2SGW5DFN", + email: "jordan.lee@example.com", + email_verified: false, + first_name: "Jordan", + last_name: "Lee", + profile_picture_url: null, + external_id: null, + metadata: {}, + last_sign_in_at: null, + created_at: "2026-07-13T09:23:55.000Z", + updated_at: "2026-07-13T09:23:55.000Z", + }, + created_at: "2026-07-13T09:23:55.410Z", + context: { client_id: "client_01J4QF3XH5Q3P0M9NDS4WTYE" }, + }, + docsUrl: "https://workos.com/docs/events/data-syncing/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "workos", + providerLabel: "WorkOS", + eventType: "user.deleted", + name: "User deleted", + description: "A User Management user was permanently deleted.", + body: { + id: "event_01J4QFH8V6B4X1W8U3THX6DGP", + event: "user.deleted", + data: { + object: "user", + id: "user_01J4QFG1W7C5Y2X9V4THX7EHQ", + email: "sam.okafor@example.com", + email_verified: true, + first_name: "Sam", + last_name: "Okafor", + profile_picture_url: null, + external_id: null, + metadata: {}, + last_sign_in_at: "2026-07-10T14:02:33.000Z", + created_at: "2026-05-02T11:10:00.000Z", + updated_at: "2026-07-13T09:25:03.000Z", + }, + created_at: "2026-07-13T09:25:03.777Z", + context: { client_id: "client_01J4QF3XH5Q3P0M9NDS4WTYE" }, + }, + docsUrl: "https://workos.com/docs/events/data-syncing/webhooks", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/zendesk.ts b/internal-packages/webhook-sources/src/handAuthored/zendesk.ts new file mode 100644 index 0000000000..b709db3203 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/zendesk.ts @@ -0,0 +1,146 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Zendesk samples. Zendesk webhooks are driven by triggers/automations where an admin authors the JSON + * body from content placeholders (e.g. `{{ticket.id}}`, `{{ticket.assignee.email}}`), so there is no + * fixed payload schema across accounts - these model the common flattened "ticket" shape used in + * Zendesk's own webhook examples. No `presetId`: the signing scheme (base64 HMAC-SHA256 over + * `timestamp + body`, headers `X-Zendesk-Webhook-Signature` / `X-Zendesk-Webhook-Timestamp`) matches + * none of our presets. + */ +export const samples: SampleRecord[] = [ + { + provider: "zendesk", + providerLabel: "Zendesk", + eventType: "ticket.created", + name: "Ticket created", + description: "A new support ticket was created and is awaiting first response.", + body: { + ticket_id: 30482, + ticket_url: "https://republic-widgets.zendesk.com/agent/tickets/30482", + subject: "Unable to reset my password", + description: "I tried the password reset link three times and never received the email.", + status: "new", + priority: "normal", + type: "question", + tags: ["password", "login"], + group: "Support Tier 1", + organization: "Acme Corp", + requester_name: "Jamie Chen", + requester_email: "jamie.chen@example.com", + assignee_name: "", + assignee_email: "", + created_at: "2026-07-10T14:02:31Z", + updated_at: "2026-07-10T14:02:31Z", + }, + docsUrl: "https://developer.zendesk.com/documentation/webhooks/verifying/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "zendesk", + providerLabel: "Zendesk", + eventType: "ticket.updated", + name: "Ticket assigned", + description: "The ticket was picked up and assigned to an agent; status moved to open.", + body: { + ticket_id: 30482, + ticket_url: "https://republic-widgets.zendesk.com/agent/tickets/30482", + subject: "Unable to reset my password", + description: "I tried the password reset link three times and never received the email.", + status: "open", + priority: "normal", + type: "question", + tags: ["password", "login"], + group: "Support Tier 1", + organization: "Acme Corp", + requester_name: "Jamie Chen", + requester_email: "jamie.chen@example.com", + assignee_name: "Morgan Reyes", + assignee_email: "morgan.reyes@republic-widgets.example.com", + created_at: "2026-07-10T14:02:31Z", + updated_at: "2026-07-10T14:05:47Z", + }, + docsUrl: "https://developer.zendesk.com/documentation/webhooks/verifying/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "zendesk", + providerLabel: "Zendesk", + eventType: "ticket.updated", + name: "Ticket priority escalated", + description: + "Priority was raised to urgent after the requester replied that billing was affected.", + body: { + ticket_id: 30510, + ticket_url: "https://republic-widgets.zendesk.com/agent/tickets/30510", + subject: "Duplicate charge on last invoice", + description: "My card was charged twice for the July invoice, please refund the duplicate.", + status: "open", + priority: "urgent", + type: "incident", + tags: ["billing", "refund"], + group: "Billing", + organization: "Acme Corp", + requester_name: "Dana Okafor", + requester_email: "dana.okafor@example.com", + assignee_name: "Priya Nair", + assignee_email: "priya.nair@republic-widgets.example.com", + created_at: "2026-07-11T09:15:02Z", + updated_at: "2026-07-11T09:41:18Z", + }, + docsUrl: "https://developer.zendesk.com/documentation/webhooks/verifying/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "zendesk", + providerLabel: "Zendesk", + eventType: "ticket.updated", + name: "Public reply added", + description: "An agent posted a public comment; `latest_comment` carries the reply text.", + body: { + ticket_id: 30510, + ticket_url: "https://republic-widgets.zendesk.com/agent/tickets/30510", + subject: "Duplicate charge on last invoice", + status: "pending", + priority: "urgent", + type: "incident", + requester_name: "Dana Okafor", + requester_email: "dana.okafor@example.com", + assignee_name: "Priya Nair", + assignee_email: "priya.nair@republic-widgets.example.com", + latest_comment: + "Thanks for flagging this - I've refunded the duplicate charge, you should see it in 3-5 business days.", + latest_comment_is_public: true, + created_at: "2026-07-11T09:15:02Z", + updated_at: "2026-07-11T10:02:55Z", + }, + docsUrl: "https://developer.zendesk.com/documentation/webhooks/verifying/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "zendesk", + providerLabel: "Zendesk", + eventType: "ticket.updated", + name: "Ticket solved", + description: "The ticket reached a terminal state after the requester confirmed the fix.", + body: { + ticket_id: 30482, + ticket_url: "https://republic-widgets.zendesk.com/agent/tickets/30482", + subject: "Unable to reset my password", + status: "solved", + priority: "normal", + type: "question", + tags: ["password", "login"], + group: "Support Tier 1", + organization: "Acme Corp", + requester_name: "Jamie Chen", + requester_email: "jamie.chen@example.com", + assignee_name: "Morgan Reyes", + assignee_email: "morgan.reyes@republic-widgets.example.com", + created_at: "2026-07-10T14:02:31Z", + updated_at: "2026-07-10T15:30:09Z", + }, + docsUrl: "https://developer.zendesk.com/documentation/webhooks/verifying/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/handAuthored/zoom.ts b/internal-packages/webhook-sources/src/handAuthored/zoom.ts new file mode 100644 index 0000000000..3e13798f86 --- /dev/null +++ b/internal-packages/webhook-sources/src/handAuthored/zoom.ts @@ -0,0 +1,194 @@ +import { type SampleRecord } from "../sampleRecord.js"; + +/** + * Zoom samples. Zoom signs webhooks with its own scheme (`x-zm-signature: v0=` over + * `v0:{x-zm-request-timestamp}:{rawBody}`, HMAC-SHA256), which does not match any of our verifier + * presets, so these ship sample-only (no `presetId`). Every event is a thin envelope + * (`event`, `event_ts`, `payload: { account_id, object }`) where `object` carries the meeting or + * recording resource. + */ +export const samples: SampleRecord[] = [ + { + provider: "zoom", + providerLabel: "Zoom", + eventType: "meeting.started", + name: "Meeting started", + description: "A scheduled meeting began. `payload.object.id` is the meeting id.", + body: { + event: "meeting.started", + event_ts: 1752562800000, + payload: { + account_id: "AAAAAABBBBBCCCCDDDD", + object: { + id: "84392028671", + uuid: "4nR0i/L9Q2mfrfXtEWMnGA==", + host_id: "z8yCxTTTTKWIFKy9d3VA", + topic: "Weekly Product Sync", + type: 8, + start_time: "2026-07-15T14:00:00Z", + timezone: "America/Los_Angeles", + duration: 60, + }, + }, + }, + docsUrl: "https://developers.zoom.us/docs/api/webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "zoom", + providerLabel: "Zoom", + eventType: "meeting.ended", + name: "Meeting ended", + description: "A meeting finished. `payload.object.end_time` marks when it closed.", + body: { + event: "meeting.ended", + event_ts: 1752566460000, + payload: { + account_id: "AAAAAABBBBBCCCCDDDD", + object: { + id: "84392028671", + uuid: "4nR0i/L9Q2mfrfXtEWMnGA==", + host_id: "z8yCxTTTTKWIFKy9d3VA", + topic: "Weekly Product Sync", + type: 8, + start_time: "2026-07-15T14:00:00Z", + end_time: "2026-07-15T15:01:00Z", + duration: 60, + }, + }, + }, + docsUrl: "https://developers.zoom.us/docs/api/webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "zoom", + providerLabel: "Zoom", + eventType: "meeting.participant_joined", + name: "Participant joined", + description: + "A participant joined the meeting. `payload.object.participant` carries their meeting-scoped id and join time.", + body: { + event: "meeting.participant_joined", + event_ts: 1752562860000, + payload: { + account_id: "AAAAAABBBBBCCCCDDDD", + object: { + id: "84392028671", + uuid: "4nR0i/L9Q2mfrfXtEWMnGA==", + host_id: "z8yCxTTTTKWIFKy9d3VA", + topic: "Weekly Product Sync", + type: 8, + start_time: "2026-07-15T14:00:00Z", + timezone: "America/Los_Angeles", + duration: 60, + participant: { + user_id: "16779264", + user_name: "Priya Nair", + id: "hK8fgZVOQkOKgOTgMFM2Jw", + join_time: "2026-07-15T14:01:00Z", + email: "priya.nair@example.com", + }, + }, + }, + }, + docsUrl: "https://developers.zoom.us/docs/api/webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "zoom", + providerLabel: "Zoom", + eventType: "meeting.participant_left", + name: "Participant left", + description: + "A participant left the meeting. `payload.object.participant.leave_reason` explains why (e.g. left voluntarily, disconnected).", + body: { + event: "meeting.participant_left", + event_ts: 1752565800000, + payload: { + account_id: "AAAAAABBBBBCCCCDDDD", + object: { + id: "84392028671", + uuid: "4nR0i/L9Q2mfrfXtEWMnGA==", + host_id: "z8yCxTTTTKWIFKy9d3VA", + topic: "Weekly Product Sync", + type: 8, + start_time: "2026-07-15T14:00:00Z", + timezone: "America/Los_Angeles", + duration: 60, + participant: { + user_id: "16779264", + user_name: "Priya Nair", + id: "hK8fgZVOQkOKgOTgMFM2Jw", + leave_time: "2026-07-15T14:50:00Z", + leave_reason: "left the meeting", + email: "priya.nair@example.com", + }, + }, + }, + }, + docsUrl: "https://developers.zoom.us/docs/api/webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, + { + provider: "zoom", + providerLabel: "Zoom", + eventType: "recording.completed", + name: "Recording completed", + description: + "Cloud recording processing finished. `payload.object.recording_files` lists each file with its `download_url`; `payload.object.download_token` is a short-lived bearer token for fetching them.", + body: { + event: "recording.completed", + event_ts: 1752570120000, + payload: { + account_id: "AAAAAABBBBBCCCCDDDD", + object: { + id: "84392028671", + uuid: "4nR0i/L9Q2mfrfXtEWMnGA==", + account_id: "AAAAAABBBBBCCCCDDDD", + host_id: "z8yCxTTTTKWIFKy9d3VA", + topic: "Weekly Product Sync", + type: 8, + start_time: "2026-07-15T14:00:00Z", + timezone: "America/Los_Angeles", + host_email: "priya.nair@example.com", + duration: 60, + total_size: 52428800, + recording_count: 2, + share_url: "https://zoom.us/rec/share/abcDEF123ghiJKL456mnoPQR789stuVWX", + recording_files: [ + { + id: "9f8e7d6c-5b4a-3210-9f8e-7d6c5b4a3210", + meeting_id: "4nR0i/L9Q2mfrfXtEWMnGA==", + recording_start: "2026-07-15T14:00:05Z", + recording_end: "2026-07-15T15:00:52Z", + file_type: "MP4", + file_extension: "MP4", + file_size: 41943040, + play_url: "https://zoom.us/rec/play/abcDEF123ghiJKL456mnoPQR789stuVWX", + download_url: "https://zoom.us/rec/download/abcDEF123ghiJKL456mnoPQR789stuVWX", + status: "completed", + recording_type: "shared_screen_with_speaker_view", + }, + { + id: "1a2b3c4d-5e6f-7890-1a2b-3c4d5e6f7890", + meeting_id: "4nR0i/L9Q2mfrfXtEWMnGA==", + recording_start: "2026-07-15T14:00:05Z", + recording_end: "2026-07-15T15:00:52Z", + file_type: "M4A", + file_extension: "M4A", + file_size: 10485760, + play_url: "https://zoom.us/rec/play/xyz987UVW654rstQPO321nml", + download_url: "https://zoom.us/rec/download/xyz987UVW654rstQPO321nml", + status: "completed", + recording_type: "audio_only", + }, + ], + recording_play_passcode: "aB3dE9fG", + download_token: "eyJhbGciOiJIUzUxMiJ9.fake-sample-token-not-real.signature", + }, + }, + }, + docsUrl: "https://developers.zoom.us/docs/api/webhooks/", + provenance: { kind: "handauthored", snapshotDate: "2026-07" }, + }, +]; diff --git a/internal-packages/webhook-sources/src/index.ts b/internal-packages/webhook-sources/src/index.ts new file mode 100644 index 0000000000..77e3179565 --- /dev/null +++ b/internal-packages/webhook-sources/src/index.ts @@ -0,0 +1,25 @@ +import { type SampleManifestItem, type SampleRecord } from "./sampleRecord.js"; +import { samples } from "./samples.js"; + +export { SampleProvenance, SampleRecord } from "./sampleRecord.js"; +export type { SampleManifestItem } from "./sampleRecord.js"; +export { samples } from "./samples.js"; +export { registry, registryEntries, getProvider } from "./registry/index.js"; +export type { + ProviderRegistryEntry, + EventTypeSource, + SecretProvisioning, + SampleSource, +} from "./registry/types.js"; +export { WEBHOOK_CATEGORIES, categoryLabel, categoryOrder } from "./registry/categories.js"; +export type { WebhookCategoryId } from "./registry/categories.js"; + +/** Picker metadata (no bodies), small enough to ship to the client in one response. */ +export function sampleManifest(): SampleManifestItem[] { + return samples.map(({ body, extraHeaders, ...meta }) => meta); +} + +/** A single sample's full record (with body + headers), looked up on selection. */ +export function getSample(provider: string, eventType: string): SampleRecord | undefined { + return samples.find((sample) => sample.provider === provider && sample.eventType === eventType); +} diff --git a/internal-packages/webhook-sources/src/ingest.ts b/internal-packages/webhook-sources/src/ingest.ts new file mode 100644 index 0000000000..915c168ed5 --- /dev/null +++ b/internal-packages/webhook-sources/src/ingest.ts @@ -0,0 +1,115 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { type SampleRecord } from "./sampleRecord.js"; + +/** + * Snapshots the MIT-licensed Hookdeck webhook-samples dataset (samples.hookdeck.com) into a normalized + * set of SampleRecords committed to src/generated. Run manually to refresh: `pnpm run ingest`. + * Attribution lives in NOTICE.md. This is tooling, never imported at runtime. + */ + +const BASE = "https://samples.hookdeck.com"; +const SNAPSHOT_DATE = "2026-07"; +const PRESET_PROVIDERS = new Set(["stripe", "github", "svix", "square", "discord"]); + +const HEADER_DENYLIST = new Set([ + "content-type", + "content-length", + "host", + "accept", + "accept-encoding", + "accept-language", + "user-agent", + "connection", + "cache-control", + "pragma", + "origin", + "referer", + "date", +]); + +function isNoiseHeader(lower: string): boolean { + if (HEADER_DENYLIST.has(lower)) return true; + if (lower.startsWith("sec-") || lower.startsWith("x-forwarded") || lower.startsWith("cf-")) { + return true; + } + return /signature|hmac|authorization|-delivery|hook-id|hook-installation|request-id|webhook-id/.test( + lower + ); +} + +function cleanHeaders(headers: unknown): Record | undefined { + if (typeof headers !== "object" || headers === null) return undefined; + const out: Record = {}; + for (const [key, value] of Object.entries(headers as Record)) { + if (isNoiseHeader(key.toLowerCase())) continue; + if (typeof value === "string") out[key] = value; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +async function fetchJson(path: string): Promise { + const res = await fetch(`${BASE}${path}`); + if (!res.ok) throw new Error(`${path} -> ${res.status}`); + return res.json(); +} + +type ProviderMeta = { label?: string; latest_version?: string }; + +async function main() { + const providers = (await fetchJson("/providers.json")) as Record; + const records: SampleRecord[] = []; + + for (const [provider, meta] of Object.entries(providers)) { + const version = meta.latest_version; + if (!version) continue; + let topics: Record; + try { + topics = (await fetchJson(`/providers/${provider}/${version}.json`)) as typeof topics; + } catch (error) { + console.warn(`skip ${provider}: ${String(error)}`); + continue; + } + + for (const [key, entry] of Object.entries(topics)) { + if (!entry || typeof entry !== "object" || entry.body === undefined) continue; + const eventType = entry.topic || key; + records.push({ + provider, + providerLabel: meta.label, + presetId: PRESET_PROVIDERS.has(provider) + ? (provider as SampleRecord["presetId"]) + : undefined, + eventType, + name: eventType, + body: entry.body, + extraHeaders: cleanHeaders(entry.headers), + provenance: { + kind: "upstream", + ref: `hookdeck/webhook-samples:${provider}/${version}`, + snapshotDate: SNAPSHOT_DATE, + }, + }); + } + } + + records.sort((a, b) => + a.provider === b.provider + ? a.eventType.localeCompare(b.eventType) + : a.provider.localeCompare(b.provider) + ); + + const here = dirname(fileURLToPath(import.meta.url)); + const outDir = join(here, "generated"); + mkdirSync(outDir, { recursive: true }); + writeFileSync(join(outDir, "hookdeck-samples.json"), JSON.stringify(records, null, 2) + "\n"); + + const providerCount = new Set(records.map((r) => r.provider)).size; + console.log(`wrote ${records.length} samples across ${providerCount} providers`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/internal-packages/webhook-sources/src/registry/adyen.ts b/internal-packages/webhook-sources/src/registry/adyen.ts new file mode 100644 index 0000000000..f8ff786550 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/adyen.ts @@ -0,0 +1,11 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "adyen", + label: "Adyen", + category: "payments", + docsUrl: "https://docs.adyen.com/development-resources/webhooks/", + secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "notificationItems.0.NotificationRequestItem.eventCode" }, + sampleSource: "hookdeck", +}; diff --git a/internal-packages/webhook-sources/src/registry/anthropic.ts b/internal-packages/webhook-sources/src/registry/anthropic.ts new file mode 100644 index 0000000000..ca5999edaf --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/anthropic.ts @@ -0,0 +1,18 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Anthropic (Managed Agents) webhooks. Every delivery carries a single `X-Webhook-Signature` + * header over a `whsec_`-prefixed secret — not the three-header Standard Webhooks scheme + * (`webhook-id`/`webhook-timestamp`/`webhook-signature`) our `svix` preset verifies, so no + * preset is set here. The discriminant lives at `data.type`; the top-level `type` is always + * the literal string `"event"`. + */ +export const entry: ProviderRegistryEntry = { + id: "anthropic", + label: "Anthropic (Claude)", + category: "ai-platform", + docsUrl: "https://platform.claude.com/docs/en/managed-agents/webhooks", + secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "data.type" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/assemblyai.ts b/internal-packages/webhook-sources/src/registry/assemblyai.ts new file mode 100644 index 0000000000..1be182ad02 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/assemblyai.ts @@ -0,0 +1,18 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * AssemblyAI authenticates webhooks with a static shared secret: the integrator supplies + * `webhook_auth_header_name` / `webhook_auth_header_value` when creating the transcript, and + * AssemblyAI echoes that header back verbatim on delivery. There is no HMAC or Ed25519 signature, so + * no preset applies. The payload only ever carries `transcript_id` and `status` (`completed` or + * `error`) - callers must GET `/v2/transcript/{id}` separately for the transcript text or error detail. + */ +export const entry: ProviderRegistryEntry = { + id: "assemblyai", + label: "AssemblyAI", + category: "ai-platform", + docsUrl: "https://www.assemblyai.com/docs/concepts/webhooks", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "status" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/attio.ts b/internal-packages/webhook-sources/src/registry/attio.ts new file mode 100644 index 0000000000..34fce170b5 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/attio.ts @@ -0,0 +1,17 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Attio signs with its own `attio-signature` header (legacy alias `X-Attio-Signature`): a bare hex + * HMAC-SHA256 digest of the raw body, no `sha256=` prefix, no timestamp component, no svix-style + * id/timestamp/signature triple. That doesn't match our `github` preset's `sha256=` format, our + * `stripe` preset's `t=,v1=` format, or `svix`'s header set, so this ships sample-only. + */ +export const entry: ProviderRegistryEntry = { + id: "attio", + label: "Attio", + category: "crm", + docsUrl: "https://docs.attio.com/rest-api/guides/webhooks", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "events.0.event_type" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/auth0.ts b/internal-packages/webhook-sources/src/registry/auth0.ts new file mode 100644 index 0000000000..153fb51407 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/auth0.ts @@ -0,0 +1,18 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Auth0 log streams authenticate with a static bearer token compared against the Authorization + * header, not an HMAC/Ed25519 signature, so no preset applies. Deliveries are a JSON array of log + * events (JSON Lines is also selectable, but array is the default Content Format); each element wraps + * the log event under `data`, so the event kind lives at `data.type` (e.g. "s" success login, "ss" + * success signup, "fp"/"fu" failed login variants). + */ +export const entry: ProviderRegistryEntry = { + id: "auth0", + label: "Auth0", + category: "auth-identity", + docsUrl: "https://auth0.com/docs/customize/log-streams/custom-log-streams", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "0.data.type" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/bigcommerce.ts b/internal-packages/webhook-sources/src/registry/bigcommerce.ts new file mode 100644 index 0000000000..002cb01e03 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/bigcommerce.ts @@ -0,0 +1,11 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "bigcommerce", + label: "BigCommerce", + category: "commerce", + docsUrl: "https://developer.bigcommerce.com/docs/integrations/webhooks", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "scope" }, + sampleSource: "hookdeck", +}; diff --git a/internal-packages/webhook-sources/src/registry/bitbucket.ts b/internal-packages/webhook-sources/src/registry/bitbucket.ts new file mode 100644 index 0000000000..9e42115b30 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/bitbucket.ts @@ -0,0 +1,11 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "bitbucket", + label: "Bitbucket", + category: "source-control", + docsUrl: "https://support.atlassian.com/bitbucket-cloud/docs/manage-webhooks/", + secretProvisioning: "integrator", + eventTypeSource: { from: "header", name: "x-event-key" }, + sampleSource: "hookdeck", +}; diff --git a/internal-packages/webhook-sources/src/registry/brex.ts b/internal-packages/webhook-sources/src/registry/brex.ts new file mode 100644 index 0000000000..088f34464b --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/brex.ts @@ -0,0 +1,19 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Brex signs webhooks with the Standard Webhooks scheme: `Webhook-Id` / `Webhook-Timestamp` / + * `Webhook-Signature` headers, HMAC-SHA256 base64 over `{id}.{timestamp}.{rawBody}`, verified + * against a base64-decoded secret from `GET /v1/webhooks/secrets` (their docs' own example secret + * has no `whsec_` prefix, but our `svix` preset's prefix strip is a no-op when absent). Matches + * our `svix` preset. The discriminant is `event_type` (not `type`) at the top level of the body. + */ +export const entry: ProviderRegistryEntry = { + id: "brex", + label: "Brex", + category: "fintech", + docsUrl: "https://developer.brex.com/docs/webhooks/", + preset: "svix", + secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "event_type" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/cal-com.ts b/internal-packages/webhook-sources/src/registry/cal-com.ts new file mode 100644 index 0000000000..ad5b90f0ed --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/cal-com.ts @@ -0,0 +1,16 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Cal.com signs webhooks with an `x-cal-signature-256` header, a plain hex HMAC-SHA256 digest of + * the raw JSON body (no `sha256=` prefix), so it does not match our `github` preset's wire format. + * Ships sample-only. The discriminant is `triggerEvent` at the top level of the body. + */ +export const entry: ProviderRegistryEntry = { + id: "cal-com", + label: "Cal.com", + category: "calendar-scheduling", + docsUrl: "https://cal.com/docs/developing/guides/automation/webhooks", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "triggerEvent" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/calendly.ts b/internal-packages/webhook-sources/src/registry/calendly.ts new file mode 100644 index 0000000000..cec0b8189c --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/calendly.ts @@ -0,0 +1,16 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Calendly signs with its own `Calendly-Webhook-Signature` header (`t=,v1=`, HMAC-SHA256 over + * `{t}.{rawBody}`), not `stripe-signature`. The scheme matches our stripe preset's math but not its wire + * format (header name), so no preset is set here; ships sample-only. + */ +export const entry: ProviderRegistryEntry = { + id: "calendly", + label: "Calendly", + category: "calendar-scheduling", + docsUrl: "https://developer.calendly.com/api-docs/4c305798a61d3-webhook-signatures", + secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "event" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/categories.ts b/internal-packages/webhook-sources/src/registry/categories.ts new file mode 100644 index 0000000000..f6cb0988d4 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/categories.ts @@ -0,0 +1,45 @@ +/** + * Canonical provider categories: the single source of truth for a category's display label and the + * order categories appear in (the console groups the picker by these). A registry entry's `category` + * must be one of these ids; the webapp renders `categoryLabel(id)` rather than deriving anything. + */ +export const WEBHOOK_CATEGORIES = [ + { id: "ai-platform", label: "AI & ML" }, + { id: "voice", label: "Voice AI" }, + { id: "communication", label: "Communication" }, + { id: "email", label: "Email" }, + { id: "marketing", label: "Marketing" }, + { id: "support", label: "Support" }, + { id: "crm", label: "CRM" }, + { id: "pm", label: "Project Management" }, + { id: "productivity", label: "Productivity" }, + { id: "calendar-scheduling", label: "Calendar & Scheduling" }, + { id: "forms", label: "Forms" }, + { id: "e-signature", label: "E-Signature" }, + { id: "storage", label: "Storage" }, + { id: "data", label: "Data" }, + { id: "source-control", label: "Source Control" }, + { id: "ci-cd", label: "CI / CD" }, + { id: "hosting-infra", label: "Hosting & Infra" }, + { id: "observability", label: "Observability" }, + { id: "auth-identity", label: "Auth & Identity" }, + { id: "payments", label: "Payments" }, + { id: "billing", label: "Billing & Subscriptions" }, + { id: "commerce", label: "Commerce" }, + { id: "fintech", label: "Fintech & Banking" }, +] as const; + +export type WebhookCategoryId = (typeof WEBHOOK_CATEGORIES)[number]["id"]; + +const LABEL = new Map(WEBHOOK_CATEGORIES.map((c) => [c.id, c.label])); +const ORDER = new Map(WEBHOOK_CATEGORIES.map((c, i) => [c.id, i])); + +/** Display label for a category id, falling back to the id itself for anything unmapped. */ +export function categoryLabel(id: string): string { + return LABEL.get(id) ?? id; +} + +/** Sort index for a category id (unmapped categories sort last). */ +export function categoryOrder(id: string): number { + return ORDER.get(id) ?? Number.MAX_SAFE_INTEGER; +} diff --git a/internal-packages/webhook-sources/src/registry/checkout.ts b/internal-packages/webhook-sources/src/registry/checkout.ts new file mode 100644 index 0000000000..6dcc796502 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/checkout.ts @@ -0,0 +1,11 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "checkout", + label: "Checkout.com", + category: "payments", + docsUrl: "https://www.checkout.com/docs/developer-resources/event-notifications/receive-webhooks", + secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "type" }, + sampleSource: "hookdeck", +}; diff --git a/internal-packages/webhook-sources/src/registry/clerk.ts b/internal-packages/webhook-sources/src/registry/clerk.ts new file mode 100644 index 0000000000..1ad9bc721e --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/clerk.ts @@ -0,0 +1,12 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "clerk", + label: "Clerk", + category: "auth-identity", + docsUrl: "https://clerk.com/docs/integrations/webhooks/overview", + preset: "svix", + secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "type" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/close-crm.ts b/internal-packages/webhook-sources/src/registry/close-crm.ts new file mode 100644 index 0000000000..7492ad4889 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/close-crm.ts @@ -0,0 +1,21 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Close signs webhooks with a custom scheme: the `close-sig-hash` header carries a hex-encoded + * HMAC-SHA256 signature computed over `{close-sig-timestamp}{rawBody}` (timestamp in a sibling + * header, key hex-decoded before use). That doesn't match `stripe` (`t=,v1=` combined into one + * header value), `square` (base64 digest), `github` (no timestamp component), `svix` (three + * id/timestamp/signature headers, base64), or `discord` (Ed25519), so this ships sample-only with + * no preset. The resource type rides the body's nested `event.object_type` plus `event.action` + * (e.g. `lead` + `created`, `activity.note` + `created`); `event.action` is the primary + * discriminant field. + */ +export const entry: ProviderRegistryEntry = { + id: "close-crm", + label: "Close", + category: "crm", + docsUrl: "https://developer.close.com/topics/webhooks/", + secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "event.action" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/commercelayer.ts b/internal-packages/webhook-sources/src/registry/commercelayer.ts new file mode 100644 index 0000000000..ed21790882 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/commercelayer.ts @@ -0,0 +1,11 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "commercelayer", + label: "Commerce Layer", + category: "commerce", + docsUrl: "https://docs.commercelayer.io/core/callbacks-security", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "data.type" }, + sampleSource: "hookdeck", +}; diff --git a/internal-packages/webhook-sources/src/registry/deepgram.ts b/internal-packages/webhook-sources/src/registry/deepgram.ts new file mode 100644 index 0000000000..c187b9d195 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/deepgram.ts @@ -0,0 +1,22 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Deepgram's async transcription callback carries a `dg-token` header set to the API Key Identifier + * that submitted the original request - an identifier, not an HMAC/Ed25519 signature - so no preset + * applies. Protecting the endpoint is left to the integrator (embed Basic Auth credentials in the + * callback URL itself, or IP-allowlist Deepgram's senders); Deepgram never issues a per-endpoint secret. + * The callback body is the raw transcription result object (metadata + results), with no discriminant + * field at all. `results.channels` is present only on success; a failed job instead delivers + * `{ err_code, err_msg, request_id }` (Deepgram's general API error shape). `eventTypeSource` points at + * `results.channels` as the representative success case; each sample's explicit `eventType` is + * authoritative. + */ +export const entry: ProviderRegistryEntry = { + id: "deepgram", + label: "Deepgram", + category: "ai-platform", + docsUrl: "https://developers.deepgram.com/docs/callback", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "results.channels" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/discord.ts b/internal-packages/webhook-sources/src/registry/discord.ts new file mode 100644 index 0000000000..49fe26bf0d --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/discord.ts @@ -0,0 +1,19 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Discord Webhook Events. Verified against docs: two headers (`X-Signature-Ed25519` + + * `X-Signature-Timestamp`), Ed25519 over `{timestamp}{rawBody}` - exact match for our `discord` + * preset. The outer `type` field is only a wrapper (`0` = PING, `1` = event) and is NOT a useful + * discriminant; the real event kind lives at `event.type` (e.g. `APPLICATION_AUTHORIZED`), so + * `eventTypeSource` points there instead of the literal top-level `type` path. + */ +export const entry: ProviderRegistryEntry = { + id: "discord", + label: "Discord", + category: "communication", + docsUrl: "https://docs.discord.com/developers/events/webhook-events", + preset: "discord", + secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "event.type" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/docusign.ts b/internal-packages/webhook-sources/src/registry/docusign.ts new file mode 100644 index 0000000000..115d486e5d --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/docusign.ts @@ -0,0 +1,11 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "docusign", + label: "DocuSign", + category: "e-signature", + docsUrl: "https://developers.docusign.com/platform/webhooks/connect/", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "event" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/elevenlabs.ts b/internal-packages/webhook-sources/src/registry/elevenlabs.ts new file mode 100644 index 0000000000..f0f9d8afa5 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/elevenlabs.ts @@ -0,0 +1,17 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * ElevenLabs signs post-call webhooks with `elevenlabs-signature: t=,v0=`, an HMAC-SHA256 + * of `{timestamp}.{rawBody}`. Structurally close to our `stripe` preset (timestamped HMAC-SHA256), but + * the header name and the `v0=` value prefix (vs. `v1=`) don't match exactly, so this ships sample-only + * (no preset) until a custom verifier config exists for this wire format. + */ +export const entry: ProviderRegistryEntry = { + id: "elevenlabs", + label: "ElevenLabs", + category: "voice", + docsUrl: "https://elevenlabs.io/docs/eleven-agents/workflows/post-call-webhooks", + secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "type" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/github.ts b/internal-packages/webhook-sources/src/registry/github.ts new file mode 100644 index 0000000000..95fb02b8c3 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/github.ts @@ -0,0 +1,12 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "github", + label: "GitHub", + category: "source-control", + docsUrl: "https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries", + preset: "github", + secretProvisioning: "integrator", + eventTypeSource: { from: "header", name: "x-github-event" }, + sampleSource: "hookdeck", +}; diff --git a/internal-packages/webhook-sources/src/registry/gitlab.ts b/internal-packages/webhook-sources/src/registry/gitlab.ts new file mode 100644 index 0000000000..820ca59ee9 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/gitlab.ts @@ -0,0 +1,12 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "gitlab", + label: "GitLab", + category: "source-control", + docsUrl: "https://docs.gitlab.com/user/project/integrations/webhooks/", + preset: "svix", + secretProvisioning: "integrator", + eventTypeSource: { from: "header", name: "x-gitlab-event" }, + sampleSource: "hookdeck", +}; diff --git a/internal-packages/webhook-sources/src/registry/hubspot.ts b/internal-packages/webhook-sources/src/registry/hubspot.ts new file mode 100644 index 0000000000..ca43d19f13 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/hubspot.ts @@ -0,0 +1,11 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "hubspot", + label: "HubSpot", + category: "crm", + docsUrl: "https://developers.hubspot.com/docs/guides/api/app-management/webhooks", + secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "0.subscriptionType" }, + sampleSource: "hookdeck", +}; diff --git a/internal-packages/webhook-sources/src/registry/index.ts b/internal-packages/webhook-sources/src/registry/index.ts new file mode 100644 index 0000000000..dca38dce75 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/index.ts @@ -0,0 +1,123 @@ +/** GENERATED by catalog/build-aggregates.ts. Do not edit by hand. */ +import { type ProviderRegistryEntry } from "./types.js"; +import { entry as e_adyen } from "./adyen.js"; +import { entry as e_anthropic } from "./anthropic.js"; +import { entry as e_assemblyai } from "./assemblyai.js"; +import { entry as e_attio } from "./attio.js"; +import { entry as e_auth0 } from "./auth0.js"; +import { entry as e_bigcommerce } from "./bigcommerce.js"; +import { entry as e_bitbucket } from "./bitbucket.js"; +import { entry as e_brex } from "./brex.js"; +import { entry as e_cal_com } from "./cal-com.js"; +import { entry as e_calendly } from "./calendly.js"; +import { entry as e_checkout } from "./checkout.js"; +import { entry as e_clerk } from "./clerk.js"; +import { entry as e_close_crm } from "./close-crm.js"; +import { entry as e_commercelayer } from "./commercelayer.js"; +import { entry as e_deepgram } from "./deepgram.js"; +import { entry as e_discord } from "./discord.js"; +import { entry as e_docusign } from "./docusign.js"; +import { entry as e_elevenlabs } from "./elevenlabs.js"; +import { entry as e_github } from "./github.js"; +import { entry as e_gitlab } from "./gitlab.js"; +import { entry as e_hubspot } from "./hubspot.js"; +import { entry as e_intercom } from "./intercom.js"; +import { entry as e_jira } from "./jira.js"; +import { entry as e_linear } from "./linear.js"; +import { entry as e_monday } from "./monday.js"; +import { entry as e_notion } from "./notion.js"; +import { entry as e_openai } from "./openai.js"; +import { entry as e_paddlebilling } from "./paddlebilling.js"; +import { entry as e_paddleclassic } from "./paddleclassic.js"; +import { entry as e_pagerduty } from "./pagerduty.js"; +import { entry as e_paypal } from "./paypal.js"; +import { entry as e_pipedrive } from "./pipedrive.js"; +import { entry as e_plaid } from "./plaid.js"; +import { entry as e_postmark } from "./postmark.js"; +import { entry as e_recall_ai } from "./recall-ai.js"; +import { entry as e_replicate } from "./replicate.js"; +import { entry as e_resend } from "./resend.js"; +import { entry as e_retell } from "./retell.js"; +import { entry as e_sendgrid } from "./sendgrid.js"; +import { entry as e_sentry } from "./sentry.js"; +import { entry as e_shopify } from "./shopify.js"; +import { entry as e_slack } from "./slack.js"; +import { entry as e_square } from "./square.js"; +import { entry as e_stripe } from "./stripe.js"; +import { entry as e_supabase } from "./supabase.js"; +import { entry as e_telegram } from "./telegram.js"; +import { entry as e_twilio } from "./twilio.js"; +import { entry as e_typeform } from "./typeform.js"; +import { entry as e_vapi } from "./vapi.js"; +import { entry as e_vercel } from "./vercel.js"; +import { entry as e_whatsapp } from "./whatsapp.js"; +import { entry as e_woocommerce } from "./woocommerce.js"; +import { entry as e_workos } from "./workos.js"; +import { entry as e_zendesk } from "./zendesk.js"; +import { entry as e_zoom } from "./zoom.js"; + +export const registryEntries: ProviderRegistryEntry[] = [ + e_adyen, + e_anthropic, + e_assemblyai, + e_attio, + e_auth0, + e_bigcommerce, + e_bitbucket, + e_brex, + e_cal_com, + e_calendly, + e_checkout, + e_clerk, + e_close_crm, + e_commercelayer, + e_deepgram, + e_discord, + e_docusign, + e_elevenlabs, + e_github, + e_gitlab, + e_hubspot, + e_intercom, + e_jira, + e_linear, + e_monday, + e_notion, + e_openai, + e_paddlebilling, + e_paddleclassic, + e_pagerduty, + e_paypal, + e_pipedrive, + e_plaid, + e_postmark, + e_recall_ai, + e_replicate, + e_resend, + e_retell, + e_sendgrid, + e_sentry, + e_shopify, + e_slack, + e_square, + e_stripe, + e_supabase, + e_telegram, + e_twilio, + e_typeform, + e_vapi, + e_vercel, + e_whatsapp, + e_woocommerce, + e_workos, + e_zendesk, + e_zoom, +]; + +export const registry: Record = Object.fromEntries( + registryEntries.map((e) => [e.id, e]) +); + +export function getProvider(id: string): ProviderRegistryEntry | undefined { + return registry[id]; +} diff --git a/internal-packages/webhook-sources/src/registry/intercom.ts b/internal-packages/webhook-sources/src/registry/intercom.ts new file mode 100644 index 0000000000..d03a1a98d2 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/intercom.ts @@ -0,0 +1,18 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Intercom signs webhook notifications with an HMAC-SHA1 digest in the `X-Hub-Signature` header + * (over the raw body, keyed by the app's client secret) - that is SHA1, not the SHA256 our github + * preset requires, so this ships sample-only with no preset. Every notification is wrapped in the + * same envelope (`type: "notification_event"`, `topic`, `data.item`), with `topic` carrying the + * event discriminant. + */ +export const entry: ProviderRegistryEntry = { + id: "intercom", + label: "Intercom", + category: "support", + docsUrl: "https://developers.intercom.com/docs/references/webhooks/webhook-models", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "topic" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/jira.ts b/internal-packages/webhook-sources/src/registry/jira.ts new file mode 100644 index 0000000000..59ca16dd3f --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/jira.ts @@ -0,0 +1,16 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Jira Cloud signs webhooks with an `X-Hub-Signature` header (no `-256` suffix, value `sha256=` + * per the WebSub convention), which does not match our `github` preset's `x-hub-signature-256` header + * name. Shipping sample-only until a custom verifier config exists for this wire format. + */ +export const entry: ProviderRegistryEntry = { + id: "jira", + label: "Jira", + category: "pm", + docsUrl: "https://developer.atlassian.com/cloud/jira/platform/webhooks/", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "webhookEvent" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/linear.ts b/internal-packages/webhook-sources/src/registry/linear.ts new file mode 100644 index 0000000000..e55f9006c2 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/linear.ts @@ -0,0 +1,19 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Linear signs webhooks with a custom scheme: a `Linear-Signature` header carrying a hex-encoded + * HMAC-SHA256 signature of the raw body, keyed by the webhook's signing secret. The header name and + * value format don't match our `github` preset (`x-hub-signature-256`, `sha256=` prefix) or any + * other preset, so this ships sample-only with no preset. The resource type rides the top-level `type` + * field in the body, but the meaningful event also depends on the top-level `action` (create/update/ + * remove). + */ +export const entry: ProviderRegistryEntry = { + id: "linear", + label: "Linear", + category: "pm", + docsUrl: "https://linear.app/developers/webhooks", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "type" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/monday.ts b/internal-packages/webhook-sources/src/registry/monday.ts new file mode 100644 index 0000000000..fd5580e868 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/monday.ts @@ -0,0 +1,11 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "monday", + label: "monday.com", + category: "pm", + docsUrl: "https://developer.monday.com/api-reference/docs/webhooks", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "event.type" }, + sampleSource: "hookdeck", +}; diff --git a/internal-packages/webhook-sources/src/registry/notion.ts b/internal-packages/webhook-sources/src/registry/notion.ts new file mode 100644 index 0000000000..c1ca8e0fd8 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/notion.ts @@ -0,0 +1,11 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "notion", + label: "Notion", + category: "productivity", + docsUrl: "https://developers.notion.com/reference/webhooks", + secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "type" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/openai.ts b/internal-packages/webhook-sources/src/registry/openai.ts new file mode 100644 index 0000000000..0dd312f057 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/openai.ts @@ -0,0 +1,12 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "openai", + label: "OpenAI", + category: "ai-platform", + docsUrl: "https://platform.openai.com/docs/guides/webhooks", + preset: "svix", + secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "type" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/paddlebilling.ts b/internal-packages/webhook-sources/src/registry/paddlebilling.ts new file mode 100644 index 0000000000..d93e158868 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/paddlebilling.ts @@ -0,0 +1,11 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "paddlebilling", + label: "Paddle Billing", + category: "billing", + docsUrl: "https://developer.paddle.com/webhooks/overview", + secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "event_type" }, + sampleSource: "hookdeck", +}; diff --git a/internal-packages/webhook-sources/src/registry/paddleclassic.ts b/internal-packages/webhook-sources/src/registry/paddleclassic.ts new file mode 100644 index 0000000000..da16761152 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/paddleclassic.ts @@ -0,0 +1,11 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "paddleclassic", + label: "Paddle Classic", + category: "billing", + docsUrl: "https://developer.paddle.com/classic/reference/ZG9jOjI1MzUzOTg2-verifying-webhooks", + secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "alert_name" }, + sampleSource: "hookdeck", +}; diff --git a/internal-packages/webhook-sources/src/registry/pagerduty.ts b/internal-packages/webhook-sources/src/registry/pagerduty.ts new file mode 100644 index 0000000000..e1d1bc54a8 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/pagerduty.ts @@ -0,0 +1,18 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * PagerDuty v3 webhook subscriptions sign with `X-PagerDuty-Signature: v1=`, an HMAC-SHA256 of + * the raw request body only (no timestamp component in the signed string), which does not match any + * of our presets (stripe needs a `t=` timestamp folded into the signing input, github's header name + * and svix/square/discord's schemes are all different). Shipping sample-only until a custom verifier + * config exists for this wire format. + */ +export const entry: ProviderRegistryEntry = { + id: "pagerduty", + label: "PagerDuty", + category: "observability", + docsUrl: "https://developer.pagerduty.com/docs/webhooks/webhook-signatures/", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "event.event_type" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/paypal.ts b/internal-packages/webhook-sources/src/registry/paypal.ts new file mode 100644 index 0000000000..8daaf1c665 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/paypal.ts @@ -0,0 +1,11 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "paypal", + label: "PayPal", + category: "payments", + docsUrl: "https://developer.paypal.com/api/rest/webhooks/rest/", + secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "event_type" }, + sampleSource: "hookdeck", +}; diff --git a/internal-packages/webhook-sources/src/registry/pipedrive.ts b/internal-packages/webhook-sources/src/registry/pipedrive.ts new file mode 100644 index 0000000000..5e28b2c3f8 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/pipedrive.ts @@ -0,0 +1,11 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "pipedrive", + label: "Pipedrive", + category: "crm", + docsUrl: "https://developers.pipedrive.com/docs/api/v1/Webhooks", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "event" }, + sampleSource: "hookdeck", +}; diff --git a/internal-packages/webhook-sources/src/registry/plaid.ts b/internal-packages/webhook-sources/src/registry/plaid.ts new file mode 100644 index 0000000000..1b93f2b381 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/plaid.ts @@ -0,0 +1,21 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Plaid signs webhooks with a `Plaid-Verification` header carrying a JWT (ES256: ECDSA over + * SHA-256), whose payload holds `iat` and a `request_body_sha256` hash of the raw body; the + * receiver fetches the signing JWK from `/webhook_verification_key/get` by the JWT's `kid`. This + * is a bespoke JWS + separate-key-fetch scheme, not a shared-secret HMAC, so it does not match any + * of stripe/github/svix/square/discord - ships sample-only with no preset. `secretProvisioning` is + * "provider" because Plaid alone holds the signing key; there is no integrator-set secret. The + * discriminant is the top-level `webhook_type` (TRANSACTIONS, ITEM, ...); the finer-grained event + * also depends on `webhook_code` (and, for ITEM ERROR, the nested `error.error_code`). + */ +export const entry: ProviderRegistryEntry = { + id: "plaid", + label: "Plaid", + category: "fintech", + docsUrl: "https://plaid.com/docs/api/webhooks/webhook-verification/", + secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "webhook_type" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/postmark.ts b/internal-packages/webhook-sources/src/registry/postmark.ts new file mode 100644 index 0000000000..7aa39eb21d --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/postmark.ts @@ -0,0 +1,18 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Postmark does not support HMAC webhook signatures (confirmed on the webhooks overview page). Requests + * are secured with HTTP Basic Auth embedded in the webhook URL (`https://:@example.com/hook`) + * and/or IP allowlisting, so no verifier preset applies and this ships sample-only. The Delivery/Bounce/ + * SpamComplaint/Open event webhooks carry a `RecordType` field; the separate Inbound webhook (a distinct, + * older feature) does not - see the handAuthored file for how that sample is handled. + */ +export const entry: ProviderRegistryEntry = { + id: "postmark", + label: "Postmark", + category: "email", + docsUrl: "https://postmarkapp.com/developer/webhooks/inbound-webhook", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "RecordType" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/recall-ai.ts b/internal-packages/webhook-sources/src/registry/recall-ai.ts new file mode 100644 index 0000000000..71bfa4f943 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/recall-ai.ts @@ -0,0 +1,16 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Recall.ai signs webhooks with Svix: Webhook-Id / Webhook-Timestamp / Webhook-Signature headers, + * HMAC-SHA256 base64 over "{id}.{timestamp}.{body}", secret prefixed "whsec_". Matches our svix preset. + */ +export const entry: ProviderRegistryEntry = { + id: "recall-ai", + label: "Recall.ai", + category: "ai-platform", + docsUrl: "https://docs.recall.ai/docs/authenticating-requests-from-recallai", + preset: "svix", + secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "event" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/replicate.ts b/internal-packages/webhook-sources/src/registry/replicate.ts new file mode 100644 index 0000000000..494a41db55 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/replicate.ts @@ -0,0 +1,12 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "replicate", + label: "Replicate", + category: "ai-platform", + docsUrl: "https://replicate.com/docs/topics/webhooks/verify-webhook", + preset: "svix", + secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "status" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/resend.ts b/internal-packages/webhook-sources/src/registry/resend.ts new file mode 100644 index 0000000000..0c250bb86b --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/resend.ts @@ -0,0 +1,12 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "resend", + label: "Resend", + category: "email", + docsUrl: "https://resend.com/docs/dashboard/webhooks/introduction", + preset: "svix", + secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "type" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/retell.ts b/internal-packages/webhook-sources/src/registry/retell.ts new file mode 100644 index 0000000000..c2af616a9e --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/retell.ts @@ -0,0 +1,16 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Retell AI signs webhooks with its own scheme: `x-retell-signature: v={timestamp_ms},d={hex_digest}`, + * HMAC-SHA256 over `rawBody + timestamp` (string concatenation, not dot-joined), keyed by an API key + * (no `whsec_`-style prefix). This does not match any of our verifier presets, so these ship sample-only. + */ +export const entry: ProviderRegistryEntry = { + id: "retell", + label: "Retell AI", + category: "voice", + docsUrl: "https://docs.retellai.com/features/secure-webhook", + secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "event" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/sendgrid.ts b/internal-packages/webhook-sources/src/registry/sendgrid.ts new file mode 100644 index 0000000000..53ab09aeca --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/sendgrid.ts @@ -0,0 +1,20 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * SendGrid's Event Webhook is signed with ECDSA (a private/public EC key pair SendGrid generates): + * `X-Twilio-Email-Event-Webhook-Signature` (base64 signature) and + * `X-Twilio-Email-Event-Webhook-Timestamp` over `{timestamp}{rawBody}`. That scheme is not among our + * stripe/github/svix/square/discord verifier presets, so this ships sample-only (no `preset`). SendGrid + * also delivers events in a BATCH: the POST body is a JSON array of event objects, so the discriminant + * lives at `0.event` (the first event in the array) rather than a top-level field. + */ +export const entry: ProviderRegistryEntry = { + id: "sendgrid", + label: "SendGrid", + category: "email", + docsUrl: + "https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/getting-started-event-webhook-security-features", + secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "0.event" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/sentry.ts b/internal-packages/webhook-sources/src/registry/sentry.ts new file mode 100644 index 0000000000..8910f9155e --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/sentry.ts @@ -0,0 +1,17 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Sentry signs Integration Platform webhooks with a custom HMAC-SHA256 scheme (Sentry-Hook-Signature + * over the raw body, keyed by the integration's client secret) - it does not match stripe/github/svix/ + * square/discord, so this ships sample-only with no preset. The resource type (issue/error/comment/...) + * rides the `Sentry-Hook-Resource` header rather than the body. + */ +export const entry: ProviderRegistryEntry = { + id: "sentry", + label: "Sentry", + category: "observability", + docsUrl: "https://docs.sentry.io/product/integrations/integration-platform/webhooks/", + secretProvisioning: "integrator", + eventTypeSource: { from: "header", name: "sentry-hook-resource" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/shopify.ts b/internal-packages/webhook-sources/src/registry/shopify.ts new file mode 100644 index 0000000000..dc743cab9e --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/shopify.ts @@ -0,0 +1,11 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "shopify", + label: "Shopify", + category: "commerce", + docsUrl: "https://shopify.dev/docs/apps/build/webhooks/verify-deliveries", + secretProvisioning: "integrator", + eventTypeSource: { from: "header", name: "x-shopify-topic" }, + sampleSource: "hookdeck", +}; diff --git a/internal-packages/webhook-sources/src/registry/slack.ts b/internal-packages/webhook-sources/src/registry/slack.ts new file mode 100644 index 0000000000..adeb10e955 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/slack.ts @@ -0,0 +1,11 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "slack", + label: "Slack", + category: "communication", + docsUrl: "https://docs.slack.dev/authentication/verifying-requests-from-slack/", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "event.type" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/square.ts b/internal-packages/webhook-sources/src/registry/square.ts new file mode 100644 index 0000000000..2294f29b78 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/square.ts @@ -0,0 +1,12 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "square", + label: "Square", + category: "payments", + docsUrl: "https://developer.squareup.com/docs/webhooks/step3validate", + preset: "square", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "type" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/stripe.ts b/internal-packages/webhook-sources/src/registry/stripe.ts new file mode 100644 index 0000000000..3b739c489e --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/stripe.ts @@ -0,0 +1,12 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "stripe", + label: "Stripe", + category: "payments", + docsUrl: "https://docs.stripe.com/webhooks", + preset: "stripe", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "type" }, + sampleSource: "hookdeck", +}; diff --git a/internal-packages/webhook-sources/src/registry/supabase.ts b/internal-packages/webhook-sources/src/registry/supabase.ts new file mode 100644 index 0000000000..8854bacb3d --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/supabase.ts @@ -0,0 +1,19 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Supabase Database Webhooks have no built-in signing scheme - the docs describe only the payload + * shape (`type`/`table`/`schema`/`record`/`old_record`), and any authentication is whatever custom + * HTTP header the integrator adds to the underlying `net.http_post` call (e.g. a bearer token or a + * shared-secret header they check for themselves). That doesn't match any of our verifier presets, so + * this ships sample-only with no preset. The event discriminant is the top-level `type` field, one of + * `INSERT` / `UPDATE` / `DELETE`. + */ +export const entry: ProviderRegistryEntry = { + id: "supabase", + label: "Supabase", + category: "hosting-infra", + docsUrl: "https://supabase.com/docs/guides/database/webhooks", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "type" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/telegram.ts b/internal-packages/webhook-sources/src/registry/telegram.ts new file mode 100644 index 0000000000..e81af84a29 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/telegram.ts @@ -0,0 +1,19 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Telegram authenticates webhooks with a static shared secret compared against the + * X-Telegram-Bot-Api-Secret-Token header (set via the `secret_token` param to `setWebhook`), not an + * HMAC/Ed25519 signature, so no preset applies. The Update object has no single `type` field: the + * event kind is whichever top-level key is present (message, edited_message, callback_query, + * inline_query, ...). `eventTypeSource.path` names the most common case as a representative default; + * a real consumer needs to check for whichever of the known Update keys exists. + */ +export const entry: ProviderRegistryEntry = { + id: "telegram", + label: "Telegram", + category: "communication", + docsUrl: "https://core.telegram.org/bots/api#setwebhook", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "message" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/twilio.ts b/internal-packages/webhook-sources/src/registry/twilio.ts new file mode 100644 index 0000000000..3dcad23cf9 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/twilio.ts @@ -0,0 +1,21 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Twilio signs webhooks with `X-Twilio-Signature`: HMAC-SHA1, base64-encoded, over the exact webhook + * URL with sorted POST param names+values appended (JSON bodies instead append a `bodySHA256` query + * param). No preset matches (SHA-1, not SHA-256; URL+params, not a raw-body or `t.body` string), so this + * ships sample-only. Bodies are `application/x-www-form-urlencoded`, not JSON; every sample is the + * decoded key/value form object. Twilio has no single "type" field across products: inbound SMS/MMS + * carries a constant `SmsStatus` ("received"), while voice call requests carry `CallStatus`, which does + * vary (ringing/completed/busy/no-answer/...). `eventTypeSource` points at `CallStatus` as the closest + * real, varying discriminant; each sample's explicit `eventType` is the authoritative label. + */ +export const entry: ProviderRegistryEntry = { + id: "twilio", + label: "Twilio", + category: "communication", + docsUrl: "https://www.twilio.com/docs/usage/webhooks/webhooks-security", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "CallStatus" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/typeform.ts b/internal-packages/webhook-sources/src/registry/typeform.ts new file mode 100644 index 0000000000..8982483a9c --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/typeform.ts @@ -0,0 +1,17 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Typeform signs webhooks with a `Typeform-Signature` header formatted as `sha256=` HMAC-SHA256 + * over the raw request body. The header name and base64 encoding don't match our `github` preset (which + * requires `x-hub-signature-256` with a hex-encoded digest), nor any other preset, so this ships + * sample-only without a custom verifier config. + */ +export const entry: ProviderRegistryEntry = { + id: "typeform", + label: "Typeform", + category: "forms", + docsUrl: "https://www.typeform.com/developers/webhooks/secure-your-webhooks/", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "event_type" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/types.ts b/internal-packages/webhook-sources/src/registry/types.ts new file mode 100644 index 0000000000..59c9c559a2 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/types.ts @@ -0,0 +1,32 @@ +import { type WebhookPresetId } from "@trigger.dev/core/webhooks"; +import { type WebhookCategoryId } from "./categories.js"; + +/** + * Where a provider's event "type" lives. Drives Hookdeck topic parsing and the console picker grouping. + * Body-discriminated providers keep the type in the payload (e.g. Stripe `type`); header-discriminated + * ones carry it in a header (e.g. GitHub `x-github-event`). + */ +export type EventTypeSource = { from: "body"; path: string } | { from: "header"; name: string }; + +/** Who holds the signing secret: the provider issues it, the integrator sets it, or either. */ +export type SecretProvisioning = "provider" | "integrator" | "either"; + +/** Where a provider's samples come from (see the sourcing pipeline in the plan). */ +export type SampleSource = "hookdeck" | "octokit" | "capture" | "handauthored"; + +/** + * One registry entry per provider. Adding a provider is one of these plus a source. `preset` is set + * only when the provider maps to one of our verifier presets (unlocks the round-trip guarantee); a + * provider without a preset ships sample-only until a custom verifier config is authored. + */ +export type ProviderRegistryEntry = { + id: string; + label: string; + category: WebhookCategoryId; + icon?: string; + docsUrl?: string; + preset?: WebhookPresetId; + secretProvisioning: SecretProvisioning; + eventTypeSource: EventTypeSource; + sampleSource: SampleSource; +}; diff --git a/internal-packages/webhook-sources/src/registry/vapi.ts b/internal-packages/webhook-sources/src/registry/vapi.ts new file mode 100644 index 0000000000..b2f8c8f688 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/vapi.ts @@ -0,0 +1,18 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Vapi signs server webhooks with a bespoke scheme: an `x-vapi-signature` header holding a raw + * HMAC-SHA256 hex digest of the raw body, keyed by the integrator-configured server secret. There is + * no timestamp component and no `sha256=`/base64 wrapping, so it does not match our + * stripe/github/svix/square/discord presets - this ships sample-only. Every server message is wrapped + * in a top-level `message` object whose `type` field is the event discriminant. + */ +export const entry: ProviderRegistryEntry = { + id: "vapi", + label: "Vapi", + category: "voice", + docsUrl: "https://docs.vapi.ai/server-url/server-authentication", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "message.type" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/vercel.ts b/internal-packages/webhook-sources/src/registry/vercel.ts new file mode 100644 index 0000000000..945f622c81 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/vercel.ts @@ -0,0 +1,18 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Vercel signs webhooks with a bespoke scheme: the `x-vercel-signature` header carries a hex-encoded + * HMAC-SHA1 digest of the raw body (no `sha1=` prefix, no timestamp component). That doesn't match any + * of our presets (stripe/github/svix/square/discord all use SHA256 or Ed25519, and github's closest + * cousin still requires the `sha256=`-prefixed hex plus a `-256` header suffix), so this ships + * sample-only with no preset. The event name rides the top-level `type` field in the body. + */ +export const entry: ProviderRegistryEntry = { + id: "vercel", + label: "Vercel", + category: "hosting-infra", + docsUrl: "https://vercel.com/docs/webhooks", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "type" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/whatsapp.ts b/internal-packages/webhook-sources/src/registry/whatsapp.ts new file mode 100644 index 0000000000..deb6cac3bf --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/whatsapp.ts @@ -0,0 +1,20 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * WhatsApp Cloud API (Meta). Signs with `X-Hub-Signature-256: sha256=` HMAC-SHA256 over the raw + * body, matching our `github` preset exactly. Note: Meta's real payloads always carry + * `entry[].changes[].field === "messages"` for both inbound messages AND status updates, so that + * field cannot discriminate the two; the real discriminant is which key (`value.messages` vs + * `value.statuses`) is present in the body. `eventTypeSource.path` below points at the field per the + * FACTS hypothesis, but automated extraction from it will not distinguish message vs status types. + */ +export const entry: ProviderRegistryEntry = { + id: "whatsapp", + label: "WhatsApp (Meta)", + category: "communication", + docsUrl: "https://developers.facebook.com/docs/whatsapp/cloud-api/guides/set-up-webhooks/", + preset: "github", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "entry.0.changes.0.field" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/woocommerce.ts b/internal-packages/webhook-sources/src/registry/woocommerce.ts new file mode 100644 index 0000000000..0332ab77df --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/woocommerce.ts @@ -0,0 +1,11 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "woocommerce", + label: "WooCommerce", + category: "commerce", + docsUrl: "https://woocommerce.com/document/webhooks/", + secretProvisioning: "integrator", + eventTypeSource: { from: "header", name: "x-wc-webhook-topic" }, + sampleSource: "hookdeck", +}; diff --git a/internal-packages/webhook-sources/src/registry/workos.ts b/internal-packages/webhook-sources/src/registry/workos.ts new file mode 100644 index 0000000000..879d7255b3 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/workos.ts @@ -0,0 +1,16 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * WorkOS signs webhooks with a `WorkOS-Signature` header formatted as `t=,v1=`, HMAC-SHA256 + * over `{t}.{rawBody}`. That scheme matches our `stripe` preset's algorithm but not its header name + * (`stripe-signature`), so this ships sample-only until a custom verifier config exists for it. + */ +export const entry: ProviderRegistryEntry = { + id: "workos", + label: "WorkOS", + category: "auth-identity", + docsUrl: "https://workos.com/docs/events/data-syncing/webhooks", + secretProvisioning: "provider", + eventTypeSource: { from: "body", path: "event" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/zendesk.ts b/internal-packages/webhook-sources/src/registry/zendesk.ts new file mode 100644 index 0000000000..3bf8c02761 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/zendesk.ts @@ -0,0 +1,24 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +/** + * Zendesk signs webhooks with an `X-Zendesk-Webhook-Signature` header: base64(HMAC-SHA256(timestamp + + * body)), paired with an `X-Zendesk-Webhook-Timestamp` header carrying the timestamp. That + * concatenated-no-separator scheme matches none of our presets (closest is `svix`, which needs a + * dot-joined `{id}.{timestamp}.{body}` string and a `webhook-id` header Zendesk never sends), so this + * ships sample-only. + * + * The request body has no fixed schema either: Zendesk triggers/automations send whatever JSON an + * admin authors from content placeholders (e.g. `{{ticket.id}}`, `{{ticket.status}}`, rendered with + * real values before delivery), so there is no universal discriminant field. `status` is the closest + * ever-present, varying field across a common ticket-shaped body; each sample's own `eventType` is the + * authoritative label regardless of what `status` holds. + */ +export const entry: ProviderRegistryEntry = { + id: "zendesk", + label: "Zendesk", + category: "support", + docsUrl: "https://developer.zendesk.com/documentation/webhooks/verifying/", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "status" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/registry/zoom.ts b/internal-packages/webhook-sources/src/registry/zoom.ts new file mode 100644 index 0000000000..e673247f11 --- /dev/null +++ b/internal-packages/webhook-sources/src/registry/zoom.ts @@ -0,0 +1,11 @@ +import { type ProviderRegistryEntry } from "./types.js"; + +export const entry: ProviderRegistryEntry = { + id: "zoom", + label: "Zoom", + category: "communication", + docsUrl: "https://developers.zoom.us/docs/api/webhooks/", + secretProvisioning: "integrator", + eventTypeSource: { from: "body", path: "event" }, + sampleSource: "handauthored", +}; diff --git a/internal-packages/webhook-sources/src/roundtrip.test.ts b/internal-packages/webhook-sources/src/roundtrip.test.ts new file mode 100644 index 0000000000..6f8c697f92 --- /dev/null +++ b/internal-packages/webhook-sources/src/roundtrip.test.ts @@ -0,0 +1,95 @@ +import { signWithVerifierConfig, verify } from "@internal/webhook-engine"; +import { + discordVerifierConfig, + githubVerifierConfig, + squareVerifierConfig, + stripeVerifierConfig, + svixVerifierConfig, + webhookProviderConfigs, + type WebhookPresetId, + type WebhookVerifierConfig, +} from "@trigger.dev/core/webhooks"; +import { type SampleRecord as SampleRecordType } from "./sampleRecord.js"; +import { describe, expect, it } from "vitest"; +import { SampleRecord } from "./sampleRecord.js"; +import { samples } from "./samples.js"; + +const TEST_SECRET = "whsec_test_secret_for_roundtrip_only"; + +function configForPreset(presetId: WebhookPresetId): WebhookVerifierConfig | null { + switch (presetId) { + case "stripe": + return stripeVerifierConfig(); + case "github": + return githubVerifierConfig(); + case "svix": + return svixVerifierConfig(); + case "square": + return squareVerifierConfig(); + case "discord": + return discordVerifierConfig(); + case "custom": + return null; + } +} + +describe("webhook-sources schema", () => { + it("every bundled sample matches the SampleRecord schema", () => { + expect(samples.length).toBeGreaterThan(0); + for (const sample of samples) { + SampleRecord.parse(sample); + } + }); +}); + +function configForSample(sample: SampleRecordType): WebhookVerifierConfig | null { + const provider = webhookProviderConfigs[sample.provider as keyof typeof webhookProviderConfigs]; + if (provider) return provider.config(); + if (sample.presetId) return configForPreset(sample.presetId); + return null; +} + +describe("verifier round-trip (sign -> verify)", () => { + const verifiableSamples = samples.filter( + (sample) => sample.presetId || sample.provider in webhookProviderConfigs + ); + + it("has verifiable samples to check", () => { + expect(verifiableSamples.length).toBeGreaterThan(0); + }); + + for (const sample of verifiableSamples) { + it(`${sample.provider} / ${sample.eventType} signs and verifies under its config`, () => { + const config = configForSample(sample); + if (!config) return; + + const rawBody = new TextEncoder().encode(JSON.stringify(sample.body)); + const url = "https://example.com/webhooks/v1/ingest/op_roundtrip"; + + const signed = signWithVerifierConfig({ + config, + secret: TEST_SECRET, + rawBody, + url, + headers: { ...(sample.extraHeaders ?? {}) }, + }); + + if (!signed.ok) { + expect(signed.notSignable).toBe(true); + return; + } + + const verdict = verify( + { kind: "config", config }, + { + rawBytes: signed.body, + headers: signed.headers, + url: signed.url, + secret: TEST_SECRET, + } + ); + + expect(verdict.ok).toBe(true); + }); + } +}); diff --git a/internal-packages/webhook-sources/src/sampleRecord.ts b/internal-packages/webhook-sources/src/sampleRecord.ts new file mode 100644 index 0000000000..9a5fcb7d3b --- /dev/null +++ b/internal-packages/webhook-sources/src/sampleRecord.ts @@ -0,0 +1,36 @@ +import { WEBHOOK_PRESET_IDS } from "@trigger.dev/core/webhooks"; +import { z } from "zod"; + +/** + * Where a sample body came from. `snapshotDate` (YYYY-MM) drives a staleness flag; `upstream` samples + * are ingested from a maintained source (their `ref` names it), `captured`/`handauthored` otherwise. + */ +export const SampleProvenance = z.object({ + kind: z.enum(["upstream", "captured", "handauthored"]), + ref: z.string().optional(), + snapshotDate: z.string().optional(), +}); +export type SampleProvenance = z.infer; + +/** + * A single provider sample: the event BODY (plus any non-signature routing header). `provider` is a + * freeform id (e.g. "shopify"); `presetId` is set only when the provider maps to one of our verifier + * presets, which unlocks the sign/verify round-trip guarantee. The composer signs the body with the + * endpoint's own config at send time, so the signature is never stored per-sample. + */ +export const SampleRecord = z.object({ + provider: z.string(), + providerLabel: z.string().optional(), + presetId: z.enum(WEBHOOK_PRESET_IDS).optional(), + eventType: z.string(), + name: z.string(), + description: z.string().optional(), + body: z.unknown(), + extraHeaders: z.record(z.string()).optional(), + docsUrl: z.string().optional(), + provenance: SampleProvenance, +}); +export type SampleRecord = z.infer; + +/** The picker metadata (no body), for listing providers + event types. */ +export type SampleManifestItem = Omit; diff --git a/internal-packages/webhook-sources/src/samples.ts b/internal-packages/webhook-sources/src/samples.ts new file mode 100644 index 0000000000..86d696ca64 --- /dev/null +++ b/internal-packages/webhook-sources/src/samples.ts @@ -0,0 +1,16 @@ +import generated from "./generated/hookdeck-samples.json"; +import { handAuthoredSamples } from "./handAuthored/index.js"; +import { type SampleRecord } from "./sampleRecord.js"; + +/** + * The provider samples: the vendored Hookdeck ingest (MIT, see NOTICE.md; regenerate with + * `pnpm run ingest`) merged with the hand-authored set, sorted by provider then event type. + */ +export const samples: SampleRecord[] = [ + ...(generated as unknown as SampleRecord[]), + ...handAuthoredSamples, +].sort((a, b) => + a.provider === b.provider + ? a.eventType.localeCompare(b.eventType) + : a.provider.localeCompare(b.provider) +); diff --git a/internal-packages/webhook-sources/tsconfig.json b/internal-packages/webhook-sources/tsconfig.json new file mode 100644 index 0000000000..725f269f23 --- /dev/null +++ b/internal-packages/webhook-sources/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020"], + "module": "Node16", + "moduleResolution": "Node16", + "moduleDetection": "force", + "verbatimModuleSyntax": false, + "types": ["vitest/globals"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "noEmit": true, + "strict": true, + "paths": { + "@trigger.dev/core/webhooks": ["../../packages/core/src/v3/webhooks/index"] + } + }, + "exclude": ["node_modules"] +} diff --git a/internal-packages/webhook-sources/vitest.config.ts b/internal-packages/webhook-sources/vitest.config.ts new file mode 100644 index 0000000000..dea05b96d5 --- /dev/null +++ b/internal-packages/webhook-sources/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + }, + esbuild: { + target: "node18", + }, +}); diff --git a/package.json b/package.json index fc489ebab2..769ef29bce 100644 --- a/package.json +++ b/package.json @@ -100,48 +100,51 @@ "react-dom@^18": "18.3.1", "ai@^6": "6.0.116", "@ai-sdk/provider-utils@^4": "4.0.29", - "express@^4>body-parser": "1.20.3", + "axios@>=1.15.2 <1.18.0": "^1.18.0", + "express@^4>body-parser": "^1.20.6", "@remix-run/dev@2.17.5>tar-fs": "2.1.4", - "tar": "7.5.19", + "tar": "7.5.21", "form-data@^2": "2.5.4", "form-data@^3": "3.0.5", "form-data@^4": "4.0.6", "ws@>=7 <7.5.11": "7.5.11", "ws@>=8 <8.21.0": "8.21.0", - "hono@>=4 <4.12.25": "4.12.25", - "undici@>=6 <6.27.0": "6.27.0", - "undici@>=7 <7.28.0": "7.28.0", - "js-yaml@>=3.0.0 <3.14.2": "3.14.2", - "js-yaml@>=4.0.0 <4.1.1": "4.1.1", + "hono@>=4 <4.12.34": "4.12.34", + "undici@>=6 <6.28.0": "6.28.0", + "undici@>=7 <7.29.0": "7.29.0", + "js-yaml@>=3.0.0 <3.15.1": "3.15.1", + "js-yaml@>=4.0.0 <4.3.1": "4.3.1", "jws@<3.2.3": "3.2.3", "qs@>=6.0.0 <6.15.2": "^6.15.2", "lodash@>=4.17 <4.18.0": "^4.18.0", "lodash-es@>=4.17 <4.18.0": "^4.18.0", - "dompurify@>=3 <3.4.0": "^3.4.1", - "vite@>=5.0.0 <6.4.2": "^6.4.2", + "dompurify@>=3 <3.4.13": "^3.4.13", + "vite@>=5.0.0 <6.4.3": "^6.4.3", "rollup@>=4 <4.59.0": "^4.59.0", "flatted@>=3 <3.4.2": "^3.4.2", "picomatch@>=2 <2.3.2": "^2.3.2", "picomatch@>=4 <4.0.4": "^4.0.4", "minimatch@>=3 <3.1.3": "^3.1.3", - "protobufjs@>=7 <7.5.6": "^7.5.6", + "protobufjs@>=7 <7.6.5": "^7.6.5", "fast-xml-parser@>=4 <4.5.5": "^4.5.5", "fast-xml-parser@>=5 <5.7.0": "^5.7.0", "path-to-regexp@>=0.1 <0.1.13": "^0.1.13", "ajv@>=8 <8.18.0": "^8.18.0", - "socket.io-parser@>=4 <4.2.6": "^4.2.6", - "postcss@>=8 <8.5.10": "^8.5.10", + "socket.io-parser@>=4 <4.2.7": "^4.2.7", + "postcss@>=8 <8.5.23": "^8.5.23", "yaml@>=2 <2.8.3": "^2.8.3", "semver@>=5 <5.7.2": "^5.7.2", "defu@>=6 <6.1.5": "^6.1.5", - "fast-uri@<3.1.2": "^3.1.2", + "fast-uri@>=3 <3.1.5": "^3.1.5", "js-cookie@<3.0.8": "3.0.8", "tmp@<0.2.7": "0.2.7", - "brace-expansion@<1.1.13": "1.1.13", - "brace-expansion@>=2 <2.0.3": "2.0.3", - "brace-expansion@>=5 <5.0.6": "5.0.6", + "brace-expansion@<1.1.18": "1.1.18", + "brace-expansion@>=2 <2.1.4": "2.1.4", + "brace-expansion@>=5 <5.0.9": "5.0.9", "mermaid@>=11 <11.16.1": "^11.16.1", - "@jsonhero/json-infer-types>ip-address": "^10.2.0", + "@jsonhero/json-infer-types>ip-address": "^10.3.1", + "express-rate-limit>ip-address": "^10.3.1", + "find-my-way@>=9 <9.7.0": "^9.7.0", "@modelcontextprotocol/sdk@>=1.26.0>express-rate-limit": "^8.6.0" }, "onlyBuiltDependencies": [ diff --git a/packages/cli-v3/src/deploy/buildImage.test.ts b/packages/cli-v3/src/deploy/buildImage.test.ts index cbeb58c083..3540d75794 100644 --- a/packages/cli-v3/src/deploy/buildImage.test.ts +++ b/packages/cli-v3/src/deploy/buildImage.test.ts @@ -1,20 +1,36 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import type { BuildRuntime } from "@trigger.dev/core/v3/schemas"; import { describe, expect, it } from "vitest"; -import { generateContainerfile } from "./buildImage.js"; +import { + BASE_IMAGE, + BUILD_IMAGE, + DEFAULT_PACKAGES, + TOOLCHAIN_PACKAGES, + generateContainerfile, +} from "./buildImage.js"; -const nodeImages: Array<[BuildRuntime, string]> = [ +const images: Array<[BuildRuntime, string, string]> = [ [ "node-24", - "node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d", + "triggerdotdev/node:24-bookworm@sha256:d2d0c01822409f6d2de1cc69a9e718424048fa12fc64b223dfa84f6db44d0ffd", + "triggerdotdev/node:24-bookworm-build@sha256:19322289508ae9b4be0b769acac179637e4b57d363844682f4b116feb951267d", ], [ "node-26", - "node:26.4.0-bookworm-slim@sha256:ec82d089a8ae2cf02628da7b34ea57dc357b24db724d557fe2d240e6beb659c1", + "triggerdotdev/node:26-bookworm@sha256:0e9b19f814f32d3766a8cf167835a499349df5bd34702611577b1f75bc2d2026", + "triggerdotdev/node:26-bookworm-build@sha256:de5cdfd683dabad582182c79779135d59faac0e6893b6cf04520d5a0dc826dd7", + ], + [ + "bun", + "triggerdotdev/bun:1.3-node20-bookworm@sha256:25b467196277b9d75a37773ee36d28b65ca81a6f41786f7d7d7f1fad95fb5a31", + "triggerdotdev/bun:1.3-node20-bookworm-build@sha256:a2d5e6d1ec25946ca1d86abdd9ffb9c589376df64ee1b490c461607953931245", ], ]; describe("generateContainerfile", () => { - it.each(nodeImages)("selects the pinned multiplatform image for %s", async (runtime, image) => { + it.each(images)("uses the pinned published base images for %s", async (runtime, base, build) => { const containerfile = await generateContainerfile({ runtime, build: {}, @@ -23,7 +39,108 @@ describe("generateContainerfile", () => { entrypoint: "entrypoint.js", }); - expect(containerfile).toContain(`FROM ${image} AS base`); + expect(containerfile).toContain(`FROM ${base} AS base`); + expect(containerfile).toContain(`FROM ${build} AS build`); + }); + + it.each(["node", "bun"] as BuildRuntime[])( + "runs no package installation for uncustomized projects on %s", + async (runtime) => { + const containerfile = await generateContainerfile({ + runtime, + build: {}, + image: undefined, + indexScript: "index.js", + entrypoint: "entrypoint.js", + }); + + expect(containerfile).not.toContain("apt-get"); + expect(containerfile).not.toContain("FROM base AS build"); + } + ); + + it("pairs every runtime image with its -build variant", () => { + for (const runtime of Object.keys(BASE_IMAGE) as BuildRuntime[]) { + const baseRef = BASE_IMAGE[runtime].split("@")[0]; + const buildRef = BUILD_IMAGE[runtime].split("@")[0]; + + expect(buildRef).toBe(`${baseRef}-build`); + expect(BASE_IMAGE[runtime]).toMatch(/@sha256:[a-f0-9]{64}$/); + expect(BUILD_IMAGE[runtime]).toMatch(/@sha256:[a-f0-9]{64}$/); + } + }); + + it("matches the published base image package lists", () => { + const imagesJson = JSON.parse( + readFileSync( + join(fileURLToPath(import.meta.url), "../../../../../base-images/images.json"), + "utf-8" + ) + ); + + expect(imagesJson.packages.split(" ").sort()).toEqual([...DEFAULT_PACKAGES].sort()); + expect(imagesJson.buildPackages).toBe(TOOLCHAIN_PACKAGES); + }); + + it("applies instructions once and builds FROM base when they are present", async () => { + const containerfile = await generateContainerfile({ + runtime: "node-22", + build: {}, + image: { + pkgs: ["jq", "curl", "git"], + instructions: ["RUN echo custom > /etc/marker"], + }, + indexScript: "index.js", + entrypoint: "entrypoint.js", + }); + + // sorted, defaults filtered out, downgrades allowed for pinned defaults, + // and repaired first: apt-get install refuses to run on dpkg state broken + // by an instruction + const installRun = `apt-get --fix-broken install -y --no-install-recommends && \\ + apt-get install -y --no-install-recommends --allow-downgrades curl jq`; + const first = containerfile.indexOf(installRun); + + expect(first).toBeGreaterThan(containerfile.indexOf("RUN echo custom > /etc/marker")); + expect(containerfile.indexOf(installRun, first + 1)).toBe(-1); + expect(containerfile).toContain("FROM base AS build"); + expect(containerfile).toContain(TOOLCHAIN_PACKAGES); + }); + + it("keeps the prebuilt toolchain image for package-only projects", async () => { + const containerfile = await generateContainerfile({ + runtime: "node-22", + build: {}, + image: { pkgs: ["jq"] }, + indexScript: "index.js", + entrypoint: "entrypoint.js", + }); + + const installLine = "apt-get install -y --no-install-recommends --allow-downgrades jq"; + const first = containerfile.indexOf(installLine); + + // installed in both stages, since base and build are separate images + expect(first).toBeGreaterThan(-1); + expect(containerfile.indexOf(installLine, first + 1)).toBeGreaterThan(first); + expect(containerfile).not.toContain("FROM base AS build"); + // a pristine base has nothing to repair + expect(containerfile).not.toContain("--fix-broken"); + }); + + it("repairs dpkg state after instructions when there are no user packages", async () => { + const containerfile = await generateContainerfile({ + runtime: "node-22", + build: {}, + image: { instructions: ["RUN echo custom > /etc/marker"] }, + indexScript: "index.js", + entrypoint: "entrypoint.js", + }); + + const instructions = containerfile.indexOf("RUN echo custom > /etc/marker"); + const repair = containerfile.indexOf("apt-get --fix-broken install -y"); + + expect(instructions).toBeGreaterThan(-1); + expect(repair).toBeGreaterThan(instructions); }); it.each(["node", "bun"] as BuildRuntime[])( diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index 210a70be34..0a077a5d8c 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -687,18 +687,31 @@ export type GenerateContainerfileOptions = { entrypoint: string; }; -const BASE_IMAGE: Record = { - bun: "imbios/bun-node:1.3.3-20-slim@sha256:59d84856a7e31eec83afedadb542f7306f672343b8b265c70d733404a6e8834b", - node: "node:21.7.3-bookworm-slim@sha256:dfc05dee209a1d7adf2ef189bd97396daad4e97c6eaa85778d6f75205ba1b0fb", +// Prebuilt in base-images/; both maps must be bumped together, from one publish run +export const BASE_IMAGE: Record = { + bun: "triggerdotdev/bun:1.3-node20-bookworm@sha256:25b467196277b9d75a37773ee36d28b65ca81a6f41786f7d7d7f1fad95fb5a31", + node: "triggerdotdev/node:21-bookworm@sha256:49c6575cda32f63ac21a4aeaabc360dc50c6f767b86b675b44de7a9e9b6ca3fc", "node-22": - "node:22.16.0-bookworm-slim@sha256:048ed02c5fd52e86fda6fbd2f6a76cf0d4492fd6c6fee9e2c463ed5108da0e34", + "triggerdotdev/node:22-bookworm@sha256:3d1b59a1d50c3df713078a7b18386441cf7fdbaeea6da799247df5a2e180bdd5", "node-24": - "node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d", + "triggerdotdev/node:24-bookworm@sha256:d2d0c01822409f6d2de1cc69a9e718424048fa12fc64b223dfa84f6db44d0ffd", "node-26": - "node:26.4.0-bookworm-slim@sha256:ec82d089a8ae2cf02628da7b34ea57dc357b24db724d557fe2d240e6beb659c1", + "triggerdotdev/node:26-bookworm@sha256:0e9b19f814f32d3766a8cf167835a499349df5bd34702611577b1f75bc2d2026", }; -const DEFAULT_PACKAGES = ["busybox", "ca-certificates", "dumb-init", "git", "openssl"]; +export const BUILD_IMAGE: Record = { + bun: "triggerdotdev/bun:1.3-node20-bookworm-build@sha256:a2d5e6d1ec25946ca1d86abdd9ffb9c589376df64ee1b490c461607953931245", + node: "triggerdotdev/node:21-bookworm-build@sha256:98f2bc6beb124da3c3aa9abba587a6c3d08a1aada217b5bb91f843364184d1d0", + "node-22": + "triggerdotdev/node:22-bookworm-build@sha256:acc6f0143021f532b601bf9fa2cd7745b07612358f94acb8e1cd864468320a81", + "node-24": + "triggerdotdev/node:24-bookworm-build@sha256:19322289508ae9b4be0b769acac179637e4b57d363844682f4b116feb951267d", + "node-26": + "triggerdotdev/node:26-bookworm-build@sha256:de5cdfd683dabad582182c79779135d59faac0e6893b6cf04520d5a0dc826dd7", +}; + +// Preinstalled in the published base images; must match base-images/images.json +export const DEFAULT_PACKAGES = ["busybox", "ca-certificates", "dumb-init", "git", "openssl"]; export async function generateContainerfile(options: GenerateContainerfileOptions) { switch (options.runtime) { @@ -714,6 +727,31 @@ export async function generateContainerfile(options: GenerateContainerfileOption } } +// repair: apt-get install refuses to run on dpkg state an instruction left +// broken (the dpkg -i pattern). --allow-downgrades: a user pin of a +// preinstalled package is a downgrade by the time this runs. +function aptInstall(packages: string[], { repair }: { repair: boolean }): string { + const repairStep = repair + ? `apt-get --fix-broken install -y --no-install-recommends && \\ + ` + : ""; + + return `RUN apt-get update && \\ + ${repairStep}apt-get install -y --no-install-recommends --allow-downgrades ${packages.join(" ")} && \\ + apt-get clean && \\ + rm -rf /var/lib/apt/lists/*`; +} + +function aptRepair(): string { + return `RUN apt-get update && \\ + apt-get --fix-broken install -y --no-install-recommends && \\ + apt-get clean && \\ + rm -rf /var/lib/apt/lists/*`; +} + +// Must match base-images/images.json buildPackages, which the -build images preinstall +export const TOOLCHAIN_PACKAGES = "python3 make g++"; + const parseGenerateOptions = (options: GenerateContainerfileOptions) => { const buildArgs = Object.entries(options.build.env || {}) .flatMap(([key]) => `ARG ${key}`) @@ -726,43 +764,59 @@ const parseGenerateOptions = (options: GenerateContainerfileOptions) => { const postInstallCommands = (options.build.commands || []).map((cmd) => `RUN ${cmd}`).join("\n"); const baseInstructions = (options.image?.instructions || []).join("\n"); - const packages = Array.from(new Set(DEFAULT_PACKAGES.concat(options.image?.pkgs || []))).join( - " " - ); + const userPackages = Array.from(new Set(options.image?.pkgs || [])) + .filter((pkg) => !DEFAULT_PACKAGES.includes(pkg)) + .sort(); + + const customization = [ + baseInstructions, + userPackages.length > 0 + ? aptInstall(userPackages, { repair: baseInstructions.length > 0 }) + : baseInstructions + ? aptRepair() + : "", + ] + .filter(Boolean) + .join("\n\n"); + + // Instructions run once (FROM base) since their downloads are unbounded; + // package-only projects keep the prebuilt toolchain and repeat the small install + const buildStage = baseInstructions + ? `FROM base AS build + +RUN apt-get update && \\ + apt-get install -y --no-install-recommends ${TOOLCHAIN_PACKAGES} && \\ + apt-get clean && \\ + rm -rf /var/lib/apt/lists/*` + : `FROM ${BUILD_IMAGE[options.runtime]} AS build + +ENV DEBIAN_FRONTEND=noninteractive${ + userPackages.length > 0 ? `\n\n${aptInstall(userPackages, { repair: false })}` : "" + }`; return { baseImage: BASE_IMAGE[options.runtime], - baseInstructions, + buildStage, + customization, buildArgs, buildEnvVars, - packages, postInstallCommands, }; }; async function generateBunContainerfile(options: GenerateContainerfileOptions) { - const { baseImage, buildArgs, buildEnvVars, postInstallCommands, baseInstructions, packages } = + const { baseImage, buildStage, buildArgs, buildEnvVars, postInstallCommands, customization } = parseGenerateOptions(options); return `# syntax=docker/dockerfile:1 # check=skip=SecretsUsedInArgOrEnv FROM ${baseImage} AS base -${baseInstructions} - ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update && \ - apt-get --fix-broken install -y && \ - apt-get install -y --no-install-recommends ${packages} && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* -FROM base AS build +${customization} -RUN apt-get update && \ - apt-get install -y --no-install-recommends python3 make g++ && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* +${buildStage} USER bun WORKDIR /app @@ -853,28 +907,18 @@ CMD [] } async function generateNodeContainerfile(options: GenerateContainerfileOptions) { - const { baseImage, buildArgs, buildEnvVars, postInstallCommands, baseInstructions, packages } = + const { baseImage, buildStage, buildArgs, buildEnvVars, postInstallCommands, customization } = parseGenerateOptions(options); return `# syntax=docker/dockerfile:1 # check=skip=SecretsUsedInArgOrEnv FROM ${baseImage} AS base -${baseInstructions} - ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update && \ - apt-get --fix-broken install -y && \ - apt-get install -y --no-install-recommends ${packages} && \ - apt-get clean && rm -rf /var/lib/apt/lists/* - -FROM base AS build - -# Install build dependencies -RUN apt-get update && \ - apt-get install -y --no-install-recommends python3 make g++ && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* + +${customization} + +${buildStage} USER node WORKDIR /app diff --git a/packages/core/package.json b/packages/core/package.json index e2ee6f0642..8ce700a5c4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,6 +25,7 @@ "./logger": "./src/logger.ts", "./utils": "./src/utils.ts", "./schemas": "./src/schemas/index.ts", + "./webhooks": "./src/v3/webhooks/index.ts", "./types": "./src/types.ts", "./v3": "./src/v3/index.ts", "./v3/tracer": "./src/v3/tracer.ts", @@ -74,6 +75,9 @@ "schemas": [ "dist/commonjs/schemas/index.d.ts" ], + "webhooks": [ + "dist/commonjs/v3/webhooks/index.d.ts" + ], "types": [ "dist/commonjs/types.d.ts" ], @@ -218,9 +222,8 @@ "execa": "^8.0.1", "humanize-duration": "^3.27.3", "jose": "^5.4.0", - "nanoid": "3.3.8", + "nanoid": "3.3.18", "prom-client": "^15.1.0", - "socket.io": "4.7.4", "socket.io-client": "4.7.5", "std-env": "^3.8.1", "tinyexec": "^0.3.2", @@ -233,6 +236,7 @@ "@ai-sdk/provider-utils": "^1.0.22", "@arethetypeswrong/cli": "^0.18.5", "@epic-web/test-server": "^0.1.0", + "@internal/testcontainers": "workspace:*", "@trigger.dev/database": "workspace:*", "@types/humanize-duration": "^3.27.1", "@types/lodash.get": "^4.4.9", @@ -296,6 +300,17 @@ "default": "./dist/commonjs/schemas/index.js" } }, + "./webhooks": { + "import": { + "@triggerdotdev/source": "./src/v3/webhooks/index.ts", + "types": "./dist/esm/v3/webhooks/index.d.ts", + "default": "./dist/esm/v3/webhooks/index.js" + }, + "require": { + "types": "./dist/commonjs/v3/webhooks/index.d.ts", + "default": "./dist/commonjs/v3/webhooks/index.js" + } + }, "./types": { "import": { "@triggerdotdev/source": "./src/types.ts", diff --git a/packages/core/src/v3/isomorphic/friendlyId.test.ts b/packages/core/src/v3/isomorphic/friendlyId.test.ts index 439de1e8c8..22b08bce7c 100644 --- a/packages/core/src/v3/isomorphic/friendlyId.test.ts +++ b/packages/core/src/v3/isomorphic/friendlyId.test.ts @@ -4,6 +4,7 @@ import { WaitpointId, SnapshotId, QueueId, + WebhookDeliveryId, RUN_OPS_ID_LENGTH, RUN_OPS_ID_REGION_INDEX, RUN_OPS_ID_VERSION, @@ -201,3 +202,51 @@ describe("firekeeper pod-name round-trip (runner-[-attempt-N] → run_)" expect(podName.length).toBeLessThanOrEqual(63); }); }); + +describe("WebhookDeliveryId (time-encoded)", () => { + it("generate() round-trips and parseTimestamp recovers the exact mint timestamp", () => { + const { id, friendlyId, timestamp } = WebhookDeliveryId.generate(); + + expect(friendlyId).toBe(`whd_${id}`); + expect(id.length).toBe(25); + expect(WebhookDeliveryId.toId(friendlyId)).toBe(id); + expect(WebhookDeliveryId.toId(id)).toBe(id); + expect(WebhookDeliveryId.toFriendlyId(id)).toBe(friendlyId); + expect(WebhookDeliveryId.parseTimestamp(friendlyId)?.getTime()).toBe(timestamp.getTime()); + expect(WebhookDeliveryId.parseTimestamp(id)?.getTime()).toBe(timestamp.getTime()); + }); + + it("encodes the wall-clock mint time so the partition key is recoverable", () => { + vi.useFakeTimers(); + try { + const minted = new Date("2026-08-09T12:34:56.789Z"); + vi.setSystemTime(minted); + const { friendlyId, timestamp } = WebhookDeliveryId.generate(); + expect(timestamp.getTime()).toBe(minted.getTime()); + expect(WebhookDeliveryId.parseTimestamp(friendlyId)?.toISOString()).toBe( + "2026-08-09T12:34:56.789Z" + ); + } finally { + vi.useRealTimers(); + } + }); + + it("sorts lexicographically in mint order at millisecond resolution", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-08-09T00:00:00.000Z")); + const first = WebhookDeliveryId.generate().id; + vi.setSystemTime(new Date("2026-08-09T00:00:00.001Z")); + const second = WebhookDeliveryId.generate().id; + expect(first < second).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("returns undefined for legacy or malformed ids so callers skip pruning", () => { + expect(WebhookDeliveryId.parseTimestamp("whd_tooShort")).toBeUndefined(); + expect(WebhookDeliveryId.parseTimestamp(`whd_${"z".repeat(24)}1`)).toBeUndefined(); + expect(WebhookDeliveryId.parseTimestamp(`whd_${"0".repeat(24)}9`)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/v3/isomorphic/friendlyId.ts b/packages/core/src/v3/isomorphic/friendlyId.ts index 3141b75aec..e390e1a461 100644 --- a/packages/core/src/v3/isomorphic/friendlyId.ts +++ b/packages/core/src/v3/isomorphic/friendlyId.ts @@ -267,6 +267,77 @@ export const BulkActionId = new IdUtil("bulk"); export const AttemptId = new IdUtil("attempt"); export const ErrorId = new IdUtil("error"); export const SessionId = new IdUtil("session"); +export const WebhookEndpointId = new IdUtil("wh"); // wh_... + +/** + * Webhook delivery id: time-encoded so the partition key (`createdAt`) is recoverable from the id. + * The body is `base32hex(6-byte big-endian unix ms timestamp + 9 CSPRNG bytes)` (24 chars) plus a + * version char "1", prefixed `whd_`. `WebhookDelivery` is RANGE-partitioned on `createdAt`, and the + * engine sets the row's `createdAt` to the mint timestamp, so a point lookup by id can recover the + * partition from the id alone instead of threading `createdAt` through every caller. + */ +const WEBHOOK_DELIVERY_ID_PREFIX = "whd"; +const WEBHOOK_DELIVERY_ID_TIMESTAMP_BYTES = 6; +const WEBHOOK_DELIVERY_ID_CORE_BYTES = 15; +const WEBHOOK_DELIVERY_ID_CORE_LENGTH = 24; +const WEBHOOK_DELIVERY_ID_VERSION = "1"; +const WEBHOOK_DELIVERY_ID_BODY_LENGTH = 25; + +function webhookDeliveryIdBody(idOrFriendlyId: string): string { + return idOrFriendlyId.startsWith(`${WEBHOOK_DELIVERY_ID_PREFIX}_`) + ? idOrFriendlyId.slice(WEBHOOK_DELIVERY_ID_PREFIX.length + 1) + : idOrFriendlyId; +} + +export const WebhookDeliveryId = { + /** + * Mint a delivery id. The row's `createdAt` MUST be set to the returned `timestamp` so the id's + * embedded timestamp equals the partition key that {@link WebhookDeliveryId.parseTimestamp} recovers. + */ + generate(): { id: string; friendlyId: string; timestamp: Date } { + const timestamp = new Date(); + const core = new Uint8Array(WEBHOOK_DELIVERY_ID_CORE_BYTES); + let ms = timestamp.getTime(); + for (let i = WEBHOOK_DELIVERY_ID_TIMESTAMP_BYTES - 1; i >= 0; i--) { + core[i] = ms % 256; + ms = Math.floor(ms / 256); + } + getRandomValues(core.subarray(WEBHOOK_DELIVERY_ID_TIMESTAMP_BYTES)); + const id = `${base32hexEncode(core)}${WEBHOOK_DELIVERY_ID_VERSION}`; + return { id, friendlyId: `${WEBHOOK_DELIVERY_ID_PREFIX}_${id}`, timestamp }; + }, + + toFriendlyId(id: string): string { + return id.startsWith(`${WEBHOOK_DELIVERY_ID_PREFIX}_`) + ? id + : `${WEBHOOK_DELIVERY_ID_PREFIX}_${id}`; + }, + + toId(idOrFriendlyId: string): string { + return webhookDeliveryIdBody(idOrFriendlyId); + }, + + /** + * Decode the mint timestamp (== the row's `createdAt` partition key) from an id or friendlyId. + * Returns `undefined` if the value is not a well-formed time-encoded id. + */ + parseTimestamp(idOrFriendlyId: string): Date | undefined { + const body = webhookDeliveryIdBody(idOrFriendlyId); + if (body.length !== WEBHOOK_DELIVERY_ID_BODY_LENGTH) return undefined; + if (body[WEBHOOK_DELIVERY_ID_CORE_LENGTH] !== WEBHOOK_DELIVERY_ID_VERSION) return undefined; + let core: Uint8Array; + try { + core = base32hexDecode(body.slice(0, WEBHOOK_DELIVERY_ID_CORE_LENGTH)); + } catch { + return undefined; + } + let ms = 0; + for (let i = 0; i < WEBHOOK_DELIVERY_ID_TIMESTAMP_BYTES; i++) { + ms = ms * 256 + (core[i] ?? 0); + } + return new Date(ms); + }, +}; export class IdGenerator { private alphabet: string; diff --git a/packages/core/src/v3/otel/tracingSDK.test.ts b/packages/core/src/v3/otel/tracingSDK.test.ts new file mode 100644 index 0000000000..3595fceff7 --- /dev/null +++ b/packages/core/src/v3/otel/tracingSDK.test.ts @@ -0,0 +1,234 @@ +import { + OtelCollectorContainer, + type StartedOtelCollectorContainer, +} from "@internal/testcontainers"; + +import { metrics } from "@opentelemetry/api"; +import { ExportResultCode } from "@opentelemetry/core"; +import { + MetricReader, + type PushMetricExporter, + type ResourceMetrics, +} from "@opentelemetry/sdk-metrics"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { TracingSDK } from "./tracingSDK.js"; + +class NoopMetricExporter implements PushMetricExporter { + forceFlushCount = 0; + + export(_metrics: ResourceMetrics, resultCallback: (result: { code: number }) => void): void { + resultCallback({ code: ExportResultCode.SUCCESS }); + } + + async forceFlush(): Promise { + this.forceFlushCount++; + } + + async shutdown(): Promise {} +} + +describe("TracingSDK with an external metric exporter", () => { + let collector: StartedOtelCollectorContainer; + let tracingSDK: TracingSDK; + + beforeAll(async () => { + collector = await new OtelCollectorContainer().start(); + + process.env.TRIGGER_OTEL_METRICS_COLLECTION_INTERVAL_MILLIS = "600000"; + + tracingSDK = new TracingSDK({ + url: collector.getOtlpHttpUrl(), + forceFlushTimeoutMillis: 30_000, + diagLogLevel: "none", + metricExporters: [new NoopMetricExporter()], + hostMetrics: true, + hostMetricGroups: ["process.cpu", "process.memory"], + nodejsRuntimeMetrics: true, + }); + }, 180_000); + + afterAll(async () => { + await tracingSDK?.shutdown(); + await collector?.stop(); + delete process.env.TRIGGER_OTEL_METRICS_COLLECTION_INTERVAL_MILLIS; + }); + + it("flushes without the collector rejecting a batch containing a NaN reading", async () => { + const gauge = metrics.getMeter("test").createObservableGauge("test.utilization"); + gauge.addCallback((result) => result.observe(NaN)); + + await expect(tracingSDK.flush()).resolves.toBeUndefined(); + }); + + it("collects from each metric reader one at a time", async () => { + let inFlight = 0; + let maxInFlight = 0; + + const gauge = metrics.getMeter("test").createObservableGauge("test.concurrency"); + gauge.addCallback(async (result) => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 5)); + result.observe(1); + inFlight--; + }); + + await tracingSDK.flush(); + + expect(maxInFlight).toBe(1); + }); +}); + +class FailingMetricReader extends MetricReader { + protected async onForceFlush(): Promise { + throw new Error("reader flush failed"); + } + + protected async onShutdown(): Promise {} +} + +class FailingShutdownMetricReader extends MetricReader { + shutdownAttempts = 0; + + protected async onForceFlush(): Promise {} + + protected async onShutdown(): Promise { + this.shutdownAttempts++; + throw new Error(`reader shutdown failed (attempt ${this.shutdownAttempts})`); + } +} + +class RecordingMetricReader extends MetricReader { + forceFlushCount = 0; + shutdownCount = 0; + + protected async onForceFlush(): Promise { + this.forceFlushCount++; + } + + protected async onShutdown(): Promise { + this.shutdownCount++; + } +} + +function captureConsoleErrors(): { lines: string[]; restore: () => void } { + const lines: string[] = []; + const original = console.error; + + console.error = (...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }; + + return { lines, restore: () => (console.error = original) }; +} + +describe("TracingSDK when one metric reader fails to flush", () => { + let recordingReader: RecordingMetricReader; + let tracingSDK: TracingSDK; + + beforeAll(() => { + recordingReader = new RecordingMetricReader(); + + tracingSDK = new TracingSDK({ + url: "http://localhost:1", + forceFlushTimeoutMillis: 5_000, + diagLogLevel: "none", + metricReaders: [new FailingMetricReader(), recordingReader], + }); + }); + + it("still flushes the readers after it", async () => { + await tracingSDK.flush().catch(() => {}); + + expect(recordingReader.forceFlushCount).toBeGreaterThan(0); + }); + + it("still reports the failure to the caller", async () => { + await expect(tracingSDK.flush()).rejects.toThrow("reader flush failed"); + }); + + it("logs the failure as it happens", async () => { + const console = captureConsoleErrors(); + + await tracingSDK.flush().catch(() => {}); + console.restore(); + + expect(console.lines.join("\n")).toContain("reader flush failed"); + }); +}); + +class OverlapRecordingMetricReader extends MetricReader { + static inFlight = 0; + static maxInFlight = 0; + + protected async onForceFlush(): Promise {} + + protected async onShutdown(): Promise { + OverlapRecordingMetricReader.inFlight++; + OverlapRecordingMetricReader.maxInFlight = Math.max( + OverlapRecordingMetricReader.maxInFlight, + OverlapRecordingMetricReader.inFlight + ); + await new Promise((resolve) => setTimeout(resolve, 5)); + OverlapRecordingMetricReader.inFlight--; + } +} + +describe("TracingSDK shutdown", () => { + it("shuts down each metric reader one at a time", async () => { + OverlapRecordingMetricReader.inFlight = 0; + OverlapRecordingMetricReader.maxInFlight = 0; + + const tracingSDK = new TracingSDK({ + url: "http://localhost:1", + forceFlushTimeoutMillis: 5_000, + diagLogLevel: "none", + metricReaders: [new OverlapRecordingMetricReader(), new OverlapRecordingMetricReader()], + }); + + await tracingSDK.shutdown().catch(() => {}); + + expect(OverlapRecordingMetricReader.maxInFlight).toBe(1); + }); + + it("still shuts down the readers after one that fails", async () => { + const recordingReader = new RecordingMetricReader(); + + const tracingSDK = new TracingSDK({ + url: "http://localhost:1", + forceFlushTimeoutMillis: 5_000, + diagLogLevel: "none", + metricReaders: [new FailingShutdownMetricReader(), recordingReader], + }); + + await tracingSDK.shutdown().catch(() => {}); + + expect(recordingReader.shutdownCount).toBeGreaterThan(0); + }); + + it("does not retry a metric reader that failed to shut down", async () => { + const failingReader = new FailingShutdownMetricReader(); + + const tracingSDK = new TracingSDK({ + url: "http://localhost:1", + forceFlushTimeoutMillis: 5_000, + diagLogLevel: "none", + metricReaders: [failingReader], + }); + + await tracingSDK.shutdown().catch(() => {}); + + expect(failingReader.shutdownAttempts).toBe(1); + }); + + it("reports the original shutdown failure, not a later one", async () => { + const tracingSDK = new TracingSDK({ + url: "http://localhost:1", + forceFlushTimeoutMillis: 5_000, + diagLogLevel: "none", + metricReaders: [new FailingShutdownMetricReader()], + }); + + await expect(tracingSDK.shutdown()).rejects.toThrow("attempt 1"); + }); +}); diff --git a/packages/core/src/v3/otel/tracingSDK.ts b/packages/core/src/v3/otel/tracingSDK.ts index 0b3a66a87b..0f4ea82a22 100644 --- a/packages/core/src/v3/otel/tracingSDK.ts +++ b/packages/core/src/v3/otel/tracingSDK.ts @@ -100,6 +100,7 @@ export class TracingSDK { private readonly _spanExporter: SpanExporter; private readonly _traceProvider: NodeTracerProvider; private readonly _meterProvider: MeterProvider; + private readonly _metricReaders: MetricReader[]; public readonly getLogger: LoggerProvider["getLogger"]; public readonly getTracer: TracerProvider["getTracer"]; @@ -318,6 +319,7 @@ export class TracingSDK { }); this._meterProvider = meterProvider; + this._metricReaders = metricReaders; metrics.setGlobalMeterProvider(meterProvider); if (config.hostMetrics) { @@ -348,15 +350,44 @@ export class TracingSDK { await Promise.all([ this._traceProvider.forceFlush(), this._logProvider.forceFlush(), - this._meterProvider.forceFlush(), + this._flushMetricReadersSerially(), ]); } + private async _flushMetricReadersSerially() { + await this._eachMetricReaderSerially("flush", (reader) => reader.forceFlush()); + } + + private async _shutdownMetricReadersSerially() { + await this._eachMetricReaderSerially("shut down", (reader) => reader.shutdown()); + await this._meterProvider.shutdown(); + } + + private async _eachMetricReaderSerially( + action: string, + run: (reader: MetricReader) => Promise + ) { + const errors: unknown[] = []; + + for (const reader of this._metricReaders) { + try { + await run(reader); + } catch (error) { + console.error(`Failed to ${action} metric reader ${reader.constructor.name}`, error); + errors.push(error); + } + } + + if (errors.length > 0) { + throw errors[0]; + } + } + public async shutdown() { await Promise.all([ this._traceProvider.shutdown(), this._logProvider.shutdown(), - this._meterProvider.shutdown(), + this._shutdownMetricReadersSerially(), ]); } } diff --git a/packages/core/src/v3/otel/tracingSDKShutdown.test.ts b/packages/core/src/v3/otel/tracingSDKShutdown.test.ts new file mode 100644 index 0000000000..6d01eb72cf --- /dev/null +++ b/packages/core/src/v3/otel/tracingSDKShutdown.test.ts @@ -0,0 +1,19 @@ +import { metrics } from "@opentelemetry/api"; +import { describe, expect, it } from "vitest"; +import { TracingSDK } from "./tracingSDK.js"; + +describe("TracingSDK shutdown", () => { + it("leaves the meter provider shut down", async () => { + const tracingSDK = new TracingSDK({ + url: "http://localhost:1", + forceFlushTimeoutMillis: 5_000, + diagLogLevel: "none", + }); + + const meterBeforeShutdown = metrics.getMeter("test"); + + await tracingSDK.shutdown().catch(() => {}); + + expect(metrics.getMeter("test")).not.toBe(meterBeforeShutdown); + }); +}); diff --git a/packages/core/src/v3/resource-catalog/catalog.ts b/packages/core/src/v3/resource-catalog/catalog.ts index 33f66e237b..1295b3e914 100644 --- a/packages/core/src/v3/resource-catalog/catalog.ts +++ b/packages/core/src/v3/resource-catalog/catalog.ts @@ -4,6 +4,8 @@ import type { SkillManifest, SkillMetadata, TaskManifest, + WebhookManifest, + WebhookMetadata, WorkerManifest, } from "../schemas/index.js"; import type { @@ -33,4 +35,13 @@ export interface ResourceCatalog { registerSkillMetadata(skill: SkillMetadata): void; listSkillManifests(): Array; getSkillManifest(id: string): SkillManifest | undefined; + registerWebhookMetadata(webhook: WebhookMetadata): void; + listWebhookManifests(): Array; + getWebhookManifest(id: string): WebhookManifest | undefined; + listWebhookIdCollisions(): Array<{ id: string; filePaths: string[] }>; + // session.webhook descriptors that were declared vs claimed by an agent's `webhooks: [...]`. A + // declared-but-unclaimed descriptor routes nothing, so the indexer surfaces it (fail loud). + registerDeclaredSessionWebhook(id: string): void; + markSessionWebhookClaimed(id: string): void; + listUnclaimedSessionWebhooks(): Array; } diff --git a/packages/core/src/v3/resource-catalog/index.ts b/packages/core/src/v3/resource-catalog/index.ts index cd5446b1fb..41211892e2 100644 --- a/packages/core/src/v3/resource-catalog/index.ts +++ b/packages/core/src/v3/resource-catalog/index.ts @@ -6,6 +6,8 @@ import type { SkillManifest, SkillMetadata, TaskManifest, + WebhookManifest, + WebhookMetadata, WorkerManifest, } from "../schemas/index.js"; import type { @@ -120,6 +122,28 @@ export class ResourceCatalogAPI { return this.#getCatalog().getSkillManifest(id); } + public registerWebhookMetadata(webhook: WebhookMetadata): void { + this.#getCatalog().registerWebhookMetadata(webhook); + } + public listWebhookManifests(): Array { + return this.#getCatalog().listWebhookManifests(); + } + public getWebhookManifest(id: string): WebhookManifest | undefined { + return this.#getCatalog().getWebhookManifest(id); + } + public listWebhookIdCollisions(): Array<{ id: string; filePaths: string[] }> { + return this.#getCatalog().listWebhookIdCollisions(); + } + public registerDeclaredSessionWebhook(id: string): void { + this.#getCatalog().registerDeclaredSessionWebhook(id); + } + public markSessionWebhookClaimed(id: string): void { + this.#getCatalog().markSessionWebhookClaimed(id); + } + public listUnclaimedSessionWebhooks(): Array { + return this.#getCatalog().listUnclaimedSessionWebhooks(); + } + #getCatalog(): ResourceCatalog { return getGlobal(API_NAME) ?? NOOP_RESOURCE_CATALOG; } diff --git a/packages/core/src/v3/resource-catalog/noopResourceCatalog.ts b/packages/core/src/v3/resource-catalog/noopResourceCatalog.ts index a78ccf4539..5fe4d2144d 100644 --- a/packages/core/src/v3/resource-catalog/noopResourceCatalog.ts +++ b/packages/core/src/v3/resource-catalog/noopResourceCatalog.ts @@ -4,6 +4,8 @@ import type { SkillManifest, SkillMetadata, TaskManifest, + WebhookManifest, + WebhookMetadata, WorkerManifest, } from "../schemas/index.js"; import { @@ -97,4 +99,26 @@ export class NoopResourceCatalog implements ResourceCatalog { getSkillManifest(id: string): SkillManifest | undefined { return undefined; } + + registerWebhookMetadata(webhook: WebhookMetadata): void { + // noop + } + listWebhookManifests(): Array { + return []; + } + getWebhookManifest(id: string): WebhookManifest | undefined { + return undefined; + } + listWebhookIdCollisions(): Array<{ id: string; filePaths: string[] }> { + return []; + } + registerDeclaredSessionWebhook(id: string): void { + // noop + } + markSessionWebhookClaimed(id: string): void { + // noop + } + listUnclaimedSessionWebhooks(): Array { + return []; + } } diff --git a/packages/core/src/v3/resource-catalog/standardResourceCatalog.ts b/packages/core/src/v3/resource-catalog/standardResourceCatalog.ts index b5cf70bb0d..16fb665332 100644 --- a/packages/core/src/v3/resource-catalog/standardResourceCatalog.ts +++ b/packages/core/src/v3/resource-catalog/standardResourceCatalog.ts @@ -6,6 +6,8 @@ import type { TaskFileMetadata, TaskMetadata, TaskManifest, + WebhookManifest, + WebhookMetadata, WorkerManifest, QueueManifest, } from "../schemas/index.js"; @@ -41,6 +43,11 @@ export class StandardResourceCatalog implements ResourceCatalog { private _queueMetadata: Map = new Map(); private _skillMetadata: Map = new Map(); private _skillFileMetadata: Map = new Map(); + private _webhookMetadata: Map = new Map(); + private _webhookFileMetadata: Map = new Map(); + private _webhookIdCollisions: Array<{ id: string; filePaths: string[] }> = []; + private _declaredSessionWebhooks: Set = new Set(); + private _claimedSessionWebhooks: Set = new Set(); private _sentinelContextWarned: Set = new Set(); // Task ids registered more than once (across files and task types). Tasks are // keyed by id below, so a second registration silently overwrites the first; @@ -355,6 +362,70 @@ export class StandardResourceCatalog implements ResourceCatalog { }; } + registerWebhookMetadata(webhook: WebhookMetadata): void { + if (!this._currentFileContext) { + return; + } + if (!webhook.id) { + return; + } + + if ( + this._webhookMetadata.has(webhook.id) && + this._currentFileContext.filePath !== NO_FILE_CONTEXT + ) { + const existingFilePath = this._webhookFileMetadata.get(webhook.id)?.filePath; + const currentFilePath = this._currentFileContext.filePath; + const collision = this._webhookIdCollisions.find((c) => c.id === webhook.id); + if (collision) { + collision.filePaths.push(currentFilePath); + } else { + this._webhookIdCollisions.push({ + id: webhook.id, + filePaths: [existingFilePath ?? currentFilePath, currentFilePath], + }); + } + } + + this._webhookFileMetadata.set(webhook.id, { ...this._currentFileContext }); + this._webhookMetadata.set(webhook.id, webhook); + } + + listWebhookIdCollisions(): Array<{ id: string; filePaths: string[] }> { + return this._webhookIdCollisions; + } + + registerDeclaredSessionWebhook(id: string): void { + if (!id) return; + this._declaredSessionWebhooks.add(id); + } + + markSessionWebhookClaimed(id: string): void { + if (!id) return; + this._claimedSessionWebhooks.add(id); + } + + listUnclaimedSessionWebhooks(): Array { + return [...this._declaredSessionWebhooks].filter((id) => !this._claimedSessionWebhooks.has(id)); + } + + listWebhookManifests(): Array { + const result: Array = []; + for (const [id, metadata] of this._webhookMetadata) { + const fileMetadata = this._webhookFileMetadata.get(id); + if (!fileMetadata) continue; + result.push({ ...metadata, ...fileMetadata }); + } + return result; + } + + getWebhookManifest(id: string): WebhookManifest | undefined { + const metadata = this._webhookMetadata.get(id); + const fileMetadata = this._webhookFileMetadata.get(id); + if (!metadata || !fileMetadata) return undefined; + return { ...metadata, ...fileMetadata }; + } + disable() { // noop } diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index f8de04bb4f..6cd100f7c3 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -1079,7 +1079,10 @@ export const ScheduleObject = z.object({ generator: ScheduleGenerator, timezone: z.string(), window: ScheduleWindow.optional(), + /** The next nominal CRON time. */ nextRun: z.coerce.date().nullish(), + /** The stable assigned time for the next nominal CRON time. */ + nextRunEffectiveAt: z.coerce.date().nullish(), environments: z.array( z.object({ id: z.string(), diff --git a/packages/core/src/v3/schemas/build.ts b/packages/core/src/v3/schemas/build.ts index 52cae0b238..dd4b019003 100644 --- a/packages/core/src/v3/schemas/build.ts +++ b/packages/core/src/v3/schemas/build.ts @@ -1,6 +1,13 @@ import { z } from "zod"; import { ConfigManifest } from "./config.js"; -import { PromptManifest, QueueManifest, SkillManifest, TaskFile, TaskManifest } from "./schemas.js"; +import { + PromptManifest, + QueueManifest, + SkillManifest, + TaskFile, + TaskManifest, + WebhookManifest, +} from "./schemas.js"; export const BuildExternal = z.object({ name: z.string(), @@ -105,6 +112,8 @@ export const WorkerManifest = z.object({ tasks: TaskManifest.array(), prompts: PromptManifest.array().optional(), skills: SkillManifest.array().optional(), + webhooks: WebhookManifest.array().optional(), // NEW + unclaimedSessionWebhooks: z.array(z.string()).optional(), // session.webhook descriptors no agent listed queues: QueueManifest.array().optional(), workerEntryPoint: z.string(), controllerEntryPoint: z.string().optional(), diff --git a/packages/core/src/v3/schemas/index.ts b/packages/core/src/v3/schemas/index.ts index 7bbe564c52..cec7b30692 100644 --- a/packages/core/src/v3/schemas/index.ts +++ b/packages/core/src/v3/schemas/index.ts @@ -12,6 +12,9 @@ export * from "./config.js"; export * from "./build.js"; export * from "./runEngine.js"; export * from "./webhooks.js"; +export * from "./webhookConfig.js"; +export * from "./webhookApi.js"; +export * from "./webhookFilter.js"; export * from "./checkpoints.js"; export * from "./warmStart.js"; export * from "./queues.js"; diff --git a/packages/core/src/v3/schemas/resources.ts b/packages/core/src/v3/schemas/resources.ts index 753324d125..bc0b7a74d8 100644 --- a/packages/core/src/v3/schemas/resources.ts +++ b/packages/core/src/v3/schemas/resources.ts @@ -1,6 +1,11 @@ import { z } from "zod"; import { QueueManifest, RetryOptions, ScheduleMetadata } from "./schemas.js"; import { MachineConfig } from "./common.js"; +import { + WebhookVerifierArtifact, + WebhookRoutingTarget, + WebhookSecretProvisioning, +} from "./webhookConfig.js"; export const AgentConfig = z.object({ type: z.string(), // "ai-sdk-chat" initially, extensible for future agent types @@ -53,12 +58,27 @@ export const PromptResource = z.object({ export type PromptResource = z.infer; +export const WebhookResource = z.object({ + id: z.string(), + description: z.string().optional(), + filePath: z.string(), + exportName: z.string().optional(), + source: z.string(), + verifierArtifact: WebhookVerifierArtifact, + routingTarget: WebhookRoutingTarget, + secretProvisioning: WebhookSecretProvisioning.optional(), + filter: z.string().optional(), // delivery filter DSL string; compiled to a FilterAst at deploy-sync + metadata: z.record(z.unknown()).optional(), +}); +export type WebhookResource = z.infer; + export const BackgroundWorkerMetadata = z.object({ packageVersion: z.string(), contentHash: z.string(), cliPackageVersion: z.string().optional(), tasks: z.array(TaskResource), prompts: z.array(PromptResource).optional(), + webhooks: z.array(WebhookResource).optional(), // NEW queues: z.array(QueueManifest).optional(), sourceFiles: z.array(BackgroundWorkerSourceFileMetadata).optional(), runtime: z.string().optional(), diff --git a/packages/core/src/v3/schemas/scheduleWindow.test.ts b/packages/core/src/v3/schemas/scheduleWindow.test.ts new file mode 100644 index 0000000000..f5812b2ed0 --- /dev/null +++ b/packages/core/src/v3/schemas/scheduleWindow.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + ScheduleWindow, + parseScheduleWindow, + type ValidatedScheduleWindow, +} from "./scheduleWindow.js"; + +describe("ScheduleWindow", () => { + it.each(["0m", "30m", "1440m", "0h", "2h", "24h", "0%", "30%", "100%"])( + "accepts %s", + (window) => { + expect(ScheduleWindow.safeParse(window).success).toBe(true); + } + ); + + it.each([ + "", + "00m", + "01m", + "1.5h", + "0d", + "1d", + "25h", + "1441m", + "30s", + "-1m", + "0.5%", + "101%", + "1e2%", + " 30m", + "30m ", + ])("rejects %j", (window) => { + expect(ScheduleWindow.safeParse(window).success).toBe(false); + }); + + it("reports percentages above 100% precisely", () => { + expect(() => parseScheduleWindow("110%")).toThrow( + "Schedule window percentage cannot exceed 100%" + ); + }); + + it("normalizes valid windows", () => { + expect(parseScheduleWindow("30m")).toEqual({ + type: "duration", + durationSeconds: 1_800, + }); + expect(parseScheduleWindow("25%")).toEqual({ + type: "percentage", + percentage: 25, + }); + }); + + it("validates literal types without rejecting runtime strings", () => { + expectTypeOf>().toEqualTypeOf<"30m">(); + expectTypeOf>().toEqualTypeOf<"24h">(); + expectTypeOf>().toEqualTypeOf<"100%">(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf< + ValidatedScheduleWindow<"25h"> + >().toEqualTypeOf<"⛔ window duration cannot exceed 24 hours">(); + expectTypeOf< + ValidatedScheduleWindow<"101%"> + >().toEqualTypeOf<"⛔ percentage cannot exceed 100%">(); + expectTypeOf< + ValidatedScheduleWindow<"1d"> + >().toEqualTypeOf<'⛔ window must look like "30m", "2h", or "50%"'>(); + }); +}); diff --git a/packages/core/src/v3/schemas/scheduleWindow.ts b/packages/core/src/v3/schemas/scheduleWindow.ts new file mode 100644 index 0000000000..ce89d76404 --- /dev/null +++ b/packages/core/src/v3/schemas/scheduleWindow.ts @@ -0,0 +1,135 @@ +import { z } from "zod"; + +export const MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS = 24 * 60 * 60; + +export type NormalizedScheduleWindow = + | { type: "duration"; durationSeconds: number } + | { type: "percentage"; percentage: number }; + +type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"; + +type DigitsBelow = { + "0": never; + "1": "0"; + "2": "0" | "1"; + "3": "0" | "1" | "2"; + "4": "0" | "1" | "2" | "3"; + "5": "0" | "1" | "2" | "3" | "4"; + "6": "0" | "1" | "2" | "3" | "4" | "5"; + "7": "0" | "1" | "2" | "3" | "4" | "5" | "6"; + "8": "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7"; + "9": "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8"; +}; + +type DigitLength< + Value extends string, + Result extends 0[] = [], +> = Value extends `${Digit}${infer Rest}` ? DigitLength : Result; + +type CompareEqualLength< + A extends string, + B extends string, +> = A extends `${infer ADigit extends Digit}${infer ARest}` + ? B extends `${infer BDigit extends Digit}${infer BRest}` + ? ADigit extends BDigit + ? CompareEqualLength + : ADigit extends DigitsBelow[BDigit] + ? "lt" + : "gt" + : "eq" + : "eq"; + +type DecimalStringLTE = + DigitLength extends DigitLength + ? CompareEqualLength extends "gt" + ? false + : true + : DigitLength extends [...DigitLength, ...0[]] + ? true + : false; + +type IsCanonicalUnsignedInteger = Value extends `${bigint}` + ? Value extends `-${string}` + ? false + : true + : false; + +/** The literal validation error for a configured schedule window, or `never` when valid. */ +export type ScheduleWindowError = string extends Window + ? never + : Window extends `${infer Amount}m` + ? IsCanonicalUnsignedInteger extends false + ? "⛔ window must be a whole non-negative number" + : DecimalStringLTE extends true + ? never + : "⛔ window duration cannot exceed 24 hours" + : Window extends `${infer Amount}h` + ? IsCanonicalUnsignedInteger extends false + ? "⛔ window must be a whole non-negative number" + : DecimalStringLTE extends true + ? never + : "⛔ window duration cannot exceed 24 hours" + : Window extends `${infer Amount}%` + ? IsCanonicalUnsignedInteger extends false + ? "⛔ percentage must be a whole non-negative number" + : DecimalStringLTE extends true + ? never + : "⛔ percentage cannot exceed 100%" + : '⛔ window must look like "30m", "2h", or "50%"'; + +/** + * Preserves valid schedule-window literals and replaces invalid literals with a descriptive type + * error. Wide `string` values pass through for authoritative runtime validation. + */ +export type ValidatedScheduleWindow = Window extends string + ? [ScheduleWindowError] extends [never] + ? Window + : ScheduleWindowError + : Window; + +/** Parses and normalizes the public schedule-window syntax. */ +export function parseScheduleWindow(value: string): NormalizedScheduleWindow { + const durationMatch = /^(0|[1-9]\d*)([mh])$/.exec(value); + + if (durationMatch) { + const amount = Number(durationMatch[1]); + const unit = durationMatch[2] as "m" | "h"; + const durationSeconds = amount * (unit === "m" ? 60 : 3_600); + + if ( + !Number.isSafeInteger(durationSeconds) || + durationSeconds > MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS + ) { + throw new RangeError("Schedule window duration cannot exceed 24 hours"); + } + + return { type: "duration", durationSeconds }; + } + + const percentageMatch = /^(0|[1-9]\d*)%$/.exec(value); + if (percentageMatch) { + const percentage = Number(percentageMatch[1]); + + if (!Number.isSafeInteger(percentage) || percentage > 100) { + throw new RangeError("Schedule window percentage cannot exceed 100%"); + } + + return { type: "percentage", percentage }; + } + + throw new TypeError( + 'Schedule window must be a whole duration such as "30m" or "2h", or a percentage such as "30%"' + ); +} + +/** Runtime authority for public schedule-window values. */ +export const ScheduleWindow = z.string().superRefine((value, ctx) => { + try { + parseScheduleWindow(value); + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: error instanceof Error ? error.message : String(error), + }); + } +}); diff --git a/packages/core/src/v3/schemas/schemas.ts b/packages/core/src/v3/schemas/schemas.ts index 7e95224f42..505622d1ef 100644 --- a/packages/core/src/v3/schemas/schemas.ts +++ b/packages/core/src/v3/schemas/schemas.ts @@ -1,5 +1,10 @@ import { z } from "zod"; import type { RequireKeys } from "../types/index.js"; +import { + WebhookVerifierArtifact, + WebhookRoutingTarget, + WebhookSecretProvisioning, +} from "./webhookConfig.js"; import { MachineConfig, MachinePreset, @@ -7,6 +12,9 @@ import { TaskRunExecution, V3TaskRunExecution, } from "./common.js"; +import { ScheduleWindow } from "./scheduleWindow.js"; + +export * from "./scheduleWindow.js"; /* WARNING: Never import anything from ./messages here. If it's needed in both, put it here instead. @@ -174,15 +182,6 @@ export const QueueManifest = z.object({ export type QueueManifest = z.infer; -/** - * A delay window after a nominal cron tick. - * - * The server's schedule timing domain validates and normalizes the public syntax. - */ -export const ScheduleWindow = z.string().min(1); - -export type ScheduleWindow = z.infer; - export const ScheduleMetadata = z.object({ cron: z.string(), timezone: z.string(), @@ -278,6 +277,28 @@ export const SkillManifest = z.object({ }); export type SkillManifest = z.infer; +// ── Webhooks ──────────────────────────────────────────────────────────────── + +const webhookMetadata = { + id: z.string(), + description: z.string().optional(), + source: z.string(), + verifierArtifact: WebhookVerifierArtifact, + routingTarget: WebhookRoutingTarget, + secretProvisioning: WebhookSecretProvisioning.optional(), + filter: z.string().optional(), // delivery filter DSL string; compiled to a FilterAst at deploy-sync + metadata: z.record(z.unknown()).optional(), +}; + +export const WebhookMetadata = z.object(webhookMetadata); +export type WebhookMetadata = z.infer; + +export const WebhookManifest = z.object({ + ...webhookMetadata, + ...taskFileMetadata, // filePath, exportName?, entryPoint +}); +export type WebhookManifest = z.infer; + export const PostStartCauses = z.enum(["index", "create", "restore"]); export type PostStartCauses = z.infer; diff --git a/packages/core/src/v3/schemas/webhookApi.ts b/packages/core/src/v3/schemas/webhookApi.ts new file mode 100644 index 0000000000..95d49251cd --- /dev/null +++ b/packages/core/src/v3/schemas/webhookApi.ts @@ -0,0 +1,106 @@ +import { z } from "zod"; +// Reuse the source-side enum (provider | integrator | either) rather than redefining it. +import { WebhookSecretProvisioning } from "./webhookConfig.js"; + +// Public HTTP API objects for webhooks (endpoints + deliveries). Read surface for now; the create +// path (dynamic endpoints) and lifecycle actions layer on later. Mirrors the Errors API shape: +// list endpoints return `{ data, pagination? }`, detail endpoints return the object directly. +// Statuses are exposed lowercase over the API (the DB enums are uppercase). + +export const WebhookEndpointApiStatus = z.enum(["active", "inactive", "deleting"]); +export type WebhookEndpointApiStatus = z.infer; + +export const WebhookDeliveryApiStatus = z.enum([ + "pending", + "processing", + "succeeded", + "failed", + "filtered", +]); +export type WebhookDeliveryApiStatus = z.infer; + +export const WebhookEndpointObject = z.object({ + /** Stable friendly id, e.g. `wh_...`. */ + id: z.string(), + /** The declared webhook() id this endpoint routes to. */ + webhook: z.string(), + /** Provider tag, e.g. "stripe" | "github" | "standard". */ + source: z.string(), + status: WebhookEndpointApiStatus, + /** Who supplies the secret/key; drives paste-vs-generate in the dashboard. */ + secretProvisioning: WebhookSecretProvisioning, + /** Whether a signing secret/public key has been set. The value is never returned. */ + secretSet: z.boolean(), + /** Tenant scope (P2 dynamic endpoints); null for the declared default endpoint. */ + tenantId: z.string().nullable(), + externalRef: z.string().nullable(), + /** The hosted ingress URL to point the provider at. */ + url: z.string(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}); +export type WebhookEndpointObject = z.infer; + +export const ListWebhookEndpointsResponse = z.object({ + data: z.array(WebhookEndpointObject), +}); +export type ListWebhookEndpointsResponse = z.infer; + +export const WebhookDeliveryListItem = z.object({ + /** Stable friendly id, e.g. `whd_...`. */ + id: z.string(), + /** The webhook() id this delivery was routed to, if resolvable. */ + webhook: z.string().nullable(), + status: WebhookDeliveryApiStatus, + /** Provider delivery id (Stripe event id, GitHub X-GitHub-Delivery, …). */ + externalDeliveryId: z.string(), + /** The triggered run's friendly id, if any. */ + runId: z.string().nullable(), + createdAt: z.coerce.date(), + processedAt: z.coerce.date().nullable(), +}); +export type WebhookDeliveryListItem = z.infer; + +export const ListWebhookDeliveriesResponse = z.object({ + data: z.array(WebhookDeliveryListItem), + pagination: z.object({ + next: z.string().optional(), + previous: z.string().optional(), + }), +}); +export type ListWebhookDeliveriesResponse = z.infer; + +export const WebhookDeliveryObject = WebhookDeliveryListItem.extend({ + idempotencyKey: z.string(), + /** The size-capped, verified event body. */ + event: z.unknown().nullable(), + /** The inbound request headers. */ + headers: z.record(z.string()).nullable(), + rawBodyHash: z.string().nullable(), + error: z.string().nullable(), + /** For a `filtered` delivery: why it was not routed (failing clause + actual value). */ + filterReason: z.string().nullable(), + updatedAt: z.coerce.date(), +}); +export type WebhookDeliveryObject = z.infer; + +// ── Write actions ── + +/** Rotating/setting a signing secret returns the plaintext ONCE; it is never readable again. */ +export const RotateWebhookEndpointSecretResponse = z.object({ + id: z.string(), + secretSet: z.literal(true), + secret: z.string(), +}); +export type RotateWebhookEndpointSecretResponse = z.infer< + typeof RotateWebhookEndpointSecretResponse +>; + +/** Replaying re-runs a delivery's task from its stored event as a new delivery. */ +export const ReplayWebhookDeliveryResponse = z.object({ + /** The new delivery's friendly id (GET it once processed for the run). */ + deliveryId: z.string(), + /** The original delivery id the replay was created from. */ + replayedFrom: z.string(), +}); +export type ReplayWebhookDeliveryResponse = z.infer; diff --git a/packages/core/src/v3/schemas/webhookConfig.ts b/packages/core/src/v3/schemas/webhookConfig.ts new file mode 100644 index 0000000000..281955a6b7 --- /dev/null +++ b/packages/core/src/v3/schemas/webhookConfig.ts @@ -0,0 +1,214 @@ +import { z } from "zod"; + +// Ingress webhook verification config. Kept in a LEAF module (imports only `z`) so it +// can be consumed by resources.ts / schemas.ts without dragging in the alert-webhook +// `Webhook` union in webhooks.ts, which imports api.js (RunStatus). resources.ts is +// imported by api.ts, so a resources -> webhooks -> api edge would be a module-init cycle. + +// Who supplies the signing secret / key, driving the dashboard Connect UI (NOT a provider-name +// switch — it's data on the source). "provider": the provider generates it and you paste it in +// (Stripe, Slack, Svix). "integrator": you choose it, so the dashboard can generate-and-reveal one +// for you to paste into the provider (GitHub, GitLab). "either": offer both. +export const WebhookSecretProvisioning = z.enum(["provider", "integrator", "either"]); +export type WebhookSecretProvisioning = z.infer; + +// Q9: the provider delivery id can live in a header (GitHub x-github-delivery) or the body (Stripe id). +export const WebhookIdempotencyField = z.object({ + from: z.enum(["header", "body"]), + name: z.string(), +}); +export type WebhookIdempotencyField = z.infer; + +// ── Shared, data-only verification primitives (no per-provider code) ── + +// Where a scalar value comes from. Used for the timestamp and for signing-string template vars. +// "signatureField" reads a field parsed out of the signature header (e.g. Stripe `t`). +// "url" is the inbound request URL (e.g. Square signs `{url}{body}`). "constant" is a literal. +export const WebhookValueSource = z.discriminatedUnion("from", [ + z.object({ from: z.literal("header"), name: z.string() }), + z.object({ from: z.literal("signatureField"), field: z.string() }), + z.object({ from: z.literal("body"), path: z.string() }), + z.object({ from: z.literal("url") }), + z.object({ from: z.literal("constant"), value: z.string() }), +]); +export type WebhookValueSource = z.infer; + +// How to pull the signature value(s) out of one header. All fields optional → the whole +// header value IS the signature (Shopify, Linear). With fieldSeparator+field we select +// named element(s): GitHub `sha256=`, Stripe `t=…,v1=…`, Svix `v1, v1,`. +// Field extraction is multi-valued (rotation / signature lists) → the verifier accepts ANY match. +export const WebhookSignatureExtraction = z.object({ + itemSeparator: z.string().optional(), // split header into elements: Stripe "," · Svix " " + fieldSeparator: z.string().optional(), // split element into namevalue: "=" · Svix "," + field: z.string().optional(), // element name carrying the signature: "v1" | "sha256" | "v0" + trim: z.boolean().optional(), // trim each element (WorkOS "t=…, v1=…") +}); +export type WebhookSignatureExtraction = z.infer; + +// Replay protection + a `{timestamp}` template var. Source is a header, an in-signature field +// (Stripe `t`), or a body field (Linear `webhookTimestamp`). Unit defaults to seconds. +export const WebhookTimestampConfig = z.object({ + source: WebhookValueSource, + unit: z.enum(["seconds", "milliseconds"]).optional(), + toleranceSeconds: z.number().optional(), +}); +export type WebhookTimestampConfig = z.infer; + +// The stored credential transform. Most providers use the secret as-is (utf8). Svix-family +// secrets are `whsec_` and must be base64-decoded after stripping the prefix. +export const WebhookSecretTransform = z.object({ + encoding: z.enum(["utf8", "base64"]).optional(), + stripPrefix: z.string().optional(), +}); +export type WebhookSecretTransform = z.infer; + +// The bytes that get signed. "raw" = the body verbatim. Otherwise a template with `{body}`, +// `{timestamp}` (auto-bound) and any named `vars` (e.g. `{id}` for Svix, `{url}` for Square). +export const WebhookSigningString = z.union([ + z.literal("raw"), + z.object({ + template: z.string(), + vars: z.record(WebhookValueSource).optional(), + }), +]); +export type WebhookSigningString = z.infer; + +/** + * Body-parsing hint. Some providers send an event as JSON but a SECOND kind of callback (e.g. Slack + * interactivity) as `application/x-www-form-urlencoded` with the JSON in a single field (Slack: `payload`). + * When JSON parsing fails, the engine form-decodes and JSON-parses `field`'s value. The signature is over + * the raw body either way, so verification is unaffected. Generic (a field name), no provider code in the engine. + */ +export const WebhookFormPayload = z.object({ + field: z.string(), +}); +export type WebhookFormPayload = z.infer; + +export const WebhookHmacConfig = z.object({ + scheme: z.literal("hmac"), + algorithm: z.enum(["sha256", "sha1"]), + encoding: z.enum(["hex", "base64"]), // explicit, no auto-detect (design decision 3) + signatureHeader: z.string(), + signature: WebhookSignatureExtraction.optional(), // omit = bare whole-header value + timestamp: WebhookTimestampConfig.optional(), + signingString: WebhookSigningString, + secret: WebhookSecretTransform.optional(), // omit = utf8, no prefix strip + idempotencyField: WebhookIdempotencyField.optional(), + formPayload: WebhookFormPayload.optional(), +}); +export type WebhookHmacConfig = z.infer; + +// Asymmetric (public-key) signatures: the provider signs with a private key, we verify with +// their public key. Same parsing/template primitives as HMAC; only the final verify differs. +// Covers Discord (ed25519), SendGrid (ecdsa-p256), RSA providers. Dynamic key fetch (PayPal +// cert URL / JWKS) is out of scope → the custom-bundle escape hatch. +export const WebhookAsymmetricConfig = z.object({ + scheme: z.literal("asymmetric"), + algorithm: z.enum(["ed25519", "ecdsa-p256", "rsa-sha256"]), + encoding: z.enum(["hex", "base64"]), // how the signature value is encoded + signatureHeader: z.string(), + signature: WebhookSignatureExtraction.optional(), + timestamp: WebhookTimestampConfig.optional(), + signingString: WebhookSigningString, + // How the stored public key is encoded: a PEM string, base64 of DER SPKI (SendGrid), + // or raw key bytes (ed25519 raw, Discord). Defaults to "pem". + publicKeyEncoding: z.enum(["pem", "spki-der-base64", "raw-hex", "raw-base64"]).optional(), + idempotencyField: WebhookIdempotencyField.optional(), +}); +export type WebhookAsymmetricConfig = z.infer; + +export const WebhookSharedSecretConfig = z.object({ + scheme: z.literal("shared-secret"), + placement: z.enum(["header", "bearer", "basic", "body"]), + fieldName: z.string().optional(), + idempotencyField: WebhookIdempotencyField.optional(), +}); +export type WebhookSharedSecretConfig = z.infer; + +export const WebhookUrlSecretConfig = z.object({ + scheme: z.literal("url-secret"), + placement: z.enum(["query", "path"]), + paramName: z.string(), + idempotencyField: WebhookIdempotencyField.optional(), +}); +export type WebhookUrlSecretConfig = z.infer; + +export const WebhookVerifierConfig = z.discriminatedUnion("scheme", [ + WebhookHmacConfig, + WebhookSharedSecretConfig, + WebhookUrlSecretConfig, + WebhookAsymmetricConfig, +]); +export type WebhookVerifierConfig = z.infer; + +// ── Provider handshake: a signed request that must get a synchronous echo, not a recorded/routed +// delivery (Slack url_verification, Discord PING). Generic + data-only: if the verified body's +// `matchPath` equals `matchValue`, ingest responds 200 with the body's `respondPath` value. ── +export const WebhookHandshakeConfig = z.object({ + matchPath: z.string(), // dotted path into the body, e.g. "type" + matchValue: z.string(), // e.g. "url_verification" + respondPath: z.string(), // dotted path to echo, e.g. "challenge" +}); +export type WebhookHandshakeConfig = z.infer; + +// ── Verifier artifact: data-only tagged union stored on WebhookEndpoint.verifierArtifact ── +export const WebhookVerifierArtifact = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("config"), + config: WebhookVerifierConfig, + handshake: WebhookHandshakeConfig.optional(), + }), + z.object({ + kind: z.literal("preset"), + preset: z.string(), + config: WebhookVerifierConfig, + handshake: WebhookHandshakeConfig.optional(), + }), + z.object({ kind: z.literal("bundle"), bundleUrl: z.string(), hash: z.string() }), // P3 seam +]); +export type WebhookVerifierArtifact = z.infer; + +// ── Routing target: data-only tagged union stored on WebhookEndpoint.routingTarget ── +// P1 implements only { type: "task" }. The session variant routes a delivery to a find-or-created +// session: keyTemplate resolves the externalId, deliverAs selects the mode. "action" (chat.event) +// carries actionType → the onAction envelope's action.type; "message" (channels) carries connectorId +// → the run resolves the connector's inbound() mapper and runs a turn. +export const WebhookRoutingTarget = z.discriminatedUnion("type", [ + z.object({ type: z.literal("task"), taskId: z.string() }), + z.object({ + type: z.literal("session"), + taskIdentifier: z.string(), + keyTemplate: z.string(), + deliverAs: z.enum(["action", "message"]), + actionType: z.string().optional(), + connectorId: z.string().optional(), + triggerConfigTemplate: z.record(z.unknown()).optional(), + // Gate session CREATION: an event that already resolves to an existing session always resumes it, + // but a key with no session is only started when the event matches this filter. Absent => always start. + startOn: z.string().optional(), + }), +]); +export type WebhookRoutingTarget = z.infer; + +// ── Data-only verdict the engine verifier returns (M5 consumes) ── +export const WebhookVerifierResult = z.object({ + ok: z.boolean(), + idempotencyKey: z.string(), + parsedEvent: z.unknown().optional(), + error: z.string().optional(), +}); +export type WebhookVerifierResult = z.infer; + +// ── Preset event-type unions (P1 ships these TYPE-only) ── +export type StripeWebhookEvent = { + id: string; + object: "event"; + type: string; + created: number; + data: { object: Record }; + [k: string]: unknown; +}; + +export type GitHubWebhookEvent = { + [k: string]: unknown; +}; diff --git a/packages/core/src/v3/schemas/webhookFilter.ts b/packages/core/src/v3/schemas/webhookFilter.ts new file mode 100644 index 0000000000..ce60613cc3 --- /dev/null +++ b/packages/core/src/v3/schemas/webhookFilter.ts @@ -0,0 +1,278 @@ +import { z } from "zod"; + +// Webhook delivery filter: server-side predicate gating routing. LEAF module (imports only `z`). + +// ── Runtime AST (stored on WebhookEndpoint.filterAst; produced by the engine parser) ── + +export const FilterOp = z.enum([ + "eq", + "neq", + "gt", + "lt", + "gte", + "lte", + "in", + "nin", + "startsWith", + "endsWith", + "contains", +]); +export type FilterOp = z.infer; + +export const FilterScalar = z.union([z.string(), z.number(), z.boolean(), z.null()]); +export type FilterScalar = z.infer; + +export const FilterValue = z.union([FilterScalar, z.array(FilterScalar)]); +export type FilterValue = z.infer; + +export type FilterAst = + | { kind: "and"; clauses: FilterAst[] } + | { kind: "or"; clauses: FilterAst[] } + // Exactly one of `value` (literal RHS) / `valueRef` (field-to-field: another path on the same event). + | { kind: "clause"; path: string; op: FilterOp; value?: FilterValue; valueRef?: string } + // `path any|all (subClause)`: the array at `path`, with `clause.path` resolved against each element. + | { + kind: "quantifier"; + mode: "any" | "all"; + path: string; + clause: { path: string; op: FilterOp; value: FilterValue }; + }; + +// Bump when the AST shape changes; a stored AST with a different version is re-parsed from `filter`. +export const FILTER_AST_VERSION = 1; + +export const FilterAst: z.ZodType = z.lazy(() => + z.union([ + z.object({ kind: z.literal("and"), clauses: z.array(FilterAst) }), + z.object({ kind: z.literal("or"), clauses: z.array(FilterAst) }), + z.object({ + kind: z.literal("clause"), + path: z.string(), + op: FilterOp, + value: FilterValue.optional(), + valueRef: z.string().optional(), + }), + z.object({ + kind: z.literal("quantifier"), + mode: z.enum(["any", "all"]), + path: z.string(), + clause: z.object({ path: z.string(), op: FilterOp, value: FilterValue }), + }), + ]) +); + +// The endpoint-level metadata the `webhook.*` namespace resolves against (mirrors WebhookEndpoint). +export type WebhookFilterMeta = { + externalRef: string; + tenantId: string; + id: string; + source: string; + deliveryId: string; +}; + +// ── Compile-time validator (type-only; the SDK `webhook({ filter })` generic uses it) ── +// Tail-recursive scanner so TS's tail-recursion elimination applies (ceiling ~200+ clauses, not ~12). + +type FilterError = `✖ ${M}`; +type Invalid = { readonly "~webhookFilterInvalid": true }; + +type PathValue = P extends `${infer H}.${infer R}` + ? H extends keyof T + ? PathValue, R> + : Invalid + : P extends keyof T + ? NonNullable + : Invalid; + +type ResolveFilterPath = P extends `event.${infer R}` + ? PathValue + : P extends `header.${string}` + ? string + : P extends `webhook.${infer R}` + ? PathValue + : Invalid; + +type FilterLiteralType = Lit extends `'${infer S}'` + ? S + : Lit extends `${infer N extends number}` + ? N + : Lit extends "true" + ? true + : Lit extends "false" + ? false + : Lit extends "null" + ? null + : Invalid; + +type CheckValue = [FilterLiteralType] extends [ + Invalid, +] + ? FilterError<`cannot parse value: ${Lit}`> + : [FilterLiteralType] extends [F] + ? true + : FilterError<`value ${Lit} is not valid for ${P}`>; +type CheckNum = [F] extends [number] + ? Lit extends `${number}` + ? true + : FilterError<`${P} needs a number`> + : FilterError<`${P} is not numeric`>; +type CheckStr = [F] extends [string] + ? Lit extends `'${string}'` + ? true + : FilterError<`${P} op needs a string literal`> + : FilterError<`${P} is not a string`>; +// `in`/`not in` lists are validated unspaced (`['a','b']`), since the type tokenizer is space-only; +// the runtime tokenizer also accepts spaced lists. +type CheckInItems< + F, + Items extends string, + P extends string, +> = Items extends `${infer Head},${infer Tail}` + ? CheckValue extends true + ? CheckInItems + : CheckValue + : CheckValue; +type CheckIn = Lit extends `[${infer Items}]` + ? CheckInItems + : FilterError<`${P} "in" expects a [list]`>; +type CheckOp = Op extends "==" | "!=" + ? CheckValue + : Op extends ">" | "<" | ">=" | "<=" + ? CheckNum + : Op extends "startsWith" | "endsWith" | "contains" + ? CheckStr + : Op extends "in" + ? CheckIn + : FilterError<`unknown operator: ${Op}`>; +// An unquoted, namespace-prefixed operand is a field reference (field-to-field), not a literal. +type IsFilterPath = S extends + | `event.${string}` + | `header.${string}` + | `webhook.${string}` + ? true + : false; + +// Field-to-field: only equality/ordering, and the two field types must be comparable. +type CheckFieldToField< + TEvent, + F, + Op extends string, + Rhs extends string, + P extends string, +> = Op extends "==" | "!=" | ">" | "<" | ">=" | "<=" + ? ResolveFilterPath extends infer RF + ? [RF] extends [Invalid] + ? FilterError<`unknown field: ${Rhs}`> + : [RF] extends [F] + ? true + : [F] extends [RF] + ? true + : FilterError<`${P} and ${Rhs} are not comparable`> + : never + : FilterError<`operator "${Op}" does not support a field reference`>; + +type LeafCheck = + ResolveFilterPath extends infer F + ? [F] extends [Invalid] + ? FilterError<`unknown field: ${P}`> + : IsFilterPath extends true + ? CheckFieldToField + : CheckOp + : never; + +// Quantifier: `arrPath any|all ( subPath op lit )` — the sub-clause resolves against the array element. +type ElementOf = T extends readonly (infer E)[] ? E : never; +type CheckQuantifier< + TEvent, + ArrP extends string, + SubP extends string, + Op extends string, + Lit extends string, +> = + ResolveFilterPath extends infer Arr + ? [Arr] extends [Invalid] + ? FilterError<`unknown field: ${ArrP}`> + : [ElementOf] extends [never] + ? FilterError<`${ArrP} is not an array`> + : PathValue, SubP> extends infer SF + ? [SF] extends [Invalid] + ? FilterError<`unknown field: ${SubP} (in ${ArrP})`> + : CheckOp + : never + : never; + +type Tokenize = S extends `${infer H} ${infer R}` + ? Tokenize + : [...Acc, S]; + +type Scan< + TEvent, + Tk extends string[], + Depth extends 1[], + Expect extends "operand" | "operator", +> = Expect extends "operand" + ? Tk extends ["(", ...infer Rest extends string[]] + ? Scan + : Tk extends [")" | "&&" | "||", ...string[]] + ? FilterError<`unexpected "${Tk[0] & string}"`> + : Tk extends [ + infer ArrP extends string, + "any" | "all", + "(", + infer SubP extends string, + infer SubOp extends string, + infer SubLit extends string, + ")", + ...infer Rest extends string[], + ] + ? CheckQuantifier extends infer R + ? R extends true + ? Scan + : R + : never + : Tk extends [ + infer P extends string, + "not", + "in", + infer Lit extends string, + ...infer Rest extends string[], + ] + ? ResolveFilterPath extends infer F + ? [F] extends [Invalid] + ? FilterError<`unknown field: ${P}`> + : CheckIn extends infer R + ? R extends true + ? Scan + : R + : never + : never + : Tk extends [ + infer P extends string, + infer Op extends string, + infer Lit extends string, + ...infer Rest extends string[], + ] + ? LeafCheck extends infer R + ? R extends true + ? Scan + : R + : never + : FilterError<`incomplete clause`> + : Tk extends [] + ? Depth extends [] + ? true + : FilterError<`unbalanced parens (missing ")")`> + : Tk extends ["&&" | "||", ...infer Rest extends string[]] + ? Scan + : Tk extends [")", ...infer Rest extends string[]] + ? Depth extends [1, ...infer D extends 1[]] + ? Scan + : FilterError<`unmatched ")"`> + : FilterError<`expected && or ||, got "${Tk[0] & string}"`>; + +type ScanFilter = Scan, [], "operand">; + +// Valid -> S; invalid -> a branded FilterError literal. Use with `const S` in conditional-param +// position so the error surfaces inline: `filter?: ValidateWebhookFilter`. +export type ValidateWebhookFilter = + ScanFilter extends true ? S : ScanFilter; diff --git a/packages/core/src/v3/schemas/webhooks.ts b/packages/core/src/v3/schemas/webhooks.ts index b5ed927602..1008e7ad70 100644 --- a/packages/core/src/v3/schemas/webhooks.ts +++ b/packages/core/src/v3/schemas/webhooks.ts @@ -289,3 +289,8 @@ export type RunFailedWebhook = Extract; export type DeploymentSuccessWebhook = Extract; export type DeploymentFailedWebhook = Extract; export type ErrorWebhook = Extract; + +// The ingress webhook verification schemas live in ./webhookConfig.ts (a leaf module), +// re-exported via schemas/index.ts. They are deliberately NOT here: this file imports +// api.js (RunStatus) for the alert union, and resources.ts (imported by api.ts) needs +// those config schemas, so keeping them here would create a module-init cycle. diff --git a/packages/core/src/v3/taskContext/otelProcessors.test.ts b/packages/core/src/v3/taskContext/otelProcessors.test.ts new file mode 100644 index 0000000000..5bd1cd4e43 --- /dev/null +++ b/packages/core/src/v3/taskContext/otelProcessors.test.ts @@ -0,0 +1,144 @@ +import { + OtelCollectorContainer, + type StartedOtelCollectorContainer, +} from "@internal/testcontainers"; +import { ExportResultCode } from "@opentelemetry/core"; +import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"; +import { + AggregationTemporality, + DataPointType, + InstrumentType, + type MetricData, + type PushMetricExporter, + type ResourceMetrics, +} from "@opentelemetry/sdk-metrics"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { BufferingMetricExporter } from "./otelProcessors.js"; + +class RecordingMetricExporter implements PushMetricExporter { + exported: ResourceMetrics[] = []; + + export(metrics: ResourceMetrics, resultCallback: (result: { code: number }) => void): void { + this.exported.push(metrics); + resultCallback({ code: ExportResultCode.SUCCESS }); + } + + async forceFlush(): Promise {} + async shutdown(): Promise {} +} + +function gauge(name: string, values: number[]): MetricData { + return { + descriptor: { + name, + description: "", + unit: "1", + type: InstrumentType.OBSERVABLE_GAUGE, + valueType: 1, + }, + aggregationTemporality: AggregationTemporality.CUMULATIVE, + dataPointType: DataPointType.GAUGE, + dataPoints: values.map((value) => ({ + attributes: { "process.cpu.state": "user" }, + startTime: [1786481102, 584000000], + endTime: [1786481102, 698000000], + value, + })), + } as MetricData; +} + +function resourceMetrics(metrics: MetricData[]): ResourceMetrics { + return { + resource: { attributes: {} }, + scopeMetrics: [{ scope: { name: "@opentelemetry/host-metrics" }, metrics }], + } as unknown as ResourceMetrics; +} + +async function exportThrough(exporter: BufferingMetricExporter, metrics: ResourceMetrics) { + exporter.export(metrics, () => {}); + await exporter.forceFlush(); +} + +function histogram(name: string, sums: number[]): MetricData { + return { + descriptor: { + name, + description: "", + unit: "ms", + type: InstrumentType.HISTOGRAM, + valueType: 1, + }, + aggregationTemporality: AggregationTemporality.DELTA, + dataPointType: DataPointType.HISTOGRAM, + dataPoints: sums.map((sum) => ({ + attributes: { "task.status": "completed" }, + startTime: [1786481102, 584000000], + endTime: [1786481102, 698000000], + value: { + min: 0, + max: 1, + sum, + count: 1, + buckets: { boundaries: [0, 1], counts: [0, 1, 0] }, + }, + })), + } as MetricData; +} + +describe("BufferingMetricExporter", () => { + it("forwards only the finite data points of a metric", async () => { + const inner = new RecordingMetricExporter(); + const exporter = new BufferingMetricExporter(inner, 30_000); + + await exportThrough(exporter, resourceMetrics([gauge("process.cpu.utilization", [NaN, 0.25])])); + + const forwarded = inner.exported[0]!.scopeMetrics[0]!.metrics[0]!; + expect(forwarded.dataPoints.map((dp) => dp.value)).toEqual([0.25]); + }); + + it("drops infinite data points as well as NaN", async () => { + const inner = new RecordingMetricExporter(); + const exporter = new BufferingMetricExporter(inner, 30_000); + + await exportThrough( + exporter, + resourceMetrics([gauge("process.cpu.utilization", [Infinity, -Infinity, 0.5])]) + ); + + const forwarded = inner.exported[0]!.scopeMetrics[0]!.metrics[0]!; + expect(forwarded.dataPoints.map((dp) => dp.value)).toEqual([0.5]); + }); + + it("forwards only the histogram data points with a finite sum", async () => { + const inner = new RecordingMetricExporter(); + const exporter = new BufferingMetricExporter(inner, 30_000); + + await exportThrough(exporter, resourceMetrics([histogram("task.duration", [NaN, 5])])); + + const forwarded = inner.exported[0]!.scopeMetrics[0]!.metrics[0]!; + expect(forwarded.dataPoints.map((dp) => (dp.value as { sum: number }).sum)).toEqual([5]); + }); + + describe("against a real otel collector", () => { + let collector: StartedOtelCollectorContainer; + + beforeAll(async () => { + collector = await new OtelCollectorContainer().start(); + }, 180_000); + + afterAll(async () => { + await collector?.stop(); + }); + + it("exports a batch containing a NaN gauge value without being rejected", async () => { + const exporter = new BufferingMetricExporter( + new OTLPMetricExporter({ url: `${collector.getOtlpHttpUrl()}/v1/metrics` }), + 30_000 + ); + + exporter.export(resourceMetrics([gauge("process.cpu.utilization", [NaN, 0.25])]), () => {}); + + await expect(exporter.forceFlush()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/v3/taskContext/otelProcessors.ts b/packages/core/src/v3/taskContext/otelProcessors.ts index 2a22a279db..2eb9c8aac1 100644 --- a/packages/core/src/v3/taskContext/otelProcessors.ts +++ b/packages/core/src/v3/taskContext/otelProcessors.ts @@ -224,6 +224,37 @@ export class TaskContextMetricExporter implements PushMetricExporter { } } +function isFiniteDataPointValue(value: unknown): boolean { + if (typeof value === "number") { + return Number.isFinite(value); + } + + if (typeof value === "object" && value !== null) { + return (["sum", "min", "max"] as const).every((key) => { + const component = (value as Record)[key]; + return typeof component !== "number" || Number.isFinite(component); + }); + } + + return true; +} + +function dropNonFiniteDataPoints(metrics: ResourceMetrics): ResourceMetrics { + return { + ...metrics, + scopeMetrics: metrics.scopeMetrics.map((scope) => ({ + ...scope, + metrics: scope.metrics.map( + (metric) => + ({ + ...metric, + dataPoints: metric.dataPoints.filter((dp) => isFiniteDataPointValue(dp.value)), + }) as MetricData + ), + })), + }; +} + export class BufferingMetricExporter implements PushMetricExporter { selectAggregationTemporality?: (instrumentType: InstrumentType) => AggregationTemporality; selectAggregation?: (instrumentType: InstrumentType) => AggregationOption; @@ -245,7 +276,7 @@ export class BufferingMetricExporter implements PushMetricExporter { } export(metrics: ResourceMetrics, resultCallback: (result: ExportResult) => void): void { - this._buffer.push(metrics); + this._buffer.push(dropNonFiniteDataPoints(metrics)); const now = Date.now(); if (now - this._lastFlushTime >= this._flushIntervalMs) { diff --git a/packages/core/src/v3/types/chatEvents.ts b/packages/core/src/v3/types/chatEvents.ts new file mode 100644 index 0000000000..1b2d28aa5c --- /dev/null +++ b/packages/core/src/v3/types/chatEvents.ts @@ -0,0 +1,112 @@ +// Type machinery for `chat.event(...)` — the descriptor an agent claims via `chat.agent({ events })`. +// Type-only. The `key` is a validated string of {body.x}/{webhook.x}/{header.x} placeholders. +import type { + WebhookSecretProvisioning, + WebhookVerifierArtifact, +} from "../schemas/webhookConfig.js"; + +export type WebhookKeyScalar = string | number | boolean | bigint; + +// Endpoint metadata resolvable at ingest without parsing the body. externalRef/tenantId are the "" +// sentinel on declared (P1) webhooks; per-tenant with P2 endpoints. +export interface WebhookKeyMeta { + externalRef: string; // the "webhook external id" (endpointExternalRef); the multi-tenant scope + tenantId: string; + id: string; // handlerWebhookId + source: string; + deliveryId: string; +} + +// A miss must be a real brand, not `never` (`never extends WebhookKeyScalar` is `true`, so bad paths +// would pass). +declare const invalidBrand: unique symbol; +type Invalid = { readonly [invalidBrand]: true }; + +type PathValue = P extends `${infer Head}.${infer Rest}` + ? Head extends keyof T + ? PathValue, Rest> + : Invalid + : P extends keyof T + ? NonNullable + : Invalid; + +export type WebhookKeyError = `✖ ${M}`; + +type TrimSpace = S extends ` ${infer R}` + ? TrimSpace + : S extends `${infer L} ` + ? TrimSpace + : S; + +// Validate one {…} placeholder by namespace: webhook.→WebhookKeyMeta, header.→any non-empty name, +// body./bare→event. `true` when a scalar path, else a branded error naming it. +type CheckKeyPathSingle = Path extends `webhook.${infer Rest}` + ? PathValue extends WebhookKeyScalar + ? true + : WebhookKeyError<`unknown or non-scalar webhook meta path: ${Rest}`> + : Path extends `header.${infer Name}` + ? Name extends "" + ? WebhookKeyError<`empty header name`> + : true + : Path extends `body.${infer Rest}` + ? PathValue extends WebhookKeyScalar + ? true + : WebhookKeyError<`unknown or non-scalar event path: ${Rest}`> + : PathValue extends WebhookKeyScalar + ? true + : WebhookKeyError<`unknown or non-scalar event path: ${Path}`>; + +// A placeholder may list first-non-empty fallbacks: `{a || b}`. Validate each side as a path. +type CheckKeyPath = Path extends `${infer L}||${infer R}` + ? CheckKeyPathSingle> extends true + ? CheckKeyPath> + : CheckKeyPathSingle> + : CheckKeyPathSingle>; + +type CheckKeyTemplate< + TEvent, + S extends string, +> = S extends `${infer _Pre}{${infer Path}}${infer Rest}` + ? CheckKeyPath extends true + ? CheckKeyTemplate + : CheckKeyPath + : true; + +// The parameter type IS the validation result: S when valid, else the branded error (which localizes +// the message to `key`). No union, no `S & ...` intersection — both broke inference/errors before. +// Paired with a `const S extends string` fn generic that captures the literal from the argument. +export type ValidatedWebhookKey = + CheckKeyTemplate extends true ? S : CheckKeyTemplate; + +declare const dEvt: unique symbol; + +export interface ChatEvent { + id: string; + type: TType; // the action.type discriminant; defaults to `id` when omitted at authoring + key: string; // compiled canonical template + source: string; // provider tag + // Registration data carried from the source so the claiming agent can create the endpoint. + verifierArtifact: WebhookVerifierArtifact; + secretProvisioning?: WebhookSecretProvisioning; + // Optional server-side gate (same DSL as webhook()); a non-matching delivery is recorded FILTERED + // and never routed to the session. + filter?: string; + readonly [dEvt]?: TEvent; +} + +export type AnyChatEvent = ChatEvent; + +// The fixed envelope a delivery becomes, projected from a descriptor; drives chat.agent.onAction. +export type ChatEventAction = + W extends ChatEvent + ? { + type: TType; + event: TEvent; + source: string; + headers: Record; + deliveryId: string; + } + : never; + +// Distributive union over an agent's `events` tuple (closed `type` discriminant, `event` narrows). +export type ChatEventActions = ChatEventAction; diff --git a/packages/core/src/v3/types/index.ts b/packages/core/src/v3/types/index.ts index 05f9473a82..d7d0f3c2a1 100644 --- a/packages/core/src/v3/types/index.ts +++ b/packages/core/src/v3/types/index.ts @@ -8,6 +8,8 @@ export * from "./queues.js"; export * from "./tasks.js"; export * from "./tools.js"; export * from "./utils.js"; +export * from "./webhooks.js"; +export * from "./chatEvents.js"; type ResolveEnvironmentVariablesOptions = { variables: Record | Array<{ name: string; value: string }>; diff --git a/packages/core/src/v3/types/webhooks.ts b/packages/core/src/v3/types/webhooks.ts new file mode 100644 index 0000000000..2485c4419b --- /dev/null +++ b/packages/core/src/v3/types/webhooks.ts @@ -0,0 +1,38 @@ +import type { + WebhookVerifierArtifact, + WebhookSecretProvisioning, +} from "../schemas/webhookConfig.js"; + +declare const __webhookEvent: unique symbol; + +export type WebhookSource = { + /** provider tag, e.g. "stripe" | "github" | "custom" */ + provider: string; + /** data-only verifier artifact (config | preset in P1) */ + verifier: WebhookVerifierArtifact; + /** who supplies the secret/key; drives the Connect UI (paste vs generate). Defaults to "either". */ + secretProvisioning?: WebhookSecretProvisioning; + /** phantom, type-level only; never present at runtime */ + [__webhookEvent]?: TEvent; +}; + +export type AnyWebhookSource = WebhookSource; + +export type InferWebhookEvent = S extends WebhookSource ? TEvent : unknown; + +// The envelope the platform delivers to a webhook task run: the verified event body plus the +// inbound request headers. The SDK's webhook() run unwraps this into onEvent({ event, headers }). +// Kept here so the trigger side (webapp) and the receive side (SDK) agree on the shape. +export type WebhookRunPayload = { + event: TEvent; + headers: Record; +}; + +// ── P2 seam: TYPE ONLY, no runtime function ── +export type CreateWebhookEndpointParams = { + /** declared webhook() id (string ref; GOLDEN LAW, never the value) */ + handler: string; + tenantId?: string; + externalRef?: string; + metadata?: Record; +}; diff --git a/packages/core/src/v3/webhooks/index.ts b/packages/core/src/v3/webhooks/index.ts new file mode 100644 index 0000000000..202852ca2b --- /dev/null +++ b/packages/core/src/v3/webhooks/index.ts @@ -0,0 +1,295 @@ +import type { + WebhookAsymmetricConfig, + WebhookHmacConfig, + WebhookSecretProvisioning, + WebhookVerifierConfig, +} from "../schemas/webhookConfig.js"; + +/** + * Preset verifier-config builders: the single source of truth for how each supported provider signs. + * The public SDK `webhooks.stripe()` producers wrap these (attaching provider + secretProvisioning + + * the phantom event type), and `@internal/webhook-sources` uses them to sign-and-verify its samples. + * These carry zero SDK dependency (pure `WebhookVerifierConfig` data), so they live in core. + */ + +export function stripeVerifierConfig(opts?: { toleranceSeconds?: number }): WebhookHmacConfig { + return { + scheme: "hmac", + algorithm: "sha256", + encoding: "hex", + signatureHeader: "stripe-signature", + signature: { itemSeparator: ",", fieldSeparator: "=", field: "v1" }, + timestamp: { + source: { from: "signatureField", field: "t" }, + toleranceSeconds: opts?.toleranceSeconds ?? 300, + }, + signingString: { template: "{timestamp}.{body}" }, + idempotencyField: { from: "body", name: "id" }, + }; +} + +export function githubVerifierConfig(): WebhookHmacConfig { + return { + scheme: "hmac", + algorithm: "sha256", + encoding: "hex", + signatureHeader: "x-hub-signature-256", + signature: { fieldSeparator: "=", field: "sha256" }, + signingString: "raw", + idempotencyField: { from: "header", name: "x-github-delivery" }, + }; +} + +export function svixVerifierConfig(): WebhookHmacConfig { + return { + scheme: "hmac", + algorithm: "sha256", + encoding: "base64", + signatureHeader: "svix-signature", + signature: { itemSeparator: " ", fieldSeparator: ",", field: "v1" }, + timestamp: { source: { from: "header", name: "svix-timestamp" }, toleranceSeconds: 300 }, + signingString: { + template: "{id}.{timestamp}.{body}", + vars: { id: { from: "header", name: "svix-id" } }, + }, + secret: { encoding: "base64", stripPrefix: "whsec_" }, + idempotencyField: { from: "header", name: "svix-id" }, + }; +} + +export function squareVerifierConfig(): WebhookHmacConfig { + return { + scheme: "hmac", + algorithm: "sha256", + encoding: "base64", + signatureHeader: "x-square-hmacsha256-signature", + signature: {}, + signingString: { template: "{url}{body}", vars: { url: { from: "url" } } }, + idempotencyField: { from: "body", name: "event_id" }, + }; +} + +export function discordVerifierConfig(opts?: { + publicKeyEncoding?: "raw-hex" | "pem"; +}): WebhookAsymmetricConfig { + return { + scheme: "asymmetric", + algorithm: "ed25519", + encoding: "hex", + signatureHeader: "x-signature-ed25519", + signature: {}, + timestamp: { source: { from: "header", name: "x-signature-timestamp" } }, + signingString: { template: "{timestamp}{body}" }, + publicKeyEncoding: opts?.publicKeyEncoding ?? "raw-hex", + }; +} + +export const WEBHOOK_PRESET_IDS = [ + "custom", + "stripe", + "github", + "svix", + "square", + "discord", +] as const; +export type WebhookPresetId = (typeof WEBHOOK_PRESET_IDS)[number]; + +/** + * Generic HMAC verifier-config builder. The per-provider configs below are concise declarative calls; + * each names the signature header, how the signature (and optional timestamp) is parsed out of it, and + * the bytes that get signed. The engine signer/verifier are the single implementation. + */ +function hmacVerifierConfig(opts: { + header: string; + algorithm?: "sha256" | "sha1"; + encoding?: "hex" | "base64"; + field?: string; + fieldSeparator?: string; + itemSeparator?: string; + trim?: boolean; + signingTemplate?: string; + timestamp?: + | { from: "header"; name: string; unit?: "seconds" | "milliseconds" } + | { from: "signatureField"; field: string; unit?: "seconds" | "milliseconds" }; + toleranceSeconds?: number; +}): WebhookHmacConfig { + const signature: WebhookHmacConfig["signature"] = {}; + if (opts.itemSeparator) signature.itemSeparator = opts.itemSeparator; + if (opts.field) { + signature.fieldSeparator = opts.fieldSeparator ?? "="; + signature.field = opts.field; + } + if (opts.trim) signature.trim = true; + + const config: WebhookHmacConfig = { + scheme: "hmac", + algorithm: opts.algorithm ?? "sha256", + encoding: opts.encoding ?? "hex", + signatureHeader: opts.header, + signature, + signingString: opts.signingTemplate ? { template: opts.signingTemplate } : "raw", + }; + + if (opts.timestamp) { + config.timestamp = { + source: + opts.timestamp.from === "header" + ? { from: "header", name: opts.timestamp.name } + : { from: "signatureField", field: opts.timestamp.field }, + ...(opts.timestamp.unit ? { unit: opts.timestamp.unit } : {}), + ...(opts.toleranceSeconds ? { toleranceSeconds: opts.toleranceSeconds } : {}), + }; + } + + return config; +} + +/** + * Per-provider verifier configs for providers whose signature is a plain HMAC variant that maps onto + * the config primitives (a different header name / prefix / signing template than the shared presets, + * but no new verifier capability). Each is round-trip tested in @internal/webhook-sources and wrapped + * by a `webhooks.X()` SDK producer. Providers needing asymmetric verification, hex-decoded keys, or + * URL+param signing are NOT here (they ship sample-only until the verifier gains those). + */ +export const webhookProviderConfigs = { + slack: { + secretProvisioning: "provider", + config: () => + hmacVerifierConfig({ + header: "x-slack-signature", + field: "v0", + signingTemplate: "v0:{timestamp}:{body}", + timestamp: { from: "header", name: "x-slack-request-timestamp" }, + toleranceSeconds: 300, + }), + }, + zoom: { + secretProvisioning: "provider", + config: () => + hmacVerifierConfig({ + header: "x-zm-signature", + field: "v0", + signingTemplate: "v0:{timestamp}:{body}", + timestamp: { from: "header", name: "x-zm-request-timestamp" }, + toleranceSeconds: 300, + }), + }, + calendly: { + secretProvisioning: "provider", + config: () => + hmacVerifierConfig({ + header: "calendly-webhook-signature", + itemSeparator: ",", + field: "v1", + signingTemplate: "{timestamp}.{body}", + timestamp: { from: "signatureField", field: "t" }, + toleranceSeconds: 300, + }), + }, + "cal-com": { + secretProvisioning: "integrator", + config: () => hmacVerifierConfig({ header: "x-cal-signature-256" }), + }, + sentry: { + secretProvisioning: "integrator", + config: () => hmacVerifierConfig({ header: "sentry-hook-signature" }), + }, + pagerduty: { + secretProvisioning: "integrator", + config: () => hmacVerifierConfig({ header: "x-pagerduty-signature", field: "v1" }), + }, + vercel: { + secretProvisioning: "integrator", + config: () => hmacVerifierConfig({ header: "x-vercel-signature", algorithm: "sha1" }), + }, + linear: { + secretProvisioning: "integrator", + config: () => hmacVerifierConfig({ header: "linear-signature" }), + }, + notion: { + secretProvisioning: "provider", + config: () => hmacVerifierConfig({ header: "x-notion-signature", field: "sha256" }), + }, + typeform: { + secretProvisioning: "integrator", + config: () => + hmacVerifierConfig({ header: "typeform-signature", field: "sha256", encoding: "base64" }), + }, + docusign: { + secretProvisioning: "integrator", + config: () => hmacVerifierConfig({ header: "x-docusign-signature-1", encoding: "base64" }), + }, + jira: { + secretProvisioning: "integrator", + config: () => hmacVerifierConfig({ header: "x-hub-signature", field: "sha256" }), + }, + intercom: { + secretProvisioning: "integrator", + config: () => + hmacVerifierConfig({ header: "x-hub-signature", field: "sha1", algorithm: "sha1" }), + }, + zendesk: { + secretProvisioning: "integrator", + config: () => + hmacVerifierConfig({ + header: "x-zendesk-webhook-signature", + encoding: "base64", + signingTemplate: "{timestamp}{body}", + timestamp: { from: "header", name: "x-zendesk-webhook-signature-timestamp" }, + }), + }, + attio: { + secretProvisioning: "integrator", + config: () => hmacVerifierConfig({ header: "attio-signature" }), + }, + workos: { + secretProvisioning: "provider", + config: () => + hmacVerifierConfig({ + header: "workos-signature", + itemSeparator: ",", + field: "v1", + trim: true, + signingTemplate: "{timestamp}.{body}", + timestamp: { from: "signatureField", field: "t", unit: "milliseconds" }, + }), + }, + elevenlabs: { + secretProvisioning: "provider", + config: () => + hmacVerifierConfig({ + header: "elevenlabs-signature", + itemSeparator: ",", + field: "v0", + signingTemplate: "{timestamp}.{body}", + timestamp: { from: "signatureField", field: "t" }, + toleranceSeconds: 1800, + }), + }, + vapi: { + secretProvisioning: "integrator", + config: () => hmacVerifierConfig({ header: "x-vapi-signature" }), + }, + retell: { + secretProvisioning: "provider", + config: () => + hmacVerifierConfig({ + header: "x-retell-signature", + itemSeparator: ",", + field: "d", + signingTemplate: "{body}{timestamp}", + timestamp: { from: "signatureField", field: "v", unit: "milliseconds" }, + }), + }, + shopify: { + secretProvisioning: "integrator", + config: () => hmacVerifierConfig({ header: "x-shopify-hmac-sha256", encoding: "base64" }), + }, +} satisfies Record< + string, + { secretProvisioning: WebhookSecretProvisioning; config: () => WebhookVerifierConfig } +>; + +export type WebhookProviderId = keyof typeof webhookProviderConfigs; + +export * from "../schemas/webhookConfig.js"; diff --git a/packages/redis-worker/package.json b/packages/redis-worker/package.json index 4e18a7a3f2..d185e72281 100644 --- a/packages/redis-worker/package.json +++ b/packages/redis-worker/package.json @@ -25,7 +25,7 @@ "dependencies": { "@trigger.dev/core": "workspace:4.5.11", "lodash.omit": "^4.5.0", - "nanoid": "^5.0.7", + "nanoid": "^5.1.16", "p-limit": "^6.2.0", "seedrandom": "^3.0.5", "zod": "3.25.76", diff --git a/packages/redis-worker/src/fair-queue/concurrency.ts b/packages/redis-worker/src/fair-queue/concurrency.ts index 641899ccc8..73eb5b487b 100644 --- a/packages/redis-worker/src/fair-queue/concurrency.ts +++ b/packages/redis-worker/src/fair-queue/concurrency.ts @@ -7,10 +7,22 @@ import type { QueueDescriptor, } from "./types.js"; +/** + * Page size for iterating a concurrency set's members (SSCAN COUNT) and cap on how many + * members one sweep script invocation checks, bounding both the snapshot reads and the + * time the atomic Lua script can hold up Redis when a set has accumulated many leaked + * members. + */ +const SWEEP_MEMBER_CHUNK_SIZE = 500; + export interface ConcurrencyManagerOptions { redis: RedisOptions; keys: FairQueueKeyProducer; groups: ConcurrencyGroupConfig[]; + logger?: { + debug: (message: string, context?: Record) => void; + error: (message: string, context?: Record) => void; + }; } /** @@ -26,12 +38,17 @@ export class ConcurrencyManager { private keys: FairQueueKeyProducer; private groups: ConcurrencyGroupConfig[]; private groupsByName: Map; + private logger: NonNullable; constructor(private options: ConcurrencyManagerOptions) { this.redis = createRedisClient(options.redis); this.keys = options.keys; this.groups = options.groups; this.groupsByName = new Map(options.groups.map((g) => [g.name, g])); + this.logger = options.logger ?? { + debug: () => {}, + error: () => {}, + }; this.#registerCommands(); } @@ -103,7 +120,37 @@ export class ConcurrencyManager { pipeline.srem(key, messageId); } - await pipeline.exec(); + this.#assertPipelineSucceeded(await pipeline.exec(), 1); + } + + /** + * Throw if any command in a released pipeline failed. ioredis resolves `exec()` even when + * individual commands error, so an unchecked pipeline reports success while leaving the + * slot held, which strands it permanently once the caller drops the in-flight record. + */ + #assertPipelineSucceeded( + results: Array<[Error | null, unknown]> | null, + messageCount: number + ): void { + if (results === null) { + throw new Error( + `Concurrency release pipeline for ${messageCount} message(s) was discarded without executing` + ); + } + + const errors = results + .map(([error]) => error) + .filter((error): error is Error => Boolean(error)); + + if (errors.length > 0) { + throw new Error( + `Failed to release ${errors.length} of ${ + results?.length ?? 0 + } concurrency slot commands across ${messageCount} message(s): ${errors + .map((error) => error.message) + .join("; ")}` + ); + } } /** @@ -127,7 +174,98 @@ export class ConcurrencyManager { } } - await pipeline.exec(); + this.#assertPipelineSucceeded(await pipeline.exec(), messages.length); + } + + /** + * Remove concurrency set members that no longer correspond to an in-flight message, + * healing slots leaked by failed releases. Scans every set of every group and, per + * member, atomically removes it unless the message id appears in one of the given + * in-flight data hashes. Sound because a message is registered in-flight before its + * slot is reserved, so at the moment of the atomic check a member with no in-flight + * record can only be a leak; if the message is about to be re-claimed, reserve simply + * re-adds the member. + * + * @param inflightDataKeys - The in-flight data hash keys for every shard. The sweep + * refuses to run when this is empty, since with nowhere to look for running messages + * every member would look orphaned and all concurrency accounting would be erased. + * @returns The message ids that were removed, and how many sets were checked + */ + async sweepOrphanedSlots( + inflightDataKeys: string[] + ): Promise<{ scannedSets: number; removed: string[] }> { + if (inflightDataKeys.length === 0) { + this.logger.error( + "Refusing to sweep concurrency slots without any in-flight data keys: every member would look orphaned and all concurrency accounting would be erased" + ); + return { scannedSets: 0, removed: [] }; + } + + const keyPrefix = this.options.redis.keyPrefix ?? ""; + let scannedSets = 0; + const removed: string[] = []; + + for (const group of this.groups) { + const pattern = `${keyPrefix}${this.keys.concurrencyKey(group.name, "*")}`; + let cursor = "0"; + + do { + const [nextCursor, foundKeys] = await this.redis.scan( + cursor, + "MATCH", + pattern, + "COUNT", + 1000 + ); + cursor = nextCursor; + + for (const fullKey of foundKeys) { + const key = + keyPrefix && fullKey.startsWith(keyPrefix) ? fullKey.slice(keyPrefix.length) : fullKey; + + try { + let sawMembers = false; + let memberCursor = "0"; + + do { + const [nextMemberCursor, page] = await this.redis.sscan( + key, + memberCursor, + "COUNT", + SWEEP_MEMBER_CHUNK_SIZE + ); + memberCursor = nextMemberCursor; + + if (page.length === 0) { + continue; + } + sawMembers = true; + + for (let i = 0; i < page.length; i += SWEEP_MEMBER_CHUNK_SIZE) { + const chunk = page.slice(i, i + SWEEP_MEMBER_CHUNK_SIZE); + const removedIds = await this.redis.removeOrphanedConcurrencySlots( + 1 + inflightDataKeys.length, + [key, ...inflightDataKeys], + ...chunk + ); + removed.push(...removedIds); + } + } while (memberCursor !== "0"); + + if (sawMembers) { + scannedSets++; + } + } catch (error) { + this.logger.error("Failed to sweep concurrency set, skipping it", { + key, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } while (cursor !== "0"); + } + + return { scannedSets, removed }; } /** @@ -268,14 +406,20 @@ export class ConcurrencyManager { local numGroups = #KEYS local messageId = ARGV[1] --- Check all groups first +-- Check all groups first. A message that is already a member of a group's set passes +-- that group's check: re-admitting it does not increase concurrency (SADD is a no-op), +-- and counting its own leftover slot against it would let a message whose earlier +-- release failed block its own retry forever. for i = 1, numGroups do local key = KEYS[i] local limit = tonumber(ARGV[1 + i]) -- Limits start at ARGV[2] - local current = redis.call('SCARD', key) - - if current >= limit then - return 0 -- At capacity + + if redis.call('SISMEMBER', key, messageId) == 0 then + local current = redis.call('SCARD', key) + + if current >= limit then + return 0 -- At capacity + end end end @@ -288,6 +432,37 @@ end return 1 `, }); + + // Atomic orphan sweep for one concurrency set + // KEYS[1]: concurrency set key + // KEYS[2..n]: in-flight data hash keys for every shard + // ARGV: candidate messageIds (a snapshot of the set's members) + this.redis.defineCommand("removeOrphanedConcurrencySlots", { + lua: ` +local concurrencyKey = KEYS[1] +local removedIds = {} + +for i = 1, #ARGV do + local messageId = ARGV[i] + local inflight = false + + for j = 2, #KEYS do + if redis.call('HEXISTS', KEYS[j], messageId) == 1 then + inflight = true + break + end + end + + if not inflight then + if redis.call('SREM', concurrencyKey, messageId) == 1 then + table.insert(removedIds, messageId) + end + end +end + +return removedIds + `, + }); } } @@ -300,5 +475,11 @@ declare module "@internal/redis" { messageId: string, ...limits: string[] ): Promise; + + removeOrphanedConcurrencySlots( + numKeys: number, + keys: string[], + ...messageIds: string[] + ): Promise; } } diff --git a/packages/redis-worker/src/fair-queue/index.ts b/packages/redis-worker/src/fair-queue/index.ts index e24de876d8..826ac73edc 100644 --- a/packages/redis-worker/src/fair-queue/index.ts +++ b/packages/redis-worker/src/fair-queue/index.ts @@ -2,7 +2,7 @@ import { createRedisClient, type Redis } from "@internal/redis"; import { SpanKind, type Span } from "@internal/tracing"; import { Logger } from "@trigger.dev/core/logger"; import { nanoid } from "nanoid"; -import { setInterval } from "node:timers/promises"; +import { setInterval, setTimeout as delay } from "node:timers/promises"; import { type z } from "zod"; import { isAbortError } from "../utils.js"; import { ConcurrencyManager } from "./concurrency.js"; @@ -25,6 +25,7 @@ import type { FairScheduler, QueueCooloffState, QueueDescriptor, + ReclaimedMessageInfo, SchedulerContext, StoredMessage, TenantQueues, @@ -85,6 +86,7 @@ export class FairQueue { private visibilityTimeoutMs: number; private heartbeatIntervalMs: number; private reclaimIntervalMs: number; + private reconcileIntervalMs: number; private workerQueueResolver: (message: StoredMessage>) => string; private batchClaimSize: number; @@ -109,6 +111,7 @@ export class FairQueue { private abortController: AbortController; private masterQueueConsumerLoops: Promise[] = []; private reclaimLoop?: Promise; + private reconcileLoop?: Promise; // Queue descriptor cache for message processing private queueDescriptorCache = new Map(); @@ -138,6 +141,7 @@ export class FairQueue { this.visibilityTimeoutMs = options.visibilityTimeoutMs ?? 30_000; this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? this.visibilityTimeoutMs / 3; this.reclaimIntervalMs = options.reclaimIntervalMs ?? 5_000; + this.reconcileIntervalMs = options.reconcileIntervalMs ?? 60_000; // Worker queue resolver (required) this.workerQueueResolver = options.workerQueue.resolveWorkerQueue; @@ -196,6 +200,10 @@ export class FairQueue { redis: options.redis, keys: options.keys, groups: options.concurrencyGroups, + logger: { + debug: (msg, ctx) => this.logger.debug(msg, ctx), + error: (msg, ctx) => this.logger.error(msg, ctx), + }, }); } @@ -657,6 +665,10 @@ export class FairQueue { // Start reclaim loop for handling timed-out messages this.reclaimLoop = this.#runReclaimLoop(); + if (this.concurrencyManager && this.reconcileIntervalMs > 0) { + this.reconcileLoop = this.#runReconcileLoop(); + } + this.logger.info("FairQueue started", { consumerCount: this.consumerCount, shardCount: this.shardCount, @@ -675,10 +687,15 @@ export class FairQueue { this.isRunning = false; this.abortController.abort(); - await Promise.allSettled([...this.masterQueueConsumerLoops, this.reclaimLoop]); + await Promise.allSettled([ + ...this.masterQueueConsumerLoops, + this.reclaimLoop, + this.reconcileLoop, + ]); this.masterQueueConsumerLoops = []; this.reclaimLoop = undefined; + this.reconcileLoop = undefined; this.logger.info("FairQueue stopped"); } @@ -1245,22 +1262,17 @@ export class FairQueue { } } - const descriptor: QueueDescriptor = storedMessage - ? (this.queueDescriptorCache.get(queueId) ?? { - id: queueId, - tenantId: storedMessage.tenantId, - metadata: storedMessage.metadata ?? {}, - }) - : { id: queueId, tenantId: this.keys.extractTenantId(queueId), metadata: {} }; + const descriptor: QueueDescriptor = this.queueDescriptorCache.get(queueId) ?? { + id: queueId, + tenantId: storedMessage?.tenantId ?? this.keys.extractTenantId(queueId), + metadata: storedMessage?.metadata ?? {}, + }; + + await this.#releaseConcurrencySafely(descriptor, messageId, "completeMessage"); // Complete in visibility manager await this.visibilityManager.complete(messageId, queueId); - // Release concurrency - if (this.concurrencyManager && storedMessage) { - await this.concurrencyManager.release(descriptor, messageId); - } - // Update both old and new indexes, clean up caches if queue is empty const { queueEmpty } = await this.#updateAllIndexesAfterDequeue(queueId, descriptor.tenantId); if (queueEmpty) { @@ -1300,13 +1312,14 @@ export class FairQueue { } } - const descriptor: QueueDescriptor = storedMessage - ? (this.queueDescriptorCache.get(queueId) ?? { - id: queueId, - tenantId: storedMessage.tenantId, - metadata: storedMessage.metadata ?? {}, - }) - : { id: queueId, tenantId: this.keys.extractTenantId(queueId), metadata: {} }; + const descriptor: QueueDescriptor = this.queueDescriptorCache.get(queueId) ?? { + id: queueId, + tenantId: storedMessage?.tenantId ?? this.keys.extractTenantId(queueId), + metadata: storedMessage?.metadata ?? {}, + }; + + // Release concurrency + await this.#releaseConcurrencySafely(descriptor, messageId, "releaseMessage"); // Release back to queue (visibility manager updates dispatch indexes atomically) // Dispatch shard is tenant-based, not queue-based @@ -1324,17 +1337,62 @@ export class FairQueue { Date.now() // Put at back of queue ); - // Release concurrency - if (this.concurrencyManager && storedMessage) { - await this.concurrencyManager.release(descriptor, messageId); - } - this.logger.debug("Message released", { messageId, queueId, }); } + /** + * Release a message's concurrency slots without letting a failure escape. Release is + * best-effort cleanup: the primary state transition (complete, retry, DLQ, requeue) + * must proceed even when the slot cannot be freed, because aborting the transition + * re-delivers or strands the message, while a leaked slot is recoverable — reserve() + * re-admits a message that already holds its own slot, and the reconcile loop removes + * members that are no longer in flight. + */ + async #releaseConcurrencySafely( + descriptor: QueueDescriptor, + messageId: string, + context: string + ): Promise { + if (!this.concurrencyManager) { + return; + } + + try { + await this.concurrencyManager.release(descriptor, messageId); + } catch (error) { + this.logger.error("Failed to release concurrency slot, leaving it for the reconciler", { + context, + messageId, + queueId: descriptor.id, + tenantId: descriptor.tenantId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Release a concurrency slot for a message we can no longer describe, deriving the + * group from the queue id alone. Used on paths that bail out before the stored + * message is available, where the slot would otherwise be held with nothing left + * to reclaim it. + */ + async #releaseOrphanedConcurrency(messageId: string, queueId: string): Promise { + if (!this.concurrencyManager) { + return; + } + + const descriptor: QueueDescriptor = this.queueDescriptorCache.get(queueId) ?? { + id: queueId, + tenantId: this.keys.extractTenantId(queueId), + metadata: {}, + }; + + await this.#releaseConcurrencySafely(descriptor, messageId, "failMessage:missing-record"); + } + /** * Mark a message as failed. This will trigger retry logic if configured, * or move the message to the dead letter queue. @@ -1353,6 +1411,7 @@ export class FairQueue { const dataJson = await this.redis.hget(inflightDataKey, messageId); if (!dataJson) { this.logger.error("Cannot fail message: not found in in-flight data", { messageId, queueId }); + await this.#releaseOrphanedConcurrency(messageId, queueId); return; } @@ -1364,6 +1423,7 @@ export class FairQueue { messageId, queueId, }); + await this.#releaseOrphanedConcurrency(messageId, queueId); return; } @@ -1411,6 +1471,8 @@ export class FairQueue { attempt: storedMessage.attempt + 1, }; + await this.#releaseConcurrencySafely(descriptor, storedMessage.id, "retry"); + // Release with delay, passing the updated message data so the Lua script // atomically writes the incremented attempt count when re-queuing. const tenantQueueIndexKey = this.keys.tenantQueueIndexKey(descriptor.tenantId); @@ -1427,11 +1489,6 @@ export class FairQueue { JSON.stringify(updatedMessage) ); - // Release concurrency - if (this.concurrencyManager) { - await this.concurrencyManager.release(descriptor, storedMessage.id); - } - this.telemetry.recordRetry(); this.logger.debug("Message scheduled for retry", { @@ -1445,13 +1502,11 @@ export class FairQueue { } } + // Release concurrency + await this.#releaseConcurrencySafely(descriptor, storedMessage.id, "deadLetter"); + // Move to DLQ await this.#moveToDeadLetterQueue(storedMessage, error?.message); - - // Release concurrency - if (this.concurrencyManager) { - await this.concurrencyManager.release(descriptor, storedMessage.id); - } } async #moveToDeadLetterQueue( @@ -1525,55 +1580,129 @@ export class FairQueue { } } + /** + * Free every timed-out message's concurrency slot in one pipeline before the batch is + * requeued. A failed release never blocks the requeue: holding the message in-flight + * would stall it behind a persistent release failure, while the leaked slot self-heals + * when the message is re-admitted (reserve is idempotent for a message that already + * holds its own slot) or via the reconcile loop. + */ + async #releaseReclaimedConcurrency(messages: ReclaimedMessageInfo[]): Promise { + if (!this.concurrencyManager || messages.length === 0) { + return; + } + + const descriptorFor = (message: ReclaimedMessageInfo): QueueDescriptor => ({ + id: message.queueId, + tenantId: message.tenantId, + metadata: message.metadata ?? {}, + }); + + try { + await this.concurrencyManager.releaseBatch( + messages.map((message) => ({ queue: descriptorFor(message), messageId: message.messageId })) + ); + return; + } catch (error) { + this.logger.error("Batch concurrency release failed, retrying message by message", { + count: messages.length, + error: error instanceof Error ? error.message : String(error), + }); + } + + for (const message of messages) { + await this.#releaseConcurrencySafely(descriptorFor(message), message.messageId, "reclaim"); + } + } + async #reclaimTimedOutMessages(): Promise { let totalReclaimed = 0; for (let shardId = 0; shardId < this.shardCount; shardId++) { - const reclaimedMessages = await this.visibilityManager.reclaimTimedOut(shardId, (queueId) => { - const tenantId = this.keys.extractTenantId(queueId); - const dispatchShardId = this.tenantDispatch.getShardForTenant(tenantId); - return { - queueKey: this.keys.queueKey(queueId), - queueItemsKey: this.keys.queueItemsKey(queueId), - tenantQueueIndexKey: this.keys.tenantQueueIndexKey(tenantId), - dispatchKey: this.keys.dispatchKey(dispatchShardId), - tenantId, - }; + try { + const reclaimedMessages = await this.visibilityManager.reclaimTimedOut( + shardId, + (queueId) => { + const tenantId = this.keys.extractTenantId(queueId); + const dispatchShardId = this.tenantDispatch.getShardForTenant(tenantId); + return { + queueKey: this.keys.queueKey(queueId), + queueItemsKey: this.keys.queueItemsKey(queueId), + tenantQueueIndexKey: this.keys.tenantQueueIndexKey(tenantId), + dispatchKey: this.keys.dispatchKey(dispatchShardId), + tenantId, + }; + }, + this.#releaseReclaimedConcurrency.bind(this) + ); + + totalReclaimed += reclaimedMessages.length; + } catch (error) { + this.logger.error("Failed to reclaim shard, leaving messages in-flight for the next tick", { + shardId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + if (totalReclaimed > 0) { + this.logger.info("Reclaimed timed-out messages", { count: totalReclaimed }); + } + } + + /** + * The initial delay is jittered so instances sharing a Redis do not all sweep at once. + */ + async #runReconcileLoop(): Promise { + try { + await delay(Math.random() * this.reconcileIntervalMs, null, { + signal: this.abortController.signal, }); - if (reclaimedMessages.length > 0) { - // Release concurrency for all reclaimed messages in a single batch - // This is critical: when a message times out, its concurrency slot must be freed - // so the message can be processed again when it's re-claimed from the queue - if (this.concurrencyManager) { - try { - await this.concurrencyManager.releaseBatch( - reclaimedMessages.map((msg) => ({ - queue: { - id: msg.queueId, - tenantId: msg.tenantId, - metadata: msg.metadata ?? {}, - }, - messageId: msg.messageId, - })) - ); - } catch (error) { - this.logger.error("Failed to release concurrency for reclaimed messages", { - count: reclaimedMessages.length, - error: error instanceof Error ? error.message : String(error), - }); - } + for await (const _ of setInterval(this.reconcileIntervalMs, null, { + signal: this.abortController.signal, + })) { + try { + await this.#reconcileConcurrency(); + } catch (error) { + this.logger.error("Concurrency reconcile loop error", { + error: error instanceof Error ? error.message : String(error), + }); } - - // Dispatch indexes are updated atomically by the releaseMessage Lua script - // inside reclaimTimedOut, so no separate index update needed here. } + } catch (error) { + if (isAbortError(error)) { + this.logger.debug("Concurrency reconcile loop aborted"); + return; + } + throw error; + } + } - totalReclaimed += reclaimedMessages.length; + /** + * Heal orphaned concurrency slots: remove any concurrency set member whose message is + * not registered in-flight on any shard. This is the recovery path for slots leaked by + * failed releases, which otherwise count against a tenant's limit forever and can + * wedge every queue the tenant owns. + */ + async #reconcileConcurrency(): Promise { + if (!this.concurrencyManager) { + return; } - if (totalReclaimed > 0) { - this.logger.info("Reclaimed timed-out messages", { count: totalReclaimed }); + const inflightDataKeys = Array.from({ length: this.shardCount }, (_, shardId) => + this.keys.inflightDataKey(shardId) + ); + + const { scannedSets, removed } = + await this.concurrencyManager.sweepOrphanedSlots(inflightDataKeys); + + if (removed.length > 0) { + this.logger.info("Reconciled orphaned concurrency slots", { + count: removed.length, + scannedSets, + messageIds: removed.slice(0, 50), + }); } } diff --git a/packages/redis-worker/src/fair-queue/tests/concurrency.test.ts b/packages/redis-worker/src/fair-queue/tests/concurrency.test.ts index 7137ea8ac8..4c4d13dbcb 100644 --- a/packages/redis-worker/src/fair-queue/tests/concurrency.test.ts +++ b/packages/redis-worker/src/fair-queue/tests/concurrency.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "vitest"; import { redisTest } from "@internal/testcontainers"; +import { createRedisClient } from "@internal/redis"; import { ConcurrencyManager } from "../concurrency.js"; import { DefaultFairQueueKeyProducer } from "../keyProducer.js"; import type { FairQueueKeyProducer, QueueDescriptor } from "../types.js"; @@ -736,4 +737,125 @@ describe("ConcurrencyManager", () => { } ); }); + + describe("reserve idempotency", () => { + redisTest( + "should re-admit a message that already holds its own slot at capacity", + { timeout: 10000 }, + async ({ redisOptions }) => { + keys = new DefaultFairQueueKeyProducer({ prefix: "test" }); + + const manager = new ConcurrencyManager({ + redis: redisOptions, + keys, + groups: [ + { + name: "tenant", + extractGroupId: (q) => q.tenantId, + getLimit: async () => 1, + defaultLimit: 1, + }, + ], + }); + + const redis = createRedisClient(redisOptions); + const queue: QueueDescriptor = { id: "queue-1", tenantId: "t1", metadata: {} }; + const concurrencyKey = keys.concurrencyKey("tenant", "t1"); + + try { + await redis.sadd(concurrencyKey, "m1"); + + expect(await manager.reserve(queue, "m1")).toBe(true); + expect(await redis.scard(concurrencyKey)).toBe(1); + + expect(await manager.reserve(queue, "m2")).toBe(false); + } finally { + await redis.del(concurrencyKey); + await redis.quit(); + await manager.close(); + } + } + ); + }); + + describe("orphaned slot sweep", () => { + redisTest( + "should remove only members without an in-flight record", + { timeout: 10000 }, + async ({ redisOptions }) => { + keys = new DefaultFairQueueKeyProducer({ prefix: "test" }); + + const manager = new ConcurrencyManager({ + redis: redisOptions, + keys, + groups: [ + { + name: "tenant", + extractGroupId: (q) => q.tenantId, + getLimit: async () => 5, + defaultLimit: 5, + }, + ], + }); + + const redis = createRedisClient(redisOptions); + const concurrencyKey = keys.concurrencyKey("tenant", "t1"); + const inflightDataKey = keys.inflightDataKey(0); + + try { + const orphans = Array.from({ length: 1200 }, (_, i) => `orphan-${i}`); + await redis.sadd(concurrencyKey, ...orphans, "active-1", "active-2"); + await redis.hset(inflightDataKey, "active-1", "{}"); + await redis.hset(inflightDataKey, "active-2", "{}"); + + const result = await manager.sweepOrphanedSlots([inflightDataKey]); + + expect(result.removed.sort()).toEqual(orphans.sort()); + expect((await redis.smembers(concurrencyKey)).sort()).toEqual(["active-1", "active-2"]); + } finally { + await redis.del(concurrencyKey); + await redis.del(inflightDataKey); + await redis.quit(); + await manager.close(); + } + } + ); + + redisTest( + "should refuse to sweep when given no in-flight data keys", + { timeout: 10000 }, + async ({ redisOptions }) => { + keys = new DefaultFairQueueKeyProducer({ prefix: "test" }); + + const manager = new ConcurrencyManager({ + redis: redisOptions, + keys, + groups: [ + { + name: "tenant", + extractGroupId: (q) => q.tenantId, + getLimit: async () => 5, + defaultLimit: 5, + }, + ], + }); + + const redis = createRedisClient(redisOptions); + const concurrencyKey = keys.concurrencyKey("tenant", "t1"); + + try { + await redis.sadd(concurrencyKey, "active-1", "active-2"); + + const result = await manager.sweepOrphanedSlots([]); + + expect(result.removed).toEqual([]); + expect((await redis.smembers(concurrencyKey)).sort()).toEqual(["active-1", "active-2"]); + } finally { + await redis.del(concurrencyKey); + await redis.quit(); + await manager.close(); + } + } + ); + }); }); diff --git a/packages/redis-worker/src/fair-queue/tests/fairQueue.test.ts b/packages/redis-worker/src/fair-queue/tests/fairQueue.test.ts index 5ae1f390f9..edf5b447c7 100644 --- a/packages/redis-worker/src/fair-queue/tests/fairQueue.test.ts +++ b/packages/redis-worker/src/fair-queue/tests/fairQueue.test.ts @@ -10,7 +10,7 @@ import { WorkerQueueManager, } from "../index.js"; import type { FairQueueKeyProducer, FairQueueOptions } from "../types.js"; -import type { RedisOptions } from "@internal/redis"; +import { createRedisClient, type RedisOptions } from "@internal/redis"; // Define a common payload schema for tests const TestPayloadSchema = z.object({ value: z.string() }); @@ -1370,4 +1370,825 @@ describe("FairQueue", () => { } ); }); + + describe("concurrency slot release", () => { + redisTest( + "should release the concurrency slot when the in-flight record is already gone", + { timeout: 15000 }, + async ({ redisOptions }) => { + const processed: string[] = []; + keys = new DefaultFairQueueKeyProducer({ prefix: "test" }); + + const scheduler = new DRRScheduler({ + redis: redisOptions, + keys, + quantum: 10, + maxDeficit: 100, + }); + + const queue = new TestFairQueueHelper(redisOptions, keys, { + scheduler, + payloadSchema: TestPayloadSchema, + shardCount: 1, + consumerCount: 1, + consumerIntervalMs: 20, + visibilityTimeoutMs: 60000, + concurrencyGroups: [ + { + name: "tenant", + extractGroupId: (q) => q.tenantId, + getLimit: async () => 1, + defaultLimit: 1, + }, + ], + startConsumers: false, + }); + + const redis = createRedisClient(redisOptions); + + try { + queue.onMessage(async (ctx) => { + if (ctx.message.payload.value === "msg-0") { + await redis.hdel(keys.inflightDataKey(0), ctx.message.id); + } + processed.push(ctx.message.payload.value); + await ctx.complete(); + }); + + for (let i = 0; i < 2; i++) { + await queue.enqueue({ + queueId: "tenant:t1:queue:q1", + tenantId: "t1", + payload: { value: `msg-${i}` }, + }); + } + + queue.start(); + + await vi.waitFor( + () => { + expect(processed).toHaveLength(2); + }, + { timeout: 10000 } + ); + + const held = await redis.scard(keys.concurrencyKey("tenant", "t1")); + expect(held).toBe(0); + } finally { + await redis.quit(); + await queue.close(); + } + } + ); + + redisTest( + "should release metadata-derived groups from the cached descriptor when the in-flight record is gone", + { timeout: 15000 }, + async ({ redisOptions }) => { + const processed: string[] = []; + keys = new DefaultFairQueueKeyProducer({ prefix: "test" }); + + const scheduler = new DRRScheduler({ + redis: redisOptions, + keys, + quantum: 10, + maxDeficit: 100, + }); + + const queue = new TestFairQueueHelper(redisOptions, keys, { + scheduler, + payloadSchema: TestPayloadSchema, + shardCount: 1, + consumerCount: 1, + consumerIntervalMs: 20, + visibilityTimeoutMs: 60000, + concurrencyGroups: [ + { + name: "tenant", + extractGroupId: (q) => q.tenantId, + getLimit: async () => 5, + defaultLimit: 5, + }, + { + name: "organization", + extractGroupId: (q) => (q.metadata.orgId as string) ?? "default", + getLimit: async () => 1, + defaultLimit: 1, + }, + ], + startConsumers: false, + }); + + const redis = createRedisClient(redisOptions); + + try { + queue.onMessage(async (ctx) => { + if (ctx.message.payload.value === "msg-0") { + await redis.hdel(keys.inflightDataKey(0), ctx.message.id); + } + processed.push(ctx.message.payload.value); + await ctx.complete(); + }); + + for (let i = 0; i < 2; i++) { + await queue.enqueue({ + queueId: "tenant:t1:queue:q1", + tenantId: "t1", + metadata: { orgId: "org-1" }, + payload: { value: `msg-${i}` }, + }); + } + + queue.start(); + + await vi.waitFor( + () => { + expect(processed).toHaveLength(2); + }, + { timeout: 10000 } + ); + + expect(await redis.scard(keys.concurrencyKey("organization", "org-1"))).toBe(0); + expect(await redis.scard(keys.concurrencyKey("organization", "default"))).toBe(0); + } finally { + await redis.quit(); + await queue.close(); + } + } + ); + + redisTest( + "should requeue a reclaimed message even when freeing its slot fails", + { timeout: 25000 }, + async ({ redisOptions }) => { + keys = new DefaultFairQueueKeyProducer({ prefix: "test" }); + + const scheduler = new DRRScheduler({ + redis: redisOptions, + keys, + quantum: 10, + maxDeficit: 100, + }); + + const queue = new TestFairQueueHelper(redisOptions, keys, { + scheduler, + payloadSchema: TestPayloadSchema, + shardCount: 1, + consumerCount: 1, + consumerIntervalMs: 20, + visibilityTimeoutMs: 500, + reclaimIntervalMs: 100, + reconcileIntervalMs: 600_000, + concurrencyGroups: [ + { + name: "tenant", + extractGroupId: (q) => q.tenantId, + getLimit: async () => 5, + defaultLimit: 5, + }, + ], + startConsumers: false, + }); + + const redis = createRedisClient(redisOptions); + const queueId = "tenant:t1:queue:release-fails"; + const concurrencyKey = keys.concurrencyKey("tenant", "t1"); + let starts = 0; + + try { + queue.onMessage(async (ctx) => { + starts++; + if (starts === 1) { + await new Promise((resolve) => setTimeout(resolve, 3000)); + return; + } + await ctx.complete(); + }); + + await queue.enqueue({ + queueId, + tenantId: "t1", + payload: { value: "msg-0" }, + }); + + queue.start(); + + await vi.waitFor( + async () => { + expect(await redis.zcard(keys.inflightKey(0))).toBe(1); + }, + { timeout: 5000 } + ); + + await redis.del(concurrencyKey); + await redis.set(concurrencyKey, "not-a-set"); + + await vi.waitFor( + async () => { + expect(await redis.zcard(keys.queueKey(queueId))).toBe(1); + expect(await redis.zcard(keys.inflightKey(0))).toBe(0); + }, + { timeout: 5000 } + ); + + await redis.del(concurrencyKey); + + await vi.waitFor( + () => { + expect(starts).toBe(2); + }, + { timeout: 10000 } + ); + + await vi.waitFor( + async () => { + expect(await redis.zcard(keys.inflightKey(0))).toBe(0); + expect(await redis.zcard(keys.queueKey(queueId))).toBe(0); + expect(await redis.scard(concurrencyKey)).toBe(0); + }, + { timeout: 5000 } + ); + } finally { + await redis.del(concurrencyKey); + await redis.quit(); + await queue.close(); + } + } + ); + + redisTest( + "should complete a message even when freeing its slot fails, never re-delivering it", + { timeout: 20000 }, + async ({ redisOptions }) => { + keys = new DefaultFairQueueKeyProducer({ prefix: "test" }); + + const scheduler = new DRRScheduler({ + redis: redisOptions, + keys, + quantum: 10, + maxDeficit: 100, + }); + + const queue = new TestFairQueueHelper(redisOptions, keys, { + scheduler, + payloadSchema: TestPayloadSchema, + shardCount: 1, + consumerCount: 1, + consumerIntervalMs: 20, + visibilityTimeoutMs: 500, + reclaimIntervalMs: 100, + reconcileIntervalMs: 600_000, + concurrencyGroups: [ + { + name: "tenant", + extractGroupId: (q) => q.tenantId, + getLimit: async () => 5, + defaultLimit: 5, + }, + ], + startConsumers: false, + }); + + const redis = createRedisClient(redisOptions); + const queueId = "tenant:t1:queue:complete-release-fails"; + const concurrencyKey = keys.concurrencyKey("tenant", "t1"); + let executions = 0; + + try { + queue.onMessage(async (ctx) => { + executions++; + await redis.del(concurrencyKey); + await redis.set(concurrencyKey, "not-a-set"); + await ctx.complete(); + }); + + await queue.enqueue({ + queueId, + tenantId: "t1", + payload: { value: "msg-0" }, + }); + + queue.start(); + + await vi.waitFor( + () => { + expect(executions).toBe(1); + }, + { timeout: 5000 } + ); + + await vi.waitFor( + async () => { + expect(await redis.zcard(keys.inflightKey(0))).toBe(0); + }, + { timeout: 5000 } + ); + + await redis.del(concurrencyKey); + + await new Promise((resolve) => setTimeout(resolve, 1500)); + + expect(executions).toBe(1); + expect(await redis.zcard(keys.inflightKey(0))).toBe(0); + expect(await redis.zcard(keys.queueKey(queueId))).toBe(0); + } finally { + await redis.del(concurrencyKey); + await redis.quit(); + await queue.close(); + } + } + ); + + redisTest( + "should schedule a retry with an advanced attempt even when freeing its slot fails", + { timeout: 20000 }, + async ({ redisOptions }) => { + keys = new DefaultFairQueueKeyProducer({ prefix: "test" }); + const attempts: number[] = []; + + const scheduler = new DRRScheduler({ + redis: redisOptions, + keys, + quantum: 10, + maxDeficit: 100, + }); + + const queue = new TestFairQueueHelper(redisOptions, keys, { + scheduler, + payloadSchema: TestPayloadSchema, + shardCount: 1, + consumerCount: 1, + consumerIntervalMs: 20, + visibilityTimeoutMs: 60000, + reclaimIntervalMs: 100, + reconcileIntervalMs: 600_000, + retry: { + strategy: new FixedDelayRetry({ maxAttempts: 2, delayMs: 50 }), + deadLetterQueue: true, + }, + concurrencyGroups: [ + { + name: "tenant", + extractGroupId: (q) => q.tenantId, + getLimit: async () => 5, + defaultLimit: 5, + }, + ], + startConsumers: false, + }); + + const redis = createRedisClient(redisOptions); + const queueId = "tenant:t1:queue:retry-release-fails"; + const concurrencyKey = keys.concurrencyKey("tenant", "t1"); + + try { + queue.onMessage(async (ctx) => { + attempts.push(ctx.message.attempt); + if (ctx.message.attempt === 1) { + await redis.del(concurrencyKey); + await redis.set(concurrencyKey, "not-a-set"); + } + await ctx.fail(new Error("boom")); + }); + + await queue.enqueue({ + queueId, + tenantId: "t1", + payload: { value: "msg-0" }, + messageId: "retry-msg", + }); + + queue.start(); + + await vi.waitFor( + () => { + expect(attempts).toEqual([1]); + }, + { timeout: 5000 } + ); + + await vi.waitFor( + async () => { + const stored = await redis.hget(keys.queueItemsKey(queueId), "retry-msg"); + expect(stored).not.toBeNull(); + expect(JSON.parse(stored!).attempt).toBe(2); + }, + { timeout: 5000 } + ); + + expect(await redis.zcard(keys.inflightKey(0))).toBe(0); + + await redis.del(concurrencyKey); + + await vi.waitFor( + () => { + expect(attempts).toEqual([1, 2]); + }, + { timeout: 5000 } + ); + + await vi.waitFor( + async () => { + expect(await queue.getDeadLetterQueueLength("t1")).toBe(1); + }, + { timeout: 5000 } + ); + } finally { + await redis.del(concurrencyKey); + await redis.quit(); + await queue.close(); + } + } + ); + + redisTest( + "should release the concurrency slot even when the reclaim requeue fails", + { timeout: 15000 }, + async ({ redisOptions }) => { + keys = new DefaultFairQueueKeyProducer({ prefix: "test" }); + + const scheduler = new DRRScheduler({ + redis: redisOptions, + keys, + quantum: 10, + maxDeficit: 100, + }); + + const queue = new TestFairQueueHelper(redisOptions, keys, { + scheduler, + payloadSchema: TestPayloadSchema, + shardCount: 1, + consumerCount: 1, + consumerIntervalMs: 20, + visibilityTimeoutMs: 30000, + reclaimIntervalMs: 100, + concurrencyGroups: [ + { + name: "tenant", + extractGroupId: (q) => q.tenantId, + getLimit: async () => 1, + defaultLimit: 1, + }, + ], + startConsumers: false, + }); + + const redis = createRedisClient(redisOptions); + const queueId = "tenant:t1:queue:reclaim-fail"; + const inflightKey = keys.inflightKey(0); + + let unblockHandler: (() => void) | undefined; + const handlerBlocked = new Promise((resolve) => { + unblockHandler = resolve; + }); + + try { + queue.onMessage(async () => { + await handlerBlocked; + }); + + await queue.enqueue({ + queueId, + tenantId: "t1", + payload: { value: "msg-0" }, + }); + + queue.start(); + + await vi.waitFor( + async () => { + expect(await redis.scard(keys.concurrencyKey("tenant", "t1"))).toBe(1); + expect(await redis.zcard(inflightKey)).toBe(1); + }, + { timeout: 5000 } + ); + + await redis.del(keys.queueKey(queueId)); + await redis.set(keys.queueKey(queueId), "not-a-zset"); + + const [member] = await redis.zrange(inflightKey, 0, 0); + expect(member).toBeDefined(); + await redis.zadd(inflightKey, Date.now() - 60000, member!); + + await vi.waitFor( + async () => { + expect(await redis.scard(keys.concurrencyKey("tenant", "t1"))).toBe(0); + }, + { timeout: 8000 } + ); + + expect(await redis.zcard(inflightKey)).toBe(1); + } finally { + unblockHandler?.(); + await redis.del(keys.queueKey(queueId)); + await redis.quit(); + await queue.close(); + } + } + ); + + redisTest( + "should release the concurrency slot when failMessage cannot read the message", + { timeout: 15000 }, + async ({ redisOptions }) => { + const started: string[] = []; + keys = new DefaultFairQueueKeyProducer({ prefix: "test" }); + + const scheduler = new DRRScheduler({ + redis: redisOptions, + keys, + quantum: 10, + maxDeficit: 100, + }); + + const queue = new TestFairQueueHelper(redisOptions, keys, { + scheduler, + payloadSchema: TestPayloadSchema, + shardCount: 1, + consumerCount: 1, + consumerIntervalMs: 20, + visibilityTimeoutMs: 60000, + concurrencyGroups: [ + { + name: "tenant", + extractGroupId: (q) => q.tenantId, + getLimit: async () => 5, + defaultLimit: 5, + }, + ], + startConsumers: false, + }); + + const redis = createRedisClient(redisOptions); + + try { + queue.onMessage(async (ctx) => { + started.push(ctx.message.payload.value); + await redis.hdel(keys.inflightDataKey(0), ctx.message.id); + await ctx.fail(new Error("boom")); + }); + + await queue.enqueue({ + queueId: "tenant:t1:queue:q1", + tenantId: "t1", + payload: { value: "msg-0" }, + }); + + queue.start(); + + await vi.waitFor( + () => { + expect(started).toHaveLength(1); + }, + { timeout: 10000 } + ); + + await vi.waitFor( + async () => { + expect(await redis.scard(keys.concurrencyKey("tenant", "t1"))).toBe(0); + }, + { timeout: 5000 } + ); + } finally { + await redis.quit(); + await queue.close(); + } + } + ); + }); + + describe("concurrency slot reconciliation", () => { + redisTest( + "should free slots held by messages that are no longer in flight", + { timeout: 15000 }, + async ({ redisOptions }) => { + keys = new DefaultFairQueueKeyProducer({ prefix: "test" }); + + const scheduler = new DRRScheduler({ + redis: redisOptions, + keys, + quantum: 10, + maxDeficit: 100, + }); + + const queue = new TestFairQueueHelper(redisOptions, keys, { + scheduler, + payloadSchema: TestPayloadSchema, + shardCount: 1, + consumerCount: 1, + consumerIntervalMs: 20, + visibilityTimeoutMs: 60000, + reclaimIntervalMs: 100, + reconcileIntervalMs: 200, + concurrencyGroups: [ + { + name: "tenant", + extractGroupId: (q) => q.tenantId, + getLimit: async () => 1, + defaultLimit: 1, + }, + ], + startConsumers: false, + }); + + const redis = createRedisClient(redisOptions); + const queueId = "tenant:t1:queue:orphaned-slot"; + const concurrencyKey = keys.concurrencyKey("tenant", "t1"); + const processed: string[] = []; + + try { + await redis.sadd(concurrencyKey, "ghost-message"); + + queue.onMessage(async (ctx) => { + processed.push(ctx.message.payload.value); + await ctx.complete(); + }); + + await queue.enqueue({ + queueId, + tenantId: "t1", + payload: { value: "msg-0" }, + }); + + queue.start(); + + await vi.waitFor( + () => { + expect(processed).toEqual(["msg-0"]); + }, + { timeout: 8000 } + ); + + expect(await redis.sismember(concurrencyKey, "ghost-message")).toBe(0); + + await vi.waitFor( + async () => { + expect(await redis.scard(concurrencyKey)).toBe(0); + }, + { timeout: 5000 } + ); + } finally { + await redis.del(concurrencyKey); + await redis.quit(); + await queue.close(); + } + } + ); + + redisTest( + "should leave the slot of an in-flight message alone", + { timeout: 15000 }, + async ({ redisOptions }) => { + keys = new DefaultFairQueueKeyProducer({ prefix: "test" }); + + const scheduler = new DRRScheduler({ + redis: redisOptions, + keys, + quantum: 10, + maxDeficit: 100, + }); + + const queue = new TestFairQueueHelper(redisOptions, keys, { + scheduler, + payloadSchema: TestPayloadSchema, + shardCount: 1, + consumerCount: 1, + consumerIntervalMs: 20, + visibilityTimeoutMs: 60000, + reclaimIntervalMs: 100, + reconcileIntervalMs: 100, + concurrencyGroups: [ + { + name: "tenant", + extractGroupId: (q) => q.tenantId, + getLimit: async () => 1, + defaultLimit: 1, + }, + ], + startConsumers: false, + }); + + const redis = createRedisClient(redisOptions); + const queueId = "tenant:t1:queue:active-slot"; + const concurrencyKey = keys.concurrencyKey("tenant", "t1"); + let executions = 0; + + try { + queue.onMessage(async (ctx) => { + executions++; + await new Promise((resolve) => setTimeout(resolve, 1500)); + await ctx.complete(); + }); + + await queue.enqueue({ + queueId, + tenantId: "t1", + payload: { value: "msg-0" }, + messageId: "active-msg", + }); + + queue.start(); + + await vi.waitFor( + async () => { + expect(await redis.zcard(keys.inflightKey(0))).toBe(1); + }, + { timeout: 5000 } + ); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + expect(await redis.sismember(concurrencyKey, "active-msg")).toBe(1); + expect(await redis.zcard(keys.inflightKey(0))).toBe(1); + + await vi.waitFor( + async () => { + expect(executions).toBe(1); + expect(await redis.scard(concurrencyKey)).toBe(0); + expect(await redis.zcard(keys.inflightKey(0))).toBe(0); + }, + { timeout: 5000 } + ); + + expect(executions).toBe(1); + } finally { + await redis.del(concurrencyKey); + await redis.quit(); + await queue.close(); + } + } + ); + + redisTest( + "should not sweep when the reconcile interval is zero", + { timeout: 10000 }, + async ({ redisOptions }) => { + keys = new DefaultFairQueueKeyProducer({ prefix: "test" }); + + const scheduler = new DRRScheduler({ + redis: redisOptions, + keys, + quantum: 10, + maxDeficit: 100, + }); + + const queue = new TestFairQueueHelper(redisOptions, keys, { + scheduler, + payloadSchema: TestPayloadSchema, + shardCount: 1, + consumerCount: 1, + consumerIntervalMs: 20, + visibilityTimeoutMs: 60000, + reclaimIntervalMs: 100, + reconcileIntervalMs: 0, + concurrencyGroups: [ + { + name: "tenant", + extractGroupId: (q) => q.tenantId, + getLimit: async () => 5, + defaultLimit: 5, + }, + ], + startConsumers: false, + }); + + const redis = createRedisClient(redisOptions); + const queueId = "tenant:t1:queue:sweep-disabled"; + const concurrencyKey = keys.concurrencyKey("tenant", "t1"); + const processed: string[] = []; + + try { + await redis.sadd(concurrencyKey, "ghost-message"); + + queue.onMessage(async (ctx) => { + processed.push(ctx.message.payload.value); + await ctx.complete(); + }); + + await queue.enqueue({ + queueId, + tenantId: "t1", + payload: { value: "msg-0" }, + }); + + queue.start(); + + await vi.waitFor( + () => { + expect(processed).toEqual(["msg-0"]); + }, + { timeout: 5000 } + ); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + expect(await redis.sismember(concurrencyKey, "ghost-message")).toBe(1); + } finally { + await redis.del(concurrencyKey); + await redis.quit(); + await queue.close(); + } + } + ); + }); }); diff --git a/packages/redis-worker/src/fair-queue/tests/visibility.test.ts b/packages/redis-worker/src/fair-queue/tests/visibility.test.ts index e20b667107..83d7e7f3e8 100644 --- a/packages/redis-worker/src/fair-queue/tests/visibility.test.ts +++ b/packages/redis-worker/src/fair-queue/tests/visibility.test.ts @@ -912,5 +912,70 @@ describe("VisibilityManager", () => { await redis.quit(); } ); + + redisTest( + "should drop a dangling in-flight entry whose payload is gone", + { timeout: 10000 }, + async ({ redisOptions }) => { + keys = new DefaultFairQueueKeyProducer({ prefix: "test" }); + + const manager = new VisibilityManager({ + redis: redisOptions, + keys, + shardCount: 1, + defaultTimeoutMs: 100, + }); + + const redis = createRedisClient(redisOptions); + const queueId = "tenant:t1:queue:dangling"; + const queueKey = keys.queueKey(queueId); + const queueItemsKey = keys.queueItemsKey(queueId); + const dispatchKey = keys.dispatchKey(0); + const inflightKey = keys.inflightKey(0); + const inflightDataKey = keys.inflightDataKey(0); + + try { + const messageId = "dangling-msg"; + const storedMessage = { + id: messageId, + queueId, + tenantId: "t1", + payload: { id: 1, value: "test" }, + timestamp: Date.now() - 1000, + attempt: 1, + }; + + await redis.zadd(queueKey, storedMessage.timestamp, messageId); + await redis.hset(queueItemsKey, messageId, JSON.stringify(storedMessage)); + + const claimResult = await manager.claim( + queueId, + queueKey, + queueItemsKey, + "consumer-1", + 100 + ); + expect(claimResult.claimed).toBe(true); + + await redis.hdel(inflightDataKey, messageId); + expect(await redis.zcard(inflightKey)).toBe(1); + + await new Promise((resolve) => setTimeout(resolve, 150)); + + await manager.reclaimTimedOut(0, (qId) => ({ + queueKey: keys.queueKey(qId), + queueItemsKey: keys.queueItemsKey(qId), + tenantQueueIndexKey: keys.tenantQueueIndexKey(keys.extractTenantId(qId)), + dispatchKey, + tenantId: keys.extractTenantId(qId), + })); + + expect(await redis.zcard(inflightKey)).toBe(0); + } finally { + await manager.close(); + await redis.quit(); + } + } + ); }); }); diff --git a/packages/redis-worker/src/fair-queue/types.ts b/packages/redis-worker/src/fair-queue/types.ts index 3bc56b599f..1bb3e6576c 100644 --- a/packages/redis-worker/src/fair-queue/types.ts +++ b/packages/redis-worker/src/fair-queue/types.ts @@ -413,6 +413,8 @@ export interface FairQueueOptions Promise ): Promise { const inflightKey = this.keys.inflightKey(shardId); const inflightDataKey = this.keys.inflightDataKey(shardId); @@ -415,30 +419,68 @@ export class VisibilityManager { 100 // Process in batches ); - const reclaimedMessages: ReclaimedMessageInfo[] = []; + const candidates: Array<{ + member: string; + deadlineScore: string; + info: ReclaimedMessageInfo; + storedMessage: StoredMessage | null; + }> = []; for (let i = 0; i < timedOut.length; i += 2) { const member = timedOut[i]; - const _deadlineScore = timedOut[i + 1]; // This is the visibility deadline, not the original timestamp - if (!member || !_deadlineScore) { + const deadlineScore = timedOut[i + 1]; + if (!member || !deadlineScore) { continue; } const { messageId, queueId } = this.#parseMember(member); + + const dataJson = await this.redis.hget(inflightDataKey, messageId); + let storedMessage: StoredMessage | null = null; + if (dataJson) { + try { + storedMessage = JSON.parse(dataJson); + } catch { + // Ignore parse error, proceed with reclaim + } + } + + if (!storedMessage) { + this.logger.error("Missing or corrupted message data during reclaim, using fallback", { + messageId, + queueId, + }); + } + + candidates.push({ + member, + deadlineScore, + storedMessage, + info: { + messageId, + queueId, + tenantId: storedMessage?.tenantId ?? this.keys.extractTenantId(queueId), + metadata: storedMessage?.metadata ?? {}, + }, + }); + } + + if (candidates.length === 0) { + return []; + } + + if (onBeforeRequeue) { + await onBeforeRequeue(candidates.map((candidate) => candidate.info)); + } + + const reclaimedMessages: ReclaimedMessageInfo[] = []; + + for (const { member, deadlineScore, storedMessage, info } of candidates) { + const { messageId, queueId } = info; + const { queueKey, queueItemsKey, tenantQueueIndexKey, dispatchKey, tenantId } = getQueueKeys(queueId); try { - // Get message data BEFORE releasing so we can extract tenantId for concurrency release - const dataJson = await this.redis.hget(inflightDataKey, messageId); - let storedMessage: StoredMessage | null = null; - if (dataJson) { - try { - storedMessage = JSON.parse(dataJson); - } catch { - // Ignore parse error, proceed with reclaim - } - } - // Re-add to queue with original timestamp to preserve priority // Fall back to now if we can't get the original timestamp const score = storedMessage?.timestamp ?? now; @@ -457,34 +499,12 @@ export class VisibilityManager { tenantId ); - // Track reclaimed message for concurrency release - // Always add to reclaimedMessages to avoid concurrency leaks - if (storedMessage) { - reclaimedMessages.push({ - messageId, - queueId, - tenantId: storedMessage.tenantId, - metadata: storedMessage.metadata, - }); - } else { - // Fallback: extract tenantId from queueId when message data is missing or corrupted - // This ensures concurrency is released even if we can't get the full metadata - this.logger.error("Missing or corrupted message data during reclaim, using fallback", { - messageId, - queueId, - }); - reclaimedMessages.push({ - messageId, - queueId, - tenantId: this.keys.extractTenantId(queueId), - metadata: {}, - }); - } + reclaimedMessages.push(info); this.logger.debug("Reclaimed timed-out message", { messageId, queueId, - deadline: _deadlineScore, + deadline: deadlineScore, }); } catch (error) { this.logger.error("Failed to reclaim message", { @@ -707,7 +727,9 @@ local tenantId = ARGV[6] -- Get message data from in-flight local payload = redis.call('HGET', inflightDataKey, messageId) if not payload then - -- Message not in in-flight or already released + -- Message not in in-flight or already released. Drop the dangling in-flight + -- entry so it stops consuming a slot in every future reclaim scan. + redis.call('ZREM', inflightKey, member) return 0 end @@ -717,14 +739,15 @@ if updatedData and updatedData ~= "" then payload = updatedData end +-- Add back to queue before dropping the in-flight record. Lua does not roll back on +-- error, so removing first would lose the message outright if the queue write failed. +redis.call('ZADD', queueKey, score, messageId) +redis.call('HSET', queueItemsKey, messageId, payload) + -- Remove from in-flight redis.call('ZREM', inflightKey, member) redis.call('HDEL', inflightDataKey, messageId) --- Add back to queue -redis.call('ZADD', queueKey, score, messageId) -redis.call('HSET', queueItemsKey, messageId, payload) - -- Update tenant queue index (Level 2) with queue's oldest message local oldest = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES') if #oldest >= 2 then @@ -771,15 +794,20 @@ for i = 0, numMessages - 1 do -- Get message data from in-flight local payload = redis.call('HGET', inflightDataKey, messageId) if payload then + -- Add back to queue before dropping the in-flight record, so a failed queue write + -- cannot lose the message: Lua does not roll back already-applied commands. + redis.call('ZADD', queueKey, score, messageId) + redis.call('HSET', queueItemsKey, messageId, payload) + -- Remove from in-flight redis.call('ZREM', inflightKey, member) redis.call('HDEL', inflightDataKey, messageId) - -- Add back to queue - redis.call('ZADD', queueKey, score, messageId) - redis.call('HSET', queueItemsKey, messageId, payload) - releasedCount = releasedCount + 1 + else + -- Dangling in-flight entry with no payload: drop it so it stops consuming + -- a slot in every future reclaim scan. + redis.call('ZREM', inflightKey, member) end end diff --git a/packages/trigger-sdk/src/v3/schedules/api.ts b/packages/trigger-sdk/src/v3/schedules/api.ts index 65fe4d4294..4264b0d3ef 100644 --- a/packages/trigger-sdk/src/v3/schedules/api.ts +++ b/packages/trigger-sdk/src/v3/schedules/api.ts @@ -1,6 +1,23 @@ -export type { - CreateScheduleOptions, - ScheduledTaskPayload, +import type { + CreateScheduleOptions as CoreCreateScheduleOptions, ListScheduleOptions, - UpdateScheduleOptions, + ScheduledTaskPayload, + UpdateScheduleOptions as CoreUpdateScheduleOptions, + ValidatedScheduleWindow, } from "@trigger.dev/core/v3"; + +export type { ListScheduleOptions, ScheduledTaskPayload }; + +export type CreateScheduleOptions = Omit< + CoreCreateScheduleOptions, + "window" +> & { + window?: ValidatedScheduleWindow; +}; + +export type UpdateScheduleOptions = Omit< + CoreUpdateScheduleOptions, + "window" +> & { + window?: ValidatedScheduleWindow; +}; diff --git a/packages/trigger-sdk/src/v3/schedules/index.test.ts b/packages/trigger-sdk/src/v3/schedules/index.test.ts new file mode 100644 index 0000000000..f224b90c18 --- /dev/null +++ b/packages/trigger-sdk/src/v3/schedules/index.test.ts @@ -0,0 +1,55 @@ +import { resourceCatalog } from "@trigger.dev/core/v3"; +import { StandardResourceCatalog } from "@trigger.dev/core/v3/workers"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { task } from "./index.js"; + +describe("declarative schedule windows", () => { + beforeEach(() => { + resourceCatalog.disable(); + resourceCatalog.setGlobalResourceCatalog(new StandardResourceCatalog()); + resourceCatalog.setCurrentFileContext("scheduled.ts", "scheduled.ts"); + }); + + afterEach(() => { + resourceCatalog.clearCurrentFileContext(); + resourceCatalog.disable(); + }); + + it.each(["0m", "30m", "2h", "24h", "30%"] as const)( + "serializes the %s window into task metadata", + (window) => { + task({ + id: "daily-report", + cron: { + pattern: "0 9 * * *", + timezone: "Europe/London", + window, + environments: ["PRODUCTION"], + }, + run: async () => undefined, + }); + + expect(resourceCatalog.getTaskManifest("daily-report")?.schedule).toEqual({ + cron: "0 9 * * *", + timezone: "Europe/London", + window, + environments: ["PRODUCTION"], + }); + } + ); + + it("leaves the window undefined when it is omitted", () => { + task({ + id: "hourly-report", + cron: { pattern: "0 * * * *" }, + run: async () => undefined, + }); + + expect(resourceCatalog.getTaskManifest("hourly-report")?.schedule).toEqual({ + cron: "0 * * * *", + timezone: "UTC", + window: undefined, + environments: undefined, + }); + }); +}); diff --git a/packages/trigger-sdk/src/v3/schedules/index.ts b/packages/trigger-sdk/src/v3/schedules/index.ts index 7ab27e00e4..ad9f1538e8 100644 --- a/packages/trigger-sdk/src/v3/schedules/index.ts +++ b/packages/trigger-sdk/src/v3/schedules/index.ts @@ -5,6 +5,7 @@ import type { InitOutput, OffsetLimitPagePromise, ScheduleObject, + ValidatedScheduleWindow, } from "@trigger.dev/core/v3"; import { TimezonesResult, @@ -23,6 +24,7 @@ export type ScheduleOptions< TIdentifier extends string, TOutput, TInitOutput extends InitOutput, + TWindow extends string | undefined = string | undefined, > = TaskOptions & { /** You can optionally specify a CRON schedule on your task. You can also dynamically add a schedule in the dashboard or using the SDK functions. * @@ -31,11 +33,12 @@ export type ScheduleOptions< * "0 0 * * *" * ``` * - * 2. Or an object with a pattern, optional timezone, and optional environments + * 2. Or an object with a pattern, optional timezone, window, and environments * ```ts * { * pattern: "0 0 * * *", * timezone: "America/Los_Angeles", + * window: "30m", * environments: ["PRODUCTION", "STAGING"] * } * ``` @@ -47,6 +50,10 @@ export type ScheduleOptions< | { pattern: string; timezone?: string; + /** Optionally assign each run a stable time after its CRON time. + * Use a whole duration such as `"30m"` or `"2h"`, or a percentage such as `"30%"`. + */ + window?: ValidatedScheduleWindow; /** You can optionally specify which environments this schedule should run in. * When not specified, the schedule will run in all environments. * @@ -64,8 +71,13 @@ export type ScheduleOptions< }; }; -export function task( - params: ScheduleOptions +export function task< + TIdentifier extends string, + TOutput, + TInitOutput extends InitOutput, + const TWindow extends string | undefined = undefined, +>( + params: ScheduleOptions ): Task { const task = createTask(params); @@ -78,6 +90,7 @@ export function task( + options: SchedulesAPI.CreateScheduleOptions, requestOptions?: ApiRequestOptions ): ApiPromise { const apiClient = apiClientManager.clientOrThrow(); @@ -177,9 +191,9 @@ export function retrieve( * @param options.externalId - An optional external identifier for the schedule * @returns The updated schedule */ -export function update( +export function update( scheduleId: string, - options: SchedulesAPI.UpdateScheduleOptions, + options: SchedulesAPI.UpdateScheduleOptions, requestOptions?: ApiRequestOptions ): ApiPromise { const apiClient = apiClientManager.clientOrThrow(); diff --git a/packages/trigger-sdk/src/v3/schedules/index.types.test.ts b/packages/trigger-sdk/src/v3/schedules/index.types.test.ts new file mode 100644 index 0000000000..9db62ef0bc --- /dev/null +++ b/packages/trigger-sdk/src/v3/schedules/index.types.test.ts @@ -0,0 +1,98 @@ +import { describe, it } from "vitest"; +import { create, task, update } from "./index.js"; + +declare const runtimeWindow: string; + +function assertDeclarativeScheduleWindowTypes() { + task({ + id: "valid-window", + cron: { pattern: "*/5 * * * *", window: "30m" }, + run: async () => undefined, + }); + task({ + id: "runtime-window", + cron: { pattern: "*/5 * * * *", window: runtimeWindow }, + run: async () => undefined, + }); + + task({ + id: "duration-too-large", + // @ts-expect-error Schedule windows cannot exceed 24 hours. + cron: { pattern: "*/5 * * * *", window: "25h" }, + run: async () => undefined, + }); + task({ + id: "percentage-too-large", + // @ts-expect-error Schedule window percentages cannot exceed 100%. + cron: { pattern: "*/5 * * * *", window: "101%" }, + run: async () => undefined, + }); + task({ + id: "unsupported-unit", + // @ts-expect-error Days are not a supported schedule-window unit. + cron: { pattern: "*/5 * * * *", window: "1d" }, + run: async () => undefined, + }); + task({ + id: "fractional-window", + // @ts-expect-error Schedule windows must use whole numbers. + cron: { pattern: "*/5 * * * *", window: "1.5m" }, + run: async () => undefined, + }); + task({ + id: "negative-window", + // @ts-expect-error Schedule windows must be non-negative. + cron: { pattern: "*/5 * * * *", window: "-1m" }, + run: async () => undefined, + }); + task({ + id: "leading-zero-window", + // @ts-expect-error Schedule windows must use canonical whole numbers. + cron: { pattern: "*/5 * * * *", window: "01m" }, + run: async () => undefined, + }); +} + +function assertImperativeScheduleWindowTypes() { + create({ + task: "scheduled-task", + cron: "*/5 * * * *", + deduplicationKey: "valid", + window: "50%", + }); + create({ + task: "scheduled-task", + cron: "*/5 * * * *", + deduplicationKey: "runtime", + window: runtimeWindow, + }); + update("schedule_123", { + task: "scheduled-task", + cron: "*/5 * * * *", + window: "1440m", + }); + + create({ + task: "scheduled-task", + cron: "*/5 * * * *", + deduplicationKey: "invalid", + // @ts-expect-error Schedule windows cannot exceed 24 hours. + window: "1441m", + }); + update("schedule_123", { + task: "scheduled-task", + cron: "*/5 * * * *", + // @ts-expect-error Schedule windows must use a supported unit. + window: "30s", + }); +} + +describe("schedule window types", () => { + it("validates declarative schedule literals", () => { + void assertDeclarativeScheduleWindowTypes; + }); + + it("validates imperative schedule literals", () => { + void assertImperativeScheduleWindowTypes; + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 160c39b7b2..779a9aa841 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,48 +16,51 @@ overrides: react-dom@^18: 18.3.1 ai@^6: 6.0.116 '@ai-sdk/provider-utils@^4': 4.0.29 - express@^4>body-parser: 1.20.3 + axios@>=1.15.2 <1.18.0: ^1.18.0 + express@^4>body-parser: ^1.20.6 '@remix-run/dev@2.17.5>tar-fs': 2.1.4 - tar: 7.5.19 + tar: 7.5.21 form-data@^2: 2.5.4 form-data@^3: 3.0.5 form-data@^4: 4.0.6 ws@>=7 <7.5.11: 7.5.11 ws@>=8 <8.21.0: 8.21.0 - hono@>=4 <4.12.25: 4.12.25 - undici@>=6 <6.27.0: 6.27.0 - undici@>=7 <7.28.0: 7.28.0 - js-yaml@>=3.0.0 <3.14.2: 3.14.2 - js-yaml@>=4.0.0 <4.1.1: 4.1.1 + hono@>=4 <4.12.34: 4.12.34 + undici@>=6 <6.28.0: 6.28.0 + undici@>=7 <7.29.0: 7.29.0 + js-yaml@>=3.0.0 <3.15.1: 3.15.1 + js-yaml@>=4.0.0 <4.3.1: 4.3.1 jws@<3.2.3: 3.2.3 qs@>=6.0.0 <6.15.2: ^6.15.2 lodash@>=4.17 <4.18.0: ^4.18.0 lodash-es@>=4.17 <4.18.0: ^4.18.0 - dompurify@>=3 <3.4.0: ^3.4.1 - vite@>=5.0.0 <6.4.2: ^6.4.2 + dompurify@>=3 <3.4.13: ^3.4.13 + vite@>=5.0.0 <6.4.3: ^6.4.3 rollup@>=4 <4.59.0: ^4.59.0 flatted@>=3 <3.4.2: ^3.4.2 picomatch@>=2 <2.3.2: ^2.3.2 picomatch@>=4 <4.0.4: ^4.0.4 minimatch@>=3 <3.1.3: ^3.1.3 - protobufjs@>=7 <7.5.6: ^7.5.6 + protobufjs@>=7 <7.6.5: ^7.6.5 fast-xml-parser@>=4 <4.5.5: ^4.5.5 fast-xml-parser@>=5 <5.7.0: ^5.7.0 path-to-regexp@>=0.1 <0.1.13: ^0.1.13 ajv@>=8 <8.18.0: ^8.18.0 - socket.io-parser@>=4 <4.2.6: ^4.2.6 - postcss@>=8 <8.5.10: ^8.5.10 + socket.io-parser@>=4 <4.2.7: ^4.2.7 + postcss@>=8 <8.5.23: ^8.5.23 yaml@>=2 <2.8.3: ^2.8.3 semver@>=5 <5.7.2: ^5.7.2 defu@>=6 <6.1.5: ^6.1.5 - fast-uri@<3.1.2: ^3.1.2 + fast-uri@>=3 <3.1.5: ^3.1.5 js-cookie@<3.0.8: 3.0.8 tmp@<0.2.7: 0.2.7 - brace-expansion@<1.1.13: 1.1.13 - brace-expansion@>=2 <2.0.3: 2.0.3 - brace-expansion@>=5 <5.0.6: 5.0.6 + brace-expansion@<1.1.18: 1.1.18 + brace-expansion@>=2 <2.1.4: 2.1.4 + brace-expansion@>=5 <5.0.9: 5.0.9 mermaid@>=11 <11.16.1: ^11.16.1 - '@jsonhero/json-infer-types>ip-address': ^10.2.0 + '@jsonhero/json-infer-types>ip-address': ^10.3.1 + express-rate-limit>ip-address: ^10.3.1 + find-my-way@>=9 <9.7.0: ^9.7.0 '@modelcontextprotocol/sdk@>=1.26.0>express-rate-limit': ^8.6.0 patchedDependencies: @@ -129,7 +132,7 @@ importers: version: 4.1.7(vitest@4.1.7) autoprefixer: specifier: ^10.4.12 - version: 10.4.13(postcss@8.5.15) + version: 10.4.13(postcss@8.5.26) knip: specifier: 6.25.0 version: 6.25.0 @@ -159,7 +162,7 @@ importers: version: 4.0.5(typescript@7.0.2) vitest: specifier: 4.1.7 - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0)) apps/supervisor: dependencies: @@ -188,8 +191,8 @@ importers: specifier: ^15.1.0 version: 15.1.0 socket.io: - specifier: 4.7.4 - version: 4.7.4(bufferutil@4.0.9) + specifier: 4.8.3 + version: 4.8.3(bufferutil@4.0.9) std-env: specifier: ^3.8.0 version: 3.8.1 @@ -326,6 +329,12 @@ importers: '@internal/tsql': specifier: workspace:* version: link:../../internal-packages/tsql + '@internal/webhook-engine': + specifier: workspace:* + version: link:../../internal-packages/webhook-engine + '@internal/webhook-sources': + specifier: workspace:* + version: link:../../internal-packages/webhook-sources '@internationalized/date': specifier: ^3.5.1 version: 3.5.1 @@ -573,8 +582,8 @@ importers: specifier: ^4.1.0 version: 4.1.0 dompurify: - specifier: ^3.4.1 - version: 3.4.1 + specifier: ^3.4.13 + version: 3.4.13 dotenv: specifier: ^16.4.5 version: 16.4.5 @@ -633,8 +642,8 @@ importers: specifier: ^1.10.0 version: 1.10.0 nanoid: - specifier: 3.3.8 - version: 3.3.8 + specifier: 3.3.18 + version: 3.3.18 neverthrow: specifier: ^8.2.0 version: 8.2.0 @@ -747,8 +756,8 @@ importers: specifier: ^6.0.0 version: 6.1.0 socket.io: - specifier: 4.7.4 - version: 4.7.4(bufferutil@4.0.9) + specifier: 4.8.3 + version: 4.8.3(bufferutil@4.0.9) socket.io-adapter: specifier: ^2.5.4 version: 2.5.4(bufferutil@4.0.9) @@ -812,7 +821,7 @@ importers: version: 1.37.0 '@remix-run/dev': specifier: 2.17.5 - version: 2.17.5(@remix-run/react@2.17.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@7.0.2))(@remix-run/serve@2.17.5(typescript@7.0.2))(@types/node@24.13.3)(bufferutil@4.0.9)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(typescript@7.0.2)(vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(yaml@2.9.0) + version: 2.17.5(@remix-run/react@2.17.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@7.0.2))(@remix-run/serve@2.17.5(typescript@7.0.2))(@types/node@24.13.3)(bufferutil@4.0.9)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(typescript@7.0.2)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(yaml@2.9.0) '@remix-run/testing': specifier: ^2.17.5 version: 2.17.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@7.0.2) @@ -907,23 +916,20 @@ importers: specifier: link:@types/@react-aria/datepicker version: link:@types/@react-aria/datepicker engine.io: - specifier: ^6.5.4 - version: 6.5.4(bufferutil@4.0.9) + specifier: ^6.6.7 + version: 6.6.8(bufferutil@4.0.9) esbuild: specifier: ^0.15.10 version: 0.15.18 evalite: specifier: 1.0.0-beta.16 version: 1.0.0-beta.16(ai@6.0.116(zod@3.25.76))(better-sqlite3@11.10.0)(bufferutil@4.0.9) - npm-run-all: - specifier: ^4.1.5 - version: 4.1.5 postcss-import: specifier: ^16.0.1 - version: 16.0.1(postcss@8.5.15) + version: 16.0.1(postcss@8.5.26) postcss-loader: specifier: ^8.1.1 - version: 8.1.1(postcss@8.5.15)(typescript@7.0.2)(webpack@5.102.1(@swc/core@1.3.26)(esbuild@0.15.18)) + version: 8.1.1(postcss@8.5.26)(typescript@7.0.2)(webpack@5.102.1(@swc/core@1.3.26)(esbuild@0.15.18)) rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -949,11 +955,11 @@ importers: specifier: npm:typescript@6.0.3 version: typescript@6.0.3 vite: - specifier: ^6.4.2 - version: 6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) + specifier: ^6.4.3 + version: 6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@7.0.2)(vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + version: 5.1.4(typescript@7.0.2)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) docs: {} @@ -1051,7 +1057,7 @@ importers: version: link:../../packages/cli-v3 vitest: specifier: 4.1.7 - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) internal-packages/dashboard-agent-contracts: dependencies: @@ -1064,7 +1070,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.7 - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) internal-packages/dashboard-agent-db: dependencies: @@ -1102,7 +1108,7 @@ importers: version: 6.0.1 vitest: specifier: 4.1.7 - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) internal-packages/emails: dependencies: @@ -1145,7 +1151,7 @@ importers: version: 18.2.7 react-email: specifier: ^6.5.0 - version: 6.5.0(bufferutil@4.0.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 6.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) internal-packages/llm-model-catalog: dependencies: @@ -1161,7 +1167,7 @@ importers: version: link:../testcontainers vitest: specifier: 4.1.7 - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) internal-packages/metrics-pipeline: dependencies: @@ -1202,7 +1208,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.7 - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) internal-packages/otlp-importer: dependencies: @@ -1210,8 +1216,8 @@ importers: specifier: ^5.2.3 version: 5.2.3 protobufjs: - specifier: ^7.5.6 - version: 7.6.1 + specifier: ^7.6.5 + version: 7.6.5 devDependencies: '@types/node': specifier: 24.13.3 @@ -1309,8 +1315,8 @@ importers: specifier: ^1.2.1 version: 1.2.1 nanoid: - specifier: 3.3.8 - version: 3.3.8 + specifier: 3.3.18 + version: 3.3.18 p-map: specifier: ^6.0.0 version: 6.0.0 @@ -1351,7 +1357,7 @@ importers: version: 6.0.1 vitest: specifier: 4.1.7 - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) internal-packages/run-store: dependencies: @@ -1395,9 +1401,6 @@ importers: cronstrue: specifier: ^2.50.0 version: 2.61.0 - nanoid: - specifier: 3.3.8 - version: 3.3.8 zod: specifier: 3.25.76 version: 3.25.76 @@ -1426,7 +1429,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.7 - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) internal-packages/sso: dependencies: @@ -1474,9 +1477,6 @@ importers: '@testcontainers/redis': specifier: ^11.14.0 version: 11.14.0 - '@trigger.dev/core': - specifier: workspace:* - version: link:../../packages/core std-env: specifier: ^3.9.0 version: 3.9.0 @@ -1518,6 +1518,47 @@ importers: specifier: 0.5.0-alpha.4 version: 0.5.0-alpha.4 + internal-packages/webhook-engine: + dependencies: + '@internal/redis': + specifier: workspace:* + version: link:../redis + '@internal/tracing': + specifier: workspace:* + version: link:../tracing + '@trigger.dev/core': + specifier: workspace:* + version: link:../../packages/core + '@trigger.dev/database': + specifier: workspace:* + version: link:../database + '@trigger.dev/redis-worker': + specifier: workspace:* + version: link:../../packages/redis-worker + zod: + specifier: 3.25.76 + version: 3.25.76 + devDependencies: + '@internal/testcontainers': + specifier: workspace:* + version: link:../testcontainers + rimraf: + specifier: 6.0.1 + version: 6.0.1 + + internal-packages/webhook-sources: + dependencies: + '@trigger.dev/core': + specifier: workspace:* + version: link:../../packages/core + zod: + specifier: 3.25.76 + version: 3.25.76 + devDependencies: + '@internal/webhook-engine': + specifier: workspace:* + version: link:../webhook-engine + packages/build: dependencies: '@prisma/config': @@ -1583,7 +1624,7 @@ importers: version: 0.0.1-cli.2.80.0 '@modelcontextprotocol/sdk': specifier: ^1.25.2 - version: 1.25.2(hono@4.12.25)(supports-color@10.0.0)(zod@3.25.76) + version: 1.25.2(hono@4.12.34)(supports-color@10.0.0)(zod@3.25.76) '@opentelemetry/api': specifier: 1.9.1 version: 1.9.1 @@ -1744,8 +1785,8 @@ importers: specifier: ^10.0.0 version: 10.0.0 tar: - specifier: 7.5.19 - version: 7.5.19 + specifier: 7.5.21 + version: 7.5.21 tiny-invariant: specifier: ^1.2.0 version: 1.3.1 @@ -1901,14 +1942,11 @@ importers: specifier: ^5.4.0 version: 5.4.0 nanoid: - specifier: 3.3.8 - version: 3.3.8 + specifier: 3.3.18 + version: 3.3.18 prom-client: specifier: ^15.1.0 version: 15.1.0 - socket.io: - specifier: 4.7.4 - version: 4.7.4(bufferutil@4.0.9) socket.io-client: specifier: 4.7.5 version: 4.7.5(bufferutil@4.0.9)(supports-color@10.0.0) @@ -1940,6 +1978,9 @@ importers: '@epic-web/test-server': specifier: ^0.1.0 version: 0.1.0(bufferutil@4.0.9) + '@internal/testcontainers': + specifier: workspace:* + version: link:../../internal-packages/testcontainers '@trigger.dev/database': specifier: workspace:* version: link:../../internal-packages/database @@ -2085,8 +2126,8 @@ importers: specifier: ^4.5.0 version: 4.5.0 nanoid: - specifier: ^5.0.7 - version: 5.1.2 + specifier: ^5.1.16 + version: 5.1.16 p-limit: specifier: ^6.2.0 version: 6.2.0 @@ -4509,13 +4550,13 @@ packages: resolution: {integrity: sha512-xjzhqhSWUE/OhN0g3KCNVzNsQMlFUAL+/8GgPUr3TKcU7cvgZVBGswFofJ8WwGEHTqobzze1lDpGJl9ZNckDhA==} engines: {node: '>=18.14.1'} peerDependencies: - hono: 4.12.25 + hono: 4.12.34 '@hono/node-server@1.19.9': resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} peerDependencies: - hono: 4.12.25 + hono: 4.12.34 '@hono/node-ws@1.0.4': resolution: {integrity: sha512-0j1TMp67U5ym0CIlvPKcKtD0f2ZjaS/EnhOxFLs3bVfV+/4WInBE7hVe2x/7PLEsNIUK9+jVL8lPd28rzTAcZg==} @@ -5765,9 +5806,6 @@ packages: '@protobufjs/float@1.0.2': resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} - '@protobufjs/inquire@1.1.2': - resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} - '@protobufjs/path@1.1.2': resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} @@ -6653,7 +6691,7 @@ packages: '@remix-run/react': ^2.17.0 '@remix-run/serve': ^2.17.0 typescript: ^5.1.0 - vite: ^6.4.2 + vite: ^6.4.3 wrangler: ^3.28.2 peerDependenciesMeta: '@remix-run/serve': @@ -7866,9 +7904,6 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - '@types/cookie@0.4.1': - resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} - '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} @@ -8499,7 +8534,7 @@ packages: resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} peerDependencies: msw: ^2.4.9 - vite: ^6.4.2 + vite: ^6.4.3 peerDependenciesMeta: msw: optional: true @@ -8999,7 +9034,7 @@ packages: engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: - postcss: ^8.5.10 + postcss: ^8.5.23 available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} @@ -9011,8 +9046,8 @@ packages: aws4fetch@1.0.18: resolution: {integrity: sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ==} - axios@1.16.1: - resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} + axios@1.19.0: + resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} b4a@1.6.6: resolution: {integrity: sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==} @@ -9113,8 +9148,8 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - body-parser@1.20.3: - resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + body-parser@1.20.6: + resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} body-parser@2.2.0: @@ -9134,15 +9169,12 @@ packages: bowser@2.12.1: resolution: {integrity: sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==} - brace-expansion@1.1.13: - resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} - - brace-expansion@2.0.3: - resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -9506,9 +9538,6 @@ packages: compute-l2norm@1.1.0: resolution: {integrity: sha512-6EHh1Elj90eU28SXi+h2PLnTQvZmkkHWySpoFz+WOlVNLz3DQoC4ISUHSV9n5jMxPHtKGJ01F4uu2PsXBB8sSg==} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - conf@15.1.0: resolution: {integrity: sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==} engines: {node: '>=20'} @@ -9551,10 +9580,6 @@ packages: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} - cookie@0.4.2: - resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} - engines: {node: '>= 0.6'} - cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} @@ -9653,10 +9678,6 @@ packages: cross-spawn@5.1.0: resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==} - cross-spawn@6.0.5: - resolution: {integrity: sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==} - engines: {node: '>=4.8'} - cross-spawn@7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -10129,8 +10150,8 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} - dompurify@3.4.1: - resolution: {integrity: sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==} + dompurify@3.4.13: + resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} domutils@3.0.1: resolution: {integrity: sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==} @@ -10349,10 +10370,6 @@ packages: resolution: {integrity: sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==} engines: {node: '>=10.0.0'} - engine.io@6.5.4: - resolution: {integrity: sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==} - engines: {node: '>=10.2.0'} - engine.io@6.6.8: resolution: {integrity: sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==} engines: {node: '>=10.2.0'} @@ -10394,10 +10411,6 @@ packages: error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} - es-abstract@1.21.1: - resolution: {integrity: sha512-QudMsPOz86xYz/1dG1OuGBKOELjCh99IIWHLzy5znUB6j8xG2yMA7bfTV86VSqKF+Y/H08vQPR+9jyXpuC6hfg==} - engines: {node: '>= 0.4'} - es-abstract@1.23.3: resolution: {integrity: sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==} engines: {node: '>= 0.4'} @@ -10825,8 +10838,8 @@ packages: fast-shallow-equal@1.0.0: resolution: {integrity: sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==} - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} @@ -10917,8 +10930,8 @@ packages: resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} engines: {node: '>= 0.8'} - find-my-way@9.3.0: - resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==} + find-my-way@9.7.0: + resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==} engines: {node: '>=20'} find-up@4.1.0: @@ -11044,10 +11057,6 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - function.prototype.name@1.1.5: - resolution: {integrity: sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==} - engines: {node: '>= 0.4'} - function.prototype.name@1.1.6: resolution: {integrity: sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==} engines: {node: '>= 0.4'} @@ -11106,10 +11115,6 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} - get-symbol-description@1.0.0: - resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==} - engines: {node: '>= 0.4'} - get-symbol-description@1.0.2: resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} engines: {node: '>= 0.4'} @@ -11246,10 +11251,6 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - has@1.0.3: - resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} - engines: {node: '>= 0.4.0'} - hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -11301,8 +11302,8 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} - hono@4.12.25: - resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} + hono@4.12.34: + resolution: {integrity: sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA==} engines: {node: '>=16.9.0'} hookable@6.1.1: @@ -11386,7 +11387,7 @@ packages: resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.5.23 ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -11458,10 +11459,6 @@ packages: resolution: {integrity: sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==} engines: {node: '>= 0.10'} - internal-slot@1.0.4: - resolution: {integrity: sha512-tA8URYccNzMo94s5MQZgH8NB/XTa6HsOo0MLfXTKKEnHVVdegzaQoFZ7Jp44bdvLvY2waT5dc+j5ICEswhi7UQ==} - engines: {node: '>= 0.4'} - internal-slot@1.0.7: resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} engines: {node: '>= 0.4'} @@ -11484,8 +11481,8 @@ packages: resolution: {integrity: sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==} engines: {node: '>=12.22.0'} - ip-address@10.2.0: - resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + ip-address@10.5.0: + resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -11509,9 +11506,6 @@ packages: resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==} engines: {node: '>= 0.4'} - is-array-buffer@3.0.1: - resolution: {integrity: sha512-ASfLknmY8Xa2XtB4wmbz13Wu202baeA18cJBCeCy0wXUHZF0IPyVEXqKEcd+t2fNSLLL1vC6k7lxZEojNbISXQ==} - is-array-buffer@3.0.4: resolution: {integrity: sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==} engines: {node: '>= 0.4'} @@ -11604,10 +11598,6 @@ packages: resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==} engines: {node: '>= 0.4'} - is-negative-zero@2.0.2: - resolution: {integrity: sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==} - engines: {node: '>= 0.4'} - is-negative-zero@2.0.3: resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} engines: {node: '>= 0.4'} @@ -11646,9 +11636,6 @@ packages: resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} engines: {node: '>= 0.4'} - is-shared-array-buffer@1.0.2: - resolution: {integrity: sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==} - is-shared-array-buffer@1.0.3: resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} engines: {node: '>= 0.4'} @@ -11795,12 +11782,12 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + js-yaml@3.15.1: + resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==} hasBin: true - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true jsep@1.4.0: @@ -11815,9 +11802,6 @@ packages: json-buffer@3.0.0: resolution: {integrity: sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ==} - json-parse-better-errors@1.0.2: - resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} - json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -12021,10 +12005,6 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - load-json-file@4.0.0: - resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} - engines: {node: '>=4'} - load-yaml-file@0.2.0: resolution: {integrity: sha512-OfCBkGEw4nN6JLtgRidPX6QxjBQGQf72q3si2uvqyFEMbycSFFHwAZeXx6cJgFM9wmLrf9zBwCP3Ivqa+LLZPw==} engines: {node: '>=6'} @@ -12325,10 +12305,6 @@ packages: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} - memorystream@0.3.1: - resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} - engines: {node: '>= 0.10.0'} - meow@12.1.1: resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} engines: {node: '>=16.10'} @@ -12606,9 +12582,6 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - minimatch@5.1.6: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} engines: {node: '>=10'} @@ -12742,23 +12715,13 @@ packages: react: 18.3.1 react-dom: 18.3.1 - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - nanoid@3.3.8: - resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - nanoid@5.1.2: - resolution: {integrity: sha512-b+CiXQCNMUGe0Ri64S9SXFcP9hogjAJ2Rd6GdVxhPLRm7mhGaM7VgOvCAJ1ZshfHbqVDI3uqTI5C8/GaKuLI7g==} + nanoid@5.1.16: + resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} engines: {node: ^18 || >=20} hasBin: true @@ -12791,9 +12754,6 @@ packages: resolution: {integrity: sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==} engines: {node: '>=18'} - nice-try@1.0.5: - resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} - node-abi@3.89.0: resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} engines: {node: '>=10'} @@ -12891,11 +12851,6 @@ packages: resolution: {integrity: sha512-1dKY+86/AIiq1tkKVD3l0WI+Gd3vkknVGAggsFeBkTvbhMQ1OND/LKkYv4JtXPKUJ8bOTCyLiqEg2P6QNdK+Gg==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - npm-run-all@4.1.5: - resolution: {integrity: sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==} - engines: {node: '>= 4'} - hasBin: true - npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -13170,9 +13125,6 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} - package-json-from-dist@1.0.0: - resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==} - package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -13201,10 +13153,6 @@ packages: engines: {node: '>=0.10.0'} hasBin: true - parse-json@4.0.0: - resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} - engines: {node: '>=4'} - parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -13254,10 +13202,6 @@ packages: resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} engines: {node: '>=14.0.0'} - path-key@2.0.1: - resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} - engines: {node: '>=4'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -13284,10 +13228,6 @@ packages: resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==} engines: {node: '>=16'} - path-type@3.0.0: - resolution: {integrity: sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==} - engines: {node: '>=4'} - path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -13378,11 +13318,6 @@ packages: resolution: {integrity: sha512-lGA1TNsmy2bxvRsTI2cV01kfTwKzZjnZSDmF9llYNyMHMrU4sP87lQ5taiIKm88L3cbswjl008nwyGc3WpNvzg==} engines: {node: '>=18.0.0'} - pidtree@0.3.1: - resolution: {integrity: sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==} - engines: {node: '>=0.10'} - hasBin: true - pidtree@0.6.0: resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} engines: {node: '>=0.10'} @@ -13392,10 +13327,6 @@ packages: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} - pify@3.0.0: - resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} - engines: {node: '>=4'} - pify@4.0.1: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} @@ -13454,19 +13385,19 @@ packages: resolution: {integrity: sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==} engines: {node: ^10 || ^12 || >=14.0} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.5.23 postcss-import@16.0.1: resolution: {integrity: sha512-i2Pci0310NaLHr/5JUFSw1j/8hf1CzwMY13g6ZDxgOavmRHQi2ba3PmUHoihO+sjaum+KmCNzskNsw7JDrg03g==} engines: {node: '>=18.0.0'} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.5.23 postcss-load-config@4.0.2: resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} engines: {node: '>= 14'} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.5.23 ts-node: '>=9.0.0' peerDependenciesMeta: postcss: @@ -13479,7 +13410,7 @@ packages: engines: {node: '>= 18.12.0'} peerDependencies: '@rspack/core': 0.x || 1.x - postcss: ^8.5.10 + postcss: ^8.5.23 webpack: ^5.0.0 peerDependenciesMeta: '@rspack/core': @@ -13491,30 +13422,30 @@ packages: resolution: {integrity: sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.5.23 postcss-modules-local-by-default@4.0.4: resolution: {integrity: sha512-L4QzMnOdVwRm1Qb8m4x8jsZzKAaPAgrUF1r/hjDR2Xj7R+8Zsf97jAlSQzWtKx5YNiNGN8QxmPFIc/sh+RQl+Q==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.5.23 postcss-modules-scope@3.1.1: resolution: {integrity: sha512-uZgqzdTleelWjzJY+Fhti6F3C9iF1JR/dODLs/JDefozYcKTBCdD8BIl6nNPbTbcLnGrk56hzwZC2DaGNvYjzA==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.5.23 postcss-modules-values@4.0.0: resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.5.23 postcss-modules@6.0.0: resolution: {integrity: sha512-7DGfnlyi/ju82BRzTIjWS5C4Tafmzl3R79YP/PASiocj+aa6yYphHhhKUOEoXQToId5rgyFgJ88+ccOUydjBXQ==} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.5.23 postcss-selector-parser@6.0.10: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} @@ -13527,12 +13458,8 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.10: - resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} - engines: {node: ^10 || ^12 || >=14} - - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} postgres-array@2.0.0: @@ -13706,8 +13633,8 @@ packages: proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} - protobufjs@7.6.1: - resolution: {integrity: sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==} + protobufjs@7.6.5: + resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} engines: {node: '>=12.0.0'} proxy-addr@2.0.7: @@ -13778,8 +13705,8 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - raw-body@2.5.2: - resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} raw-body@3.0.0: @@ -13964,10 +13891,6 @@ packages: resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} engines: {node: '>=8'} - read-pkg@3.0.0: - resolution: {integrity: sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==} - engines: {node: '>=4'} - read-pkg@5.2.0: resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==} engines: {node: '>=8'} @@ -14051,10 +13974,6 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} - regexp.prototype.flags@1.4.3: - resolution: {integrity: sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==} - engines: {node: '>= 0.4'} - regexp.prototype.flags@1.5.2: resolution: {integrity: sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==} engines: {node: '>= 0.4'} @@ -14368,9 +14287,6 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safe-regex-test@1.0.0: - resolution: {integrity: sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==} - safe-regex-test@1.0.3: resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==} engines: {node: '>= 0.4'} @@ -14502,10 +14418,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.8.4: - resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} - engines: {node: '>= 0.4'} - shiki@3.23.0: resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} @@ -14585,14 +14497,10 @@ packages: resolution: {integrity: sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==} engines: {node: '>=10.0.0'} - socket.io-parser@4.2.6: - resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==} + socket.io-parser@4.2.7: + resolution: {integrity: sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==} engines: {node: '>=10.0.0'} - socket.io@4.7.4: - resolution: {integrity: sha512-DcotgfP1Zg9iP/dH9zvAQcWrE0TtbMVwXmlV4T4mqsvY+gw+LqUGPfx2AoVyRk0FLME+GQhufDMyacFmw7ksqw==} - engines: {node: '>=10.2.0'} - socket.io@4.8.3: resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} engines: {node: '>=10.2.0'} @@ -14743,23 +14651,13 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} - string.prototype.padend@3.1.4: - resolution: {integrity: sha512-67otBXoksdjsnXXRUq+KMVTdlVRZ2af422Y0aTyTjVaoQkGr3mxl2Bc5emi7dOQ3OGVVQQskmLEWwFXwommpNw==} - engines: {node: '>= 0.4'} - string.prototype.trim@1.2.9: resolution: {integrity: sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==} engines: {node: '>= 0.4'} - string.prototype.trimend@1.0.6: - resolution: {integrity: sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==} - string.prototype.trimend@1.0.8: resolution: {integrity: sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==} - string.prototype.trimstart@1.0.6: - resolution: {integrity: sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==} - string.prototype.trimstart@1.0.8: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} @@ -14952,8 +14850,8 @@ packages: tar-stream@3.1.7: resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} - tar@7.5.19: - resolution: {integrity: sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==} + tar@7.5.21: + resolution: {integrity: sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==} engines: {node: '>=18'} tdigest@0.1.2: @@ -15353,9 +15251,6 @@ packages: resolution: {integrity: sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==} engines: {node: '>= 0.4'} - typed-array-length@1.0.4: - resolution: {integrity: sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==} - typed-array-length@1.0.6: resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==} engines: {node: '>= 0.4'} @@ -15425,12 +15320,12 @@ packages: resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} - undici@6.27.0: - resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} + undici@6.28.0: + resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} engines: {node: '>=18.17'} - undici@7.28.0: - resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} unicode-emoji-modifier-base@1.0.0: @@ -15678,7 +15573,7 @@ packages: vite-tsconfig-paths@5.1.4: resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} peerDependencies: - vite: ^6.4.2 + vite: ^6.4.3 peerDependenciesMeta: vite: optional: true @@ -15711,8 +15606,8 @@ packages: terser: optional: true - vite@6.4.2: - resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: @@ -15767,7 +15662,7 @@ packages: '@vitest/ui': 4.1.7 happy-dom: '*' jsdom: '*' - vite: ^6.4.2 + vite: ^6.4.3 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -16100,7 +15995,7 @@ snapshots: dependencies: '@ai-sdk/provider': 0.0.26 eventsource-parser: 1.1.2 - nanoid: 3.3.8 + nanoid: 3.3.18 secure-json-parse: 2.7.0 optionalDependencies: zod: 3.25.76 @@ -16181,7 +16076,7 @@ snapshots: commander: 10.0.1 marked: 9.1.6 marked-terminal: 7.1.0(marked@9.1.6) - semver: 7.8.1 + semver: 7.8.5 '@arethetypeswrong/core@0.18.5': dependencies: @@ -16190,7 +16085,7 @@ snapshots: cjs-module-lexer: 1.4.3 fflate: 0.8.3 lru-cache: 11.2.4 - semver: 7.8.1 + semver: 7.8.5 typescript: 5.6.1-rc validate-npm-package-name: 5.0.0 @@ -18089,7 +17984,7 @@ snapshots: '@changesets/parse@0.3.16': dependencies: '@changesets/types': 5.2.1 - js-yaml: 3.14.2 + js-yaml: 3.15.1 '@changesets/pre@1.0.14': dependencies: @@ -18344,11 +18239,11 @@ snapshots: '@epic-web/test-server@0.1.0(bufferutil@4.0.9)': dependencies: - '@hono/node-server': 1.12.2(hono@4.12.25) - '@hono/node-ws': 1.0.4(@hono/node-server@1.12.2(hono@4.12.25))(bufferutil@4.0.9) + '@hono/node-server': 1.12.2(hono@4.12.34) + '@hono/node-ws': 1.0.4(@hono/node-server@1.12.2(hono@4.12.34))(bufferutil@4.0.9) '@open-draft/deferred-promise': 2.2.0 '@types/ws': 8.5.12 - hono: 4.12.25 + hono: 4.12.34 ws: 8.21.0(bufferutil@4.0.9) transitivePeerDependencies: - bufferutil @@ -18901,7 +18796,7 @@ snapshots: dependencies: ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) - fast-uri: 3.1.2 + fast-uri: 3.1.5 '@fastify/busboy@2.1.1': {} @@ -19025,7 +18920,7 @@ snapshots: dependencies: lodash.camelcase: 4.3.0 long: 5.2.3 - protobufjs: 7.6.1 + protobufjs: 7.6.5 yargs: 17.7.2 '@hcaptcha/loader@2.4.2': {} @@ -19047,17 +18942,17 @@ snapshots: dependencies: react: 18.3.1 - '@hono/node-server@1.12.2(hono@4.12.25)': + '@hono/node-server@1.12.2(hono@4.12.34)': dependencies: - hono: 4.12.25 + hono: 4.12.34 - '@hono/node-server@1.19.9(hono@4.12.25)': + '@hono/node-server@1.19.9(hono@4.12.34)': dependencies: - hono: 4.12.25 + hono: 4.12.34 - '@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.12.25))(bufferutil@4.0.9)': + '@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.12.34))(bufferutil@4.0.9)': dependencies: - '@hono/node-server': 1.12.2(hono@4.12.25) + '@hono/node-server': 1.12.2(hono@4.12.34) ws: 8.21.0(bufferutil@4.0.9) transitivePeerDependencies: - bufferutil @@ -19160,7 +19055,7 @@ snapshots: '@jsonhero/json-infer-types@1.2.11': dependencies: - ip-address: 10.2.0 + ip-address: 10.5.0 json5: 2.2.3 jwt-decode: 3.1.2 uuid: 8.3.2 @@ -19202,13 +19097,13 @@ snapshots: '@types/ws': 8.5.12 form-data: 4.0.6 isomorphic-ws: 5.0.0(ws@8.21.0(bufferutil@4.0.9)) - js-yaml: 4.1.1 + js-yaml: 4.3.1 jsonpath-plus: 10.3.0 node-fetch: 2.6.12(encoding@0.1.13) openid-client: 6.3.3 rfc4648: 1.5.3 stream-buffers: 3.0.2 - tar: 7.5.19 + tar: 7.5.21 tmp-promise: 3.0.3 tslib: 2.6.2 ws: 8.21.0(bufferutil@4.0.9) @@ -19311,9 +19206,9 @@ snapshots: '@microsoft/fetch-event-source@2.0.1': {} - '@modelcontextprotocol/sdk@1.25.2(hono@4.12.25)(supports-color@10.0.0)(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.25.2(hono@4.12.34)(supports-color@10.0.0)(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.9(hono@4.12.25) + '@hono/node-server': 1.19.9(hono@4.12.34) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -19335,7 +19230,7 @@ snapshots: '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.9(hono@4.12.25) + '@hono/node-server': 1.19.9(hono@4.12.34) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -19345,7 +19240,7 @@ snapshots: eventsource-parser: 3.1.0 express: 5.2.1 express-rate-limit: 8.6.0(express@5.2.1) - hono: 4.12.25 + hono: 4.12.34 jose: 6.1.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.0 @@ -19392,7 +19287,7 @@ snapshots: '@npmcli/fs@3.1.0': dependencies: - semver: 7.8.1 + semver: 7.8.5 '@npmcli/git@4.1.0': dependencies: @@ -19402,7 +19297,7 @@ snapshots: proc-log: 3.0.0 promise-inflight: 1.0.1 promise-retry: 2.0.1 - semver: 7.8.1 + semver: 7.8.5 which: 3.0.1 transitivePeerDependencies: - bluebird @@ -19781,7 +19676,7 @@ snapshots: '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.28.0 forwarded-parse: 2.1.2 - semver: 7.8.1 + semver: 7.8.5 transitivePeerDependencies: - supports-color @@ -19924,7 +19819,7 @@ snapshots: '@types/shimmer': 1.2.0 import-in-the-middle: 1.15.0 require-in-the-middle: 7.5.2 - semver: 7.8.1 + semver: 7.8.5 shimmer: 1.2.1 transitivePeerDependencies: - supports-color @@ -20365,8 +20260,6 @@ snapshots: '@protobufjs/float@1.0.2': {} - '@protobufjs/inquire@1.1.2': {} - '@protobufjs/path@1.1.2': {} '@protobufjs/pool@1.1.0': {} @@ -21356,7 +21249,7 @@ snapshots: transitivePeerDependencies: - encoding - '@remix-run/dev@2.17.5(@remix-run/react@2.17.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@7.0.2))(@remix-run/serve@2.17.5(typescript@7.0.2))(@types/node@24.13.3)(bufferutil@4.0.9)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(typescript@7.0.2)(vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(yaml@2.9.0)': + '@remix-run/dev@2.17.5(@remix-run/react@2.17.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@7.0.2))(@remix-run/serve@2.17.5(typescript@7.0.2))(@types/node@24.13.3)(bufferutil@4.0.9)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(typescript@7.0.2)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(yaml@2.9.0)': dependencies: '@babel/core': 7.22.17 '@babel/generator': 7.29.7 @@ -21399,10 +21292,10 @@ snapshots: picocolors: 1.1.1 picomatch: 2.3.2 pidtree: 0.6.0 - postcss: 8.5.10 - postcss-discard-duplicates: 5.1.0(postcss@8.5.10) - postcss-load-config: 4.0.2(postcss@8.5.10) - postcss-modules: 6.0.0(postcss@8.5.10) + postcss: 8.5.26 + postcss-discard-duplicates: 5.1.0(postcss@8.5.26) + postcss-load-config: 4.0.2(postcss@8.5.26) + postcss-modules: 6.0.0(postcss@8.5.26) prettier: 2.8.8 pretty-ms: 7.0.1 react-refresh: 0.14.0 @@ -21418,7 +21311,7 @@ snapshots: optionalDependencies: '@remix-run/serve': 2.17.5(typescript@7.0.2) typescript: 7.0.2 - vite: 6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) + vite: 6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - bluebird @@ -21452,7 +21345,7 @@ snapshots: cookie-signature: 1.2.2 source-map-support: 0.5.21 stream-slice: 0.1.2 - undici: 6.27.0 + undici: 6.28.0 optionalDependencies: typescript: 7.0.2 @@ -21894,7 +21787,7 @@ snapshots: '@slack/types': 2.21.1 '@types/node': 24.13.3 '@types/retry': 0.12.0 - axios: 1.16.1 + axios: 1.19.0 eventemitter3: 5.0.1 form-data: 4.0.6 is-electron: 2.2.2 @@ -22731,7 +22624,7 @@ snapshots: '@alloc/quick-lru': 5.2.0 '@tailwindcss/node': 4.3.1 '@tailwindcss/oxide': 4.3.1 - postcss: 8.5.15 + postcss: 8.5.26 tailwindcss: 4.3.1 '@tailwindcss/typography@0.5.20(tailwindcss@4.3.1)': @@ -22841,8 +22734,6 @@ snapshots: dependencies: '@types/node': 24.13.3 - '@types/cookie@0.4.1': {} - '@types/cookie@0.6.0': {} '@types/cookiejar@2.1.5': {} @@ -23506,7 +23397,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0)) + vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0)) '@vitest/expect@4.1.7': dependencies: @@ -23517,21 +23408,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.7(vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0))': + '@vitest/mocker@4.1.7(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0) + vite: 6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0) - '@vitest/mocker@4.1.7(vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0))': + '@vitest/mocker@4.1.7(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0) + vite: 6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0) '@vitest/pretty-format@4.1.7': dependencies: @@ -23834,14 +23725,14 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -23998,7 +23889,7 @@ snapshots: ajv: 8.18.0 compute-cosine-similarity: 1.1.0 js-levenshtein: 1.1.6 - js-yaml: 4.1.1 + js-yaml: 4.3.1 linear-sum-assignment: 1.0.7 mustache: 4.2.0 openai: 4.97.0(encoding@0.1.13)(ws@8.21.0(bufferutil@4.0.9))(zod@3.25.76) @@ -24008,14 +23899,14 @@ snapshots: - encoding - ws - autoprefixer@10.4.13(postcss@8.5.15): + autoprefixer@10.4.13(postcss@8.5.26): dependencies: browserslist: 4.21.4 caniuse-lite: 1.0.30001577 fraction.js: 4.2.0 normalize-range: 0.1.2 picocolors: 1.0.0 - postcss: 8.5.15 + postcss: 8.5.26 postcss-value-parser: 4.2.0 available-typed-arrays@1.0.7: @@ -24029,7 +23920,7 @@ snapshots: aws4fetch@1.0.18: {} - axios@1.16.1: + axios@1.19.0: dependencies: follow-redirects: 1.16.0 form-data: 4.0.6 @@ -24128,18 +24019,18 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - body-parser@1.20.3: + body-parser@1.20.6: dependencies: bytes: 3.1.2 content-type: 1.0.5 debug: 2.6.9 depd: 2.0.0 destroy: 1.2.0 - http-errors: 2.0.0 + http-errors: 2.0.1 iconv-lite: 0.4.24 on-finished: 2.4.1 qs: 6.15.2 - raw-body: 2.5.2 + raw-body: 2.5.3 type-is: 1.6.18 unpipe: 1.0.0 transitivePeerDependencies: @@ -24179,16 +24070,11 @@ snapshots: bowser@2.12.1: {} - brace-expansion@1.1.13: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.0.3: + brace-expansion@2.1.4: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.6: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -24249,7 +24135,7 @@ snapshots: builtins@5.0.1: dependencies: - semver: 7.8.1 + semver: 7.8.5 bundle-name@4.1.0: dependencies: @@ -24311,7 +24197,7 @@ snapshots: minipass-pipeline: 1.2.4 p-map: 4.0.0 ssri: 10.0.5 - tar: 7.5.19 + tar: 7.5.21 unique-filename: 3.0.0 cacheable-request@6.1.0: @@ -24593,8 +24479,6 @@ snapshots: validate.io-array: 1.0.6 validate.io-function: 1.0.2 - concat-map@0.0.1: {} - conf@15.1.0: dependencies: ajv: 8.20.0 @@ -24604,7 +24488,7 @@ snapshots: dot-prop: 10.1.0 env-paths: 3.0.0 json-schema-typed: 8.0.2 - semver: 7.8.1 + semver: 7.8.5 uint8array-extras: 1.5.0 confbox@0.1.8: {} @@ -24636,8 +24520,6 @@ snapshots: cookie-signature@1.2.2: {} - cookie@0.4.2: {} - cookie@0.6.0: {} cookie@0.7.1: {} @@ -24675,7 +24557,7 @@ snapshots: dependencies: env-paths: 2.2.1 import-fresh: 3.3.0 - js-yaml: 4.1.1 + js-yaml: 4.3.1 parse-json: 5.2.0 optionalDependencies: typescript: 7.0.2 @@ -24735,14 +24617,6 @@ snapshots: shebang-command: 1.2.0 which: 1.3.1 - cross-spawn@6.0.5: - dependencies: - nice-try: 1.0.5 - path-key: 2.0.1 - semver: 5.7.2 - shebang-command: 1.2.0 - which: 1.3.1 - cross-spawn@7.0.3: dependencies: path-key: 3.1.1 @@ -24765,12 +24639,12 @@ snapshots: css-loader@6.10.0(webpack@5.102.1(@swc/core@1.3.26)(esbuild@0.15.18)): dependencies: - icss-utils: 5.1.0(postcss@8.5.10) - postcss: 8.5.10 - postcss-modules-extract-imports: 3.0.0(postcss@8.5.10) - postcss-modules-local-by-default: 4.0.4(postcss@8.5.10) - postcss-modules-scope: 3.1.1(postcss@8.5.10) - postcss-modules-values: 4.0.0(postcss@8.5.10) + icss-utils: 5.1.0(postcss@8.5.26) + postcss: 8.5.26 + postcss-modules-extract-imports: 3.0.0(postcss@8.5.26) + postcss-modules-local-by-default: 4.0.4(postcss@8.5.26) + postcss-modules-scope: 3.1.1(postcss@8.5.26) + postcss-modules-values: 4.0.0(postcss@8.5.26) postcss-value-parser: 4.2.0 semver: 7.8.1 optionalDependencies: @@ -25187,7 +25061,7 @@ snapshots: '@grpc/grpc-js': 1.12.6 '@grpc/proto-loader': 0.7.13 docker-modem: 5.0.7 - protobufjs: 7.6.1 + protobufjs: 7.6.5 tar-fs: 2.1.4 uuid: 10.0.0 transitivePeerDependencies: @@ -25199,7 +25073,7 @@ snapshots: '@grpc/grpc-js': 1.12.6 '@grpc/proto-loader': 0.7.13 docker-modem: 5.0.6 - protobufjs: 7.6.1 + protobufjs: 7.6.5 tar-fs: 2.1.3 uuid: 10.0.0 transitivePeerDependencies: @@ -25222,7 +25096,7 @@ snapshots: dependencies: domelementtype: 2.3.0 - dompurify@3.4.1: + dompurify@3.4.13: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -25302,7 +25176,7 @@ snapshots: '@one-ini/wasm': 0.1.1 commander: 10.0.1 minimatch: 9.0.1 - semver: 7.8.1 + semver: 7.8.5 ee-first@1.1.1: {} @@ -25370,23 +25244,6 @@ snapshots: engine.io-parser@5.2.2(patch_hash=9011e7e16547017450c9baec0fceff0e070310c20d9e62f8d1383bb1da77104f): {} - engine.io@6.5.4(bufferutil@4.0.9): - dependencies: - '@types/cookie': 0.4.1 - '@types/cors': 2.8.17 - '@types/node': 24.13.3 - accepts: 1.3.8 - base64id: 2.0.0 - cookie: 0.4.2 - cors: 2.8.5 - debug: 4.3.4 - engine.io-parser: 5.2.2(patch_hash=9011e7e16547017450c9baec0fceff0e070310c20d9e62f8d1383bb1da77104f) - ws: 8.21.0(bufferutil@4.0.9) - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - engine.io@6.6.8(bufferutil@4.0.9): dependencies: '@types/cors': 2.8.17 @@ -25433,42 +25290,6 @@ snapshots: dependencies: stackframe: 1.3.4 - es-abstract@1.21.1: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - es-set-tostringtag: 2.1.0 - es-to-primitive: 1.2.1 - function-bind: 1.1.2 - function.prototype.name: 1.1.5 - get-intrinsic: 1.3.0 - get-symbol-description: 1.0.0 - globalthis: 1.0.3 - gopd: 1.2.0 - has: 1.0.3 - has-property-descriptors: 1.0.2 - has-proto: 1.0.3 - has-symbols: 1.1.0 - internal-slot: 1.0.4 - is-array-buffer: 3.0.1 - is-callable: 1.2.7 - is-negative-zero: 2.0.2 - is-regex: 1.1.4 - is-shared-array-buffer: 1.0.2 - is-string: 1.0.7 - is-typed-array: 1.1.13 - is-weakref: 1.0.2 - object-inspect: 1.13.4 - object-keys: 1.1.1 - object.assign: 4.1.5 - regexp.prototype.flags: 1.4.3 - safe-regex-test: 1.0.0 - string.prototype.trimend: 1.0.6 - string.prototype.trimstart: 1.0.6 - typed-array-length: 1.0.4 - unbox-primitive: 1.0.2 - which-typed-array: 1.1.15 - es-abstract@1.23.3: dependencies: array-buffer-byte-length: 1.0.1 @@ -26001,7 +25822,7 @@ snapshots: dependencies: debug: 4.4.3(supports-color@10.0.0) express: 5.2.1 - ip-address: 10.2.0 + ip-address: 10.5.0 transitivePeerDependencies: - supports-color @@ -26009,7 +25830,7 @@ snapshots: dependencies: accepts: 1.3.8 array-flatten: 1.1.1 - body-parser: 1.20.3 + body-parser: 1.20.6 content-disposition: 0.5.4 content-type: 1.0.5 cookie: 0.6.0 @@ -26150,7 +25971,7 @@ snapshots: '@fastify/merge-json-schemas': 0.2.1 ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) - fast-uri: 3.1.2 + fast-uri: 3.1.5 json-schema-ref-resolver: 2.0.1 rfdc: 1.4.1 @@ -26164,7 +25985,7 @@ snapshots: fast-shallow-equal@1.0.0: {} - fast-uri@3.1.2: {} + fast-uri@3.1.5: {} fast-xml-builder@1.2.0: dependencies: @@ -26195,13 +26016,13 @@ snapshots: abstract-logging: 2.0.1 avvio: 9.1.0 fast-json-stringify: 6.0.1 - find-my-way: 9.3.0 + find-my-way: 9.7.0 light-my-request: 6.6.0 pino: 10.3.1 process-warning: 5.0.0 rfdc: 1.4.1 secure-json-parse: 4.0.0 - semver: 7.8.1 + semver: 7.8.5 toad-cache: 3.7.0 fastq@1.15.0: @@ -26232,6 +26053,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + fflate@0.4.8: {} fflate@0.8.3: {} @@ -26279,7 +26104,7 @@ snapshots: transitivePeerDependencies: - supports-color - find-my-way@9.3.0: + find-my-way@9.7.0: dependencies: fast-deep-equal: 3.1.3 fast-querystring: 1.1.2 @@ -26406,13 +26231,6 @@ snapshots: function-bind@1.1.2: {} - function.prototype.name@1.1.5: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.23.3 - functions-have-names: 1.2.3 - function.prototype.name@1.1.6: dependencies: call-bind: 1.0.8 @@ -26471,11 +26289,6 @@ snapshots: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 - get-symbol-description@1.0.0: - dependencies: - call-bind: 1.0.8 - get-intrinsic: 1.3.0 - get-symbol-description@1.0.2: dependencies: call-bind: 1.0.8 @@ -26503,7 +26316,7 @@ snapshots: nypm: 0.3.9 ohash: 1.1.3 pathe: 1.1.2 - tar: 7.5.19 + tar: 7.5.21 giget@2.0.0: dependencies: @@ -26531,7 +26344,7 @@ snapshots: jackspeak: 3.4.3 minimatch: 9.0.5 minipass: 7.1.3 - package-json-from-dist: 1.0.0 + package-json-from-dist: 1.0.1 path-scurry: 1.11.1 glob@11.1.0: @@ -26540,7 +26353,7 @@ snapshots: jackspeak: 4.2.3 minimatch: 10.2.5 minipass: 7.1.3 - package-json-from-dist: 1.0.0 + package-json-from-dist: 1.0.1 path-scurry: 2.0.2 glob@13.0.6: @@ -26638,10 +26451,6 @@ snapshots: dependencies: has-symbols: 1.1.0 - has@1.0.3: - dependencies: - function-bind: 1.1.2 - hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -26773,7 +26582,7 @@ snapshots: dependencies: react-is: 16.13.1 - hono@4.12.25: {} + hono@4.12.34: {} hookable@6.1.1: {} @@ -26857,9 +26666,9 @@ snapshots: dependencies: safer-buffer: 2.1.2 - icss-utils@5.1.0(postcss@8.5.10): + icss-utils@5.1.0(postcss@8.5.26): dependencies: - postcss: 8.5.10 + postcss: 8.5.26 ieee754@1.2.1: {} @@ -26917,12 +26726,6 @@ snapshots: install@0.13.0: {} - internal-slot@1.0.4: - dependencies: - get-intrinsic: 1.3.0 - has: 1.0.3 - side-channel: 1.1.0 - internal-slot@1.0.7: dependencies: es-errors: 1.3.0 @@ -26956,7 +26759,7 @@ snapshots: transitivePeerDependencies: - supports-color - ip-address@10.2.0: {} + ip-address@10.5.0: {} ipaddr.js@1.9.1: {} @@ -26976,12 +26779,6 @@ snapshots: call-bind: 1.0.8 has-tostringtag: 1.0.2 - is-array-buffer@3.0.1: - dependencies: - call-bind: 1.0.8 - get-intrinsic: 1.3.0 - is-typed-array: 1.1.13 - is-array-buffer@3.0.4: dependencies: call-bind: 1.0.8 @@ -27057,8 +26854,6 @@ snapshots: call-bind: 1.0.8 define-properties: 1.2.1 - is-negative-zero@2.0.2: {} - is-negative-zero@2.0.3: {} is-network-error@1.0.0: {} @@ -27086,10 +26881,6 @@ snapshots: call-bind: 1.0.8 has-tostringtag: 1.0.2 - is-shared-array-buffer@1.0.2: - dependencies: - call-bind: 1.0.8 - is-shared-array-buffer@1.0.3: dependencies: call-bind: 1.0.8 @@ -27207,12 +26998,12 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.14.2: + js-yaml@3.15.1: dependencies: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.1.1: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -27222,8 +27013,6 @@ snapshots: json-buffer@3.0.0: {} - json-parse-better-errors@1.0.2: {} - json-parse-even-better-errors@2.3.1: {} json-parse-even-better-errors@3.0.0: {} @@ -27289,7 +27078,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.8.1 + semver: 7.8.5 junk@4.0.1: {} @@ -27416,17 +27205,10 @@ snapshots: lines-and-columns@1.2.4: {} - load-json-file@4.0.0: - dependencies: - graceful-fs: 4.2.11 - parse-json: 4.0.0 - pify: 3.0.0 - strip-bom: 3.0.0 - load-yaml-file@0.2.0: dependencies: graceful-fs: 4.2.11 - js-yaml: 3.14.2 + js-yaml: 3.15.1 pify: 4.0.1 strip-bom: 3.0.0 @@ -27556,7 +27338,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.8.1 + semver: 7.8.5 map-obj@1.0.1: {} @@ -27569,7 +27351,7 @@ snapshots: marked-terminal@7.1.0(marked@9.1.6): dependencies: ansi-escapes: 7.0.0 - chalk: 5.3.0 + chalk: 5.6.2 cli-highlight: 2.1.11 cli-table3: 0.6.5 marked: 9.1.6 @@ -27861,8 +27643,6 @@ snapshots: media-typer@1.1.0: {} - memorystream@0.3.1: {} - meow@12.1.1: {} meow@6.1.1: @@ -27901,7 +27681,7 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 dayjs: 1.11.20 - dompurify: 3.4.1 + dompurify: 3.4.13 es-toolkit: 1.50.0 katex: 0.16.47 khroma: 2.1.0 @@ -28359,27 +28139,23 @@ snapshots: minimatch@10.0.1: dependencies: - brace-expansion: 2.0.3 + brace-expansion: 2.1.4 minimatch@10.2.5: dependencies: - brace-expansion: 5.0.6 - - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.13 + brace-expansion: 5.0.9 minimatch@5.1.6: dependencies: - brace-expansion: 2.0.3 + brace-expansion: 2.1.4 minimatch@9.0.1: dependencies: - brace-expansion: 2.0.3 + brace-expansion: 2.1.4 minimatch@9.0.5: dependencies: - brace-expansion: 2.0.3 + brace-expansion: 2.1.4 minimist-options@4.1.0: dependencies: @@ -28526,13 +28302,9 @@ snapshots: stacktrace-js: 2.0.2 stylis: 4.3.0 - nanoid@3.3.11: {} - - nanoid@3.3.12: {} + nanoid@3.3.18: {} - nanoid@3.3.8: {} - - nanoid@5.1.2: {} + nanoid@5.1.16: {} napi-build-utils@2.0.0: optional: true @@ -28559,8 +28331,6 @@ snapshots: optionalDependencies: '@rollup/rollup-linux-x64-gnu': 4.60.1 - nice-try@1.0.5: {} - node-abi@3.89.0: dependencies: semver: 7.8.5 @@ -28617,7 +28387,7 @@ snapshots: dependencies: hosted-git-info: 6.1.1 is-core-module: 2.14.0 - semver: 7.8.1 + semver: 7.8.5 validate-npm-package-license: 3.0.4 normalize-path@3.0.0: {} @@ -28630,7 +28400,7 @@ snapshots: npm-install-checks@6.2.0: dependencies: - semver: 7.8.1 + semver: 7.8.5 npm-normalize-package-bin@3.0.1: {} @@ -28638,7 +28408,7 @@ snapshots: dependencies: hosted-git-info: 6.1.1 proc-log: 3.0.0 - semver: 7.8.1 + semver: 7.8.5 validate-npm-package-name: 5.0.0 npm-pick-manifest@8.0.2: @@ -28646,19 +28416,7 @@ snapshots: npm-install-checks: 6.2.0 npm-normalize-package-bin: 3.0.1 npm-package-arg: 10.1.0 - semver: 7.8.1 - - npm-run-all@4.1.5: - dependencies: - ansi-styles: 3.2.1 - chalk: 2.4.2 - cross-spawn: 6.0.5 - memorystream: 0.3.1 - minimatch: 3.1.5 - pidtree: 0.3.1 - read-pkg: 3.0.0 - shell-quote: 1.8.4 - string.prototype.padend: 3.1.4 + semver: 7.8.5 npm-run-path@4.0.1: dependencies: @@ -29019,8 +28777,6 @@ snapshots: p-try@2.2.0: {} - package-json-from-dist@1.0.0: {} - package-json-from-dist@1.0.1: {} package-json@6.5.0: @@ -29053,11 +28809,6 @@ snapshots: parse-github-url@1.0.2: {} - parse-json@4.0.0: - dependencies: - error-ex: 1.3.2 - json-parse-better-errors: 1.0.2 - parse-json@5.2.0: dependencies: '@babel/code-frame': 7.29.7 @@ -29100,8 +28851,6 @@ snapshots: path-expression-matcher@1.5.0: {} - path-key@2.0.1: {} - path-key@3.1.1: {} path-key@4.0.0: {} @@ -29122,10 +28871,6 @@ snapshots: path-to-regexp@8.2.0: {} - path-type@3.0.0: - dependencies: - pify: 3.0.0 - path-type@4.0.0: {} pathe@1.1.2: {} @@ -29209,14 +28954,10 @@ snapshots: picospinner@3.0.0: {} - pidtree@0.3.1: {} - pidtree@0.6.0: {} pify@2.3.0: {} - pify@3.0.0: {} - pify@4.0.1: {} pino-abstract-transport@3.0.0: @@ -29278,66 +29019,66 @@ snapshots: possible-typed-array-names@1.0.0: {} - postcss-discard-duplicates@5.1.0(postcss@8.5.10): + postcss-discard-duplicates@5.1.0(postcss@8.5.26): dependencies: - postcss: 8.5.10 + postcss: 8.5.26 - postcss-import@16.0.1(postcss@8.5.15): + postcss-import@16.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.15 + postcss: 8.5.26 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.8 - postcss-load-config@4.0.2(postcss@8.5.10): + postcss-load-config@4.0.2(postcss@8.5.26): dependencies: lilconfig: 3.1.3 yaml: 2.9.0 optionalDependencies: - postcss: 8.5.10 + postcss: 8.5.26 - postcss-loader@8.1.1(postcss@8.5.15)(typescript@7.0.2)(webpack@5.102.1(@swc/core@1.3.26)(esbuild@0.15.18)): + postcss-loader@8.1.1(postcss@8.5.26)(typescript@7.0.2)(webpack@5.102.1(@swc/core@1.3.26)(esbuild@0.15.18)): dependencies: cosmiconfig: 9.0.0(typescript@7.0.2) jiti: 1.21.0 - postcss: 8.5.15 + postcss: 8.5.26 semver: 7.8.1 optionalDependencies: webpack: 5.102.1(@swc/core@1.3.26)(esbuild@0.15.18) transitivePeerDependencies: - typescript - postcss-modules-extract-imports@3.0.0(postcss@8.5.10): + postcss-modules-extract-imports@3.0.0(postcss@8.5.26): dependencies: - postcss: 8.5.10 + postcss: 8.5.26 - postcss-modules-local-by-default@4.0.4(postcss@8.5.10): + postcss-modules-local-by-default@4.0.4(postcss@8.5.26): dependencies: - icss-utils: 5.1.0(postcss@8.5.10) - postcss: 8.5.10 + icss-utils: 5.1.0(postcss@8.5.26) + postcss: 8.5.26 postcss-selector-parser: 6.1.2 postcss-value-parser: 4.2.0 - postcss-modules-scope@3.1.1(postcss@8.5.10): + postcss-modules-scope@3.1.1(postcss@8.5.26): dependencies: - postcss: 8.5.10 + postcss: 8.5.26 postcss-selector-parser: 6.1.2 - postcss-modules-values@4.0.0(postcss@8.5.10): + postcss-modules-values@4.0.0(postcss@8.5.26): dependencies: - icss-utils: 5.1.0(postcss@8.5.10) - postcss: 8.5.10 + icss-utils: 5.1.0(postcss@8.5.26) + postcss: 8.5.26 - postcss-modules@6.0.0(postcss@8.5.10): + postcss-modules@6.0.0(postcss@8.5.26): dependencies: generic-names: 4.0.0 - icss-utils: 5.1.0(postcss@8.5.10) + icss-utils: 5.1.0(postcss@8.5.26) lodash.camelcase: 4.3.0 - postcss: 8.5.10 - postcss-modules-extract-imports: 3.0.0(postcss@8.5.10) - postcss-modules-local-by-default: 4.0.4(postcss@8.5.10) - postcss-modules-scope: 3.1.1(postcss@8.5.10) - postcss-modules-values: 4.0.0(postcss@8.5.10) + postcss: 8.5.26 + postcss-modules-extract-imports: 3.0.0(postcss@8.5.26) + postcss-modules-local-by-default: 4.0.4(postcss@8.5.26) + postcss-modules-scope: 3.1.1(postcss@8.5.26) + postcss-modules-values: 4.0.0(postcss@8.5.26) string-hash: 1.1.3 postcss-selector-parser@6.0.10: @@ -29352,15 +29093,9 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.5.10: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - postcss@8.5.15: + postcss@8.5.26: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -29514,7 +29249,7 @@ snapshots: proto-list@1.2.4: {} - protobufjs@7.6.1: + protobufjs@7.6.5: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 @@ -29522,7 +29257,6 @@ snapshots: '@protobufjs/eventemitter': 1.1.1 '@protobufjs/fetch': 1.1.1 '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.2 '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 @@ -29594,10 +29328,10 @@ snapshots: range-parser@1.2.1: {} - raw-body@2.5.2: + raw-body@2.5.3: dependencies: bytes: 3.1.2 - http-errors: 2.0.0 + http-errors: 2.0.1 iconv-lite: 0.4.24 unpipe: 1.0.0 @@ -29666,7 +29400,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-email@6.5.0(bufferutil@4.0.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-email@6.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@babel/parser': 7.27.0 '@babel/traverse': 7.27.0 @@ -29860,12 +29594,6 @@ snapshots: read-pkg: 5.2.0 type-fest: 0.8.1 - read-pkg@3.0.0: - dependencies: - load-json-file: 4.0.0 - normalize-package-data: 2.5.0 - path-type: 3.0.0 - read-pkg@5.2.0: dependencies: '@types/normalize-package-data': 2.4.1 @@ -29876,7 +29604,7 @@ snapshots: read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 - js-yaml: 3.14.2 + js-yaml: 3.15.1 pify: 4.0.1 strip-bom: 3.0.0 @@ -29970,12 +29698,6 @@ snapshots: dependencies: regex-utilities: 2.3.0 - regexp.prototype.flags@1.4.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - functions-have-names: 1.2.3 - regexp.prototype.flags@1.5.2: dependencies: call-bind: 1.0.8 @@ -30032,7 +29754,7 @@ snapshots: dependencies: estree-util-is-identifier-name: 1.1.0 estree-util-value-to-estree: 1.3.0 - js-yaml: 4.1.1 + js-yaml: 4.3.1 toml: 3.0.0 remark-mdx@2.3.0: @@ -30220,7 +29942,7 @@ snapshots: rimraf@6.0.1: dependencies: glob: 11.1.0 - package-json-from-dist: 1.0.0 + package-json-from-dist: 1.0.1 rimraf@6.1.3: dependencies: @@ -30360,12 +30082,6 @@ snapshots: safe-buffer@5.2.1: {} - safe-regex-test@1.0.0: - dependencies: - call-bind: 1.0.8 - get-intrinsic: 1.3.0 - is-regex: 1.1.4 - safe-regex-test@1.0.3: dependencies: call-bind: 1.0.8 @@ -30552,8 +30268,6 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.8.4: {} - shiki@3.23.0: dependencies: '@shikijs/core': 3.23.0 @@ -30656,33 +30370,19 @@ snapshots: '@socket.io/component-emitter': 3.1.0 debug: 4.3.7(supports-color@10.0.0) engine.io-client: 6.5.3(bufferutil@4.0.9)(supports-color@10.0.0) - socket.io-parser: 4.2.6(supports-color@10.0.0) + socket.io-parser: 4.2.7(supports-color@10.0.0) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - socket.io-parser@4.2.6(supports-color@10.0.0): + socket.io-parser@4.2.7(supports-color@10.0.0): dependencies: '@socket.io/component-emitter': 3.1.0 debug: 4.4.3(supports-color@10.0.0) transitivePeerDependencies: - supports-color - socket.io@4.7.4(bufferutil@4.0.9): - dependencies: - accepts: 1.3.8 - base64id: 2.0.0 - cors: 2.8.5 - debug: 4.3.7(supports-color@10.0.0) - engine.io: 6.5.4(bufferutil@4.0.9) - socket.io-adapter: 2.5.4(bufferutil@4.0.9) - socket.io-parser: 4.2.6(supports-color@10.0.0) - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - socket.io@4.8.3(bufferutil@4.0.9): dependencies: accepts: 1.3.8 @@ -30691,7 +30391,7 @@ snapshots: debug: 4.4.3(supports-color@10.0.0) engine.io: 6.6.8(bufferutil@4.0.9) socket.io-adapter: 2.5.4(bufferutil@4.0.9) - socket.io-parser: 4.2.6(supports-color@10.0.0) + socket.io-parser: 4.2.7(supports-color@10.0.0) transitivePeerDependencies: - bufferutil - supports-color @@ -30859,12 +30559,6 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.0 - string.prototype.padend@3.1.4: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.21.1 - string.prototype.trim@1.2.9: dependencies: call-bind: 1.0.8 @@ -30872,24 +30566,12 @@ snapshots: es-abstract: 1.23.3 es-object-atoms: 1.1.1 - string.prototype.trimend@1.0.6: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.23.3 - string.prototype.trimend@1.0.8: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 es-object-atoms: 1.1.1 - string.prototype.trimstart@1.0.6: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.23.3 - string.prototype.trimstart@1.0.8: dependencies: call-bind: 1.0.8 @@ -31101,7 +30783,7 @@ snapshots: transitivePeerDependencies: - bare-abort-controller - tar@7.5.19: + tar@7.5.21: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -31149,7 +30831,7 @@ snapshots: ssh-remote-port-forward: 1.0.4 tar-fs: 3.1.2 tmp: 0.2.7 - undici: 7.28.0 + undici: 7.29.0 transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -31282,12 +30964,12 @@ snapshots: ts-proto-descriptors@1.15.0: dependencies: long: 5.3.2 - protobufjs: 7.6.1 + protobufjs: 7.6.5 ts-proto@1.167.3: dependencies: case-anything: 2.1.13 - protobufjs: 7.6.1 + protobufjs: 7.6.5 ts-poet: 6.6.0 ts-proto-descriptors: 1.15.0 @@ -31407,7 +31089,7 @@ snapshots: tsx@4.17.0: dependencies: esbuild: 0.23.0 - get-tsconfig: 4.7.6 + get-tsconfig: 4.14.0 optionalDependencies: fsevents: 2.3.3 @@ -31526,12 +31208,6 @@ snapshots: has-proto: 1.0.3 is-typed-array: 1.1.13 - typed-array-length@1.0.4: - dependencies: - call-bind: 1.0.8 - for-each: 0.3.3 - is-typed-array: 1.1.13 - typed-array-length@1.0.6: dependencies: call-bind: 1.0.8 @@ -31608,9 +31284,9 @@ snapshots: dependencies: '@fastify/busboy': 2.1.1 - undici@6.27.0: {} + undici@6.28.0: {} - undici@7.28.0: {} + undici@7.29.0: {} unicode-emoji-modifier-base@1.0.0: {} @@ -31888,7 +31564,7 @@ snapshots: debug: 4.4.3(supports-color@10.0.0) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) + vite: 6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -31912,13 +31588,13 @@ snapshots: - supports-color - typescript - vite-tsconfig-paths@5.1.4(typescript@7.0.2)(vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)): + vite-tsconfig-paths@5.1.4(typescript@7.0.2)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)): dependencies: debug: 4.4.3(supports-color@10.0.0) globrex: 0.1.2 tsconfck: 3.1.3(typescript@7.0.2) optionalDependencies: - vite: 6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) + vite: 6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) transitivePeerDependencies: - supports-color - typescript @@ -31926,7 +31602,7 @@ snapshots: vite@4.4.9(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.46.1): dependencies: esbuild: 0.18.20 - postcss: 8.5.15 + postcss: 8.5.26 rollup: 3.29.1 optionalDependencies: '@types/node': 24.13.3 @@ -31934,12 +31610,12 @@ snapshots: lightningcss: 1.32.0 terser: 5.46.1 - vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0): + vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0): dependencies: esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.15 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.26 rollup: 4.60.1 tinyglobby: 0.2.17 optionalDependencies: @@ -31951,12 +31627,12 @@ snapshots: tsx: 3.12.2 yaml: 2.9.0 - vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0): + vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0): dependencies: esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.15 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.26 rollup: 4.60.1 tinyglobby: 0.2.17 optionalDependencies: @@ -31968,12 +31644,12 @@ snapshots: tsx: 4.20.6 yaml: 2.9.0 - vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0): + vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0): dependencies: esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.15 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.26 rollup: 4.60.1 tinyglobby: 0.2.17 optionalDependencies: @@ -31985,10 +31661,10 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0)): + vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.7 - '@vitest/mocker': 4.1.7(vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0)) + '@vitest/mocker': 4.1.7(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.7 '@vitest/runner': 4.1.7 '@vitest/snapshot': 4.1.7 @@ -32005,7 +31681,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0) + vite: 6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -32014,10 +31690,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.7 - '@vitest/mocker': 4.1.7(vite@6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/mocker': 4.1.7(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.7 '@vitest/runner': 4.1.7 '@vitest/snapshot': 4.1.7 @@ -32034,7 +31710,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 6.4.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0) + vite: 6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1