GeezerVison is showing github.com — feat(core,cli,webapp): previousSecretTtlSeconds in the API client and… · triggerdotdev/trigger.dev@98adb66
File treeExpand file treeCollapse file tree Open diff view settings _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.endpoints.$endpointParam
Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboard +6Lines changed: 6 additions & 0 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
| | 1 | +--- | | 2 | +"@trigger.dev/core": patch | | 3 | +"trigger.dev": patch | | 4 | +--- | | 5 | + | | 6 | +The `generate_webhook_secret` and `set_webhook_secret` MCP tools take an optional `previousSecretTtlSeconds` to keep accepting a webhook endpoint's old signing secret while the provider switches to the new one (up to 7 days), and report when the old secret stops working. |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.endpoints.$endpointParam/route.tsx +6-3Lines changed: 6 additions & 3 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -8,7 +8,11 @@ import { | 8 | 8 | } from "@remix-run/react"; | 9 | 9 | import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; | 10 | 10 | import { randomBytes } from "node:crypto"; | 11 | | -import { WebhookRoutingTarget, WebhookVerifierArtifact } from "@trigger.dev/core/v3"; | | 11 | +import { | | 12 | + WebhookPreviousSecretTtlSeconds, | | 13 | + WebhookRoutingTarget, | | 14 | + WebhookVerifierArtifact, | | 15 | +} from "@trigger.dev/core/v3"; | 12 | 16 | import type { WebhookValueSource } from "@trigger.dev/core/v3"; | 13 | 17 | import { type MutableRefObject, Suspense, useEffect, useRef, useState } from "react"; | 14 | 18 | import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; | @@ -69,7 +73,6 @@ import { docsPath, EnvironmentParamSchema, v3WebhooksPath } from "~/utils/pathBu | 69 | 73 | import { throwPermissionDenied } from "~/utils/permissionDenied"; | 70 | 74 | import { parseFiniteInt } from "~/utils/searchParams"; | 71 | 75 | import { webhookEngine, webhookVerifyTokenKey } from "~/v3/webhookEngine.server"; | 72 | | -import { PreviousSecretTtlSeconds } from "~/v3/webhookSigningSecret"; | 73 | 76 | import { | 74 | 77 | generateWebhookSigningSecret, | 75 | 78 | storeWebhookSigningSecret, | @@ -212,7 +215,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { | 212 | 215 | }; | 213 | 216 |
| 214 | 217 | const PreviousSecretTtlFormField = z.object({ | 215 | | - previousSecretTtlSeconds: z.coerce.number().pipe(PreviousSecretTtlSeconds).optional(), | | 218 | + previousSecretTtlSeconds: z.coerce.number().pipe(WebhookPreviousSecretTtlSeconds).optional(), | 216 | 219 | }); | 217 | 220 |
| 218 | 221 | const SetSecretSchema = PreviousSecretTtlFormField.extend({ | |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/routes/api.v1.webhooks.endpoints.$endpointId.rotate-secret.ts +4-7Lines changed: 4 additions & 7 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 1 | 1 | import { json } from "@remix-run/server-runtime"; | | 2 | +import { RotateWebhookEndpointSecretRequestBody } from "@trigger.dev/core/v3"; | 2 | 3 | import { z } from "zod"; | 3 | 4 | import { webhookPrisma } from "~/db.server"; | 4 | 5 | import { webhookEndpointLookup } from "~/v3/webhookEndpointLookup"; | 5 | 6 | import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; | 6 | 7 | import { safeJsonParse } from "~/utils/json"; | 7 | | -import { PreviousSecretTtlSeconds } from "~/v3/webhookSigningSecret"; | 8 | 8 | import { generateWebhookSigningSecret } from "~/v3/webhookSigningSecret.server"; | 9 | 9 |
| 10 | 10 | const ParamsSchema = z.object({ endpointId: z.string() }); | 11 | 11 |
| 12 | | -/** Optional: existing callers POST with no body, which keeps the replace-immediately behavior. */ | 13 | | -const RotateSecretRequestBody = z.object({ | 14 | | - previousSecretTtlSeconds: PreviousSecretTtlSeconds.optional(), | 15 | | -}); | 16 | | - | 17 | 12 | // POST /api/v1/webhooks/endpoints/:endpointId/rotate-secret — mint a new signing secret and return | 18 | 13 | // it ONCE. Only for schemes we generate (hmac / shared-secret); asymmetric endpoints set a public key. | 19 | 14 | const { action, loader } = createActionApiRoute( | @@ -26,7 +21,9 @@ const { action, loader } = createActionApiRoute( | 26 | 21 | }, | 27 | 22 | async ({ params, authentication, request }) => { | 28 | 23 | const rawBody = await request.text(); | 29 | | - const body = RotateSecretRequestBody.safeParse(rawBody ? safeJsonParse(rawBody) : {}); | | 24 | + const body = RotateWebhookEndpointSecretRequestBody.safeParse( | | 25 | + rawBody ? safeJsonParse(rawBody) : {} | | 26 | + ); | 30 | 27 | if (!body.success) { | 31 | 28 | return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 }); | 32 | 29 | } | |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/routes/api.v1.webhooks.endpoints.$endpointId.secret.ts +1-6Lines changed: 1 addition & 6 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -4,19 +4,14 @@ import { z } from "zod"; | 4 | 4 | import { webhookPrisma } from "~/db.server"; | 5 | 5 | import { webhookEndpointLookup } from "~/v3/webhookEndpointLookup"; | 6 | 6 | import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; | 7 | | -import { PreviousSecretTtlSeconds } from "~/v3/webhookSigningSecret"; | 8 | 7 | import { storeWebhookSigningSecret } from "~/v3/webhookSigningSecret.server"; | 9 | 8 |
| 10 | 9 | const ParamsSchema = z.object({ endpointId: z.string() }); | 11 | 10 |
| 12 | | -const SetSecretRequestBody = SetWebhookEndpointSecretRequestBody.extend({ | 13 | | - previousSecretTtlSeconds: PreviousSecretTtlSeconds.optional(), | 14 | | -}); | 15 | | - | 16 | 11 | const { action, loader } = createActionApiRoute( | 17 | 12 | { | 18 | 13 | params: ParamsSchema, | 19 | | - body: SetSecretRequestBody, | | 14 | + body: SetWebhookEndpointSecretRequestBody, | 20 | 15 | method: "PUT", | 21 | 16 | allowJWT: true, | 22 | 17 | corsStrategy: "all", | |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/v3/webhookSetupPrompt.ts +1-1Lines changed: 1 addition & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -152,7 +152,7 @@ export function renderWebhookSetupPrompt( | 152 | 152 | function secretStep(endpoint: WebhookSetupPromptEndpoint, asymmetric: boolean): string { | 153 | 153 | const source = endpoint.source; | 154 | 154 | const replacing = endpoint.hasSigningSecret | 155 | | - ? ` ${asymmetric ? "A public key" : "A secret"} is already set; replacing it breaks deliveries until ${source} uses the new one, so only replace it when you're creating or updating the webhook in ${source}.` | | 155 | + ? ` ${asymmetric ? "A public key" : "A secret"} is already set; only replace it when you're creating or updating the webhook in ${source}, and pass \`previousSecretTtlSeconds\` (for example 3600) so deliveries signed with the old one keep verifying until ${source} uses the new one.` | 156 | 156 | : ""; | 157 | 157 |
| 158 | 158 | if (asymmetric) { | |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/v3/webhookSigningSecret.ts -9Lines changed: 0 additions & 9 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 1 | 1 | import { z } from "zod"; | 2 | 2 |
| 3 | | -export const MAX_PREVIOUS_SECRET_TTL_SECONDS = 7 * 24 * 60 * 60; | 4 | | - | 5 | | -/** How long the replaced secret keeps verifying deliveries; 0 replaces it immediately. */ | 6 | | -export const PreviousSecretTtlSeconds = z | 7 | | - .number() | 8 | | - .int() | 9 | | - .min(0) | 10 | | - .max(MAX_PREVIOUS_SECRET_TTL_SECONDS); | 11 | | - | 12 | 3 | /** | 13 | 4 | * An endpoint's signing secret as stored in the SecretStore. `previous` is the secret it replaced, | 14 | 5 | * still accepted until `expiresAt` so a rotation doesn't fail deliveries the provider signs with it. | |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/test/webhookSetupPrompt.test.ts +2-1Lines changed: 2 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -84,7 +84,8 @@ describe("renderWebhookSetupPrompt", () => { | 84 | 84 |
| 85 | 85 | expect(prompt).toContain("Public key: set"); | 86 | 86 | expect(prompt).toContain("Copy its public key and store it with `set_webhook_secret`."); | 87 | | - expect(prompt).toContain("A public key is already set; replacing it breaks deliveries"); | | 87 | + expect(prompt).toContain("A public key is already set; only replace it when"); | | 88 | + expect(prompt).toContain("pass `previousSecretTtlSeconds`"); | 88 | 89 | }); | 89 | 90 |
| 90 | 91 | it("asks which events to send when nothing subscribes yet", () => { | |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/test/webhookSigningSecret.test.ts +4-5Lines changed: 4 additions & 5 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 1 | 1 | import { describe, expect, it } from "vitest"; | 2 | 2 | import { | 3 | | - activeSigningSecrets, | 4 | | - MAX_PREVIOUS_SECRET_TTL_SECONDS, | 5 | | - PreviousSecretTtlSeconds, | 6 | | - replaceStoredSigningSecret, | 7 | | -} from "~/v3/webhookSigningSecret"; | | 3 | + MAX_WEBHOOK_PREVIOUS_SECRET_TTL_SECONDS as MAX_PREVIOUS_SECRET_TTL_SECONDS, | | 4 | + WebhookPreviousSecretTtlSeconds as PreviousSecretTtlSeconds, | | 5 | +} from "@trigger.dev/core/v3"; | | 6 | +import { activeSigningSecrets, replaceStoredSigningSecret } from "~/v3/webhookSigningSecret"; | 8 | 7 |
| 9 | 8 | const now = new Date("2026-10-20T12:00:00.000Z"); | 10 | 9 | const later = (seconds: number) => new Date(now.getTime() + seconds * 1000); | |
Collapse file Copy file name to clipboardExpand all lines: docs/v3-openapi.yaml +40-6Lines changed: 40 additions & 6 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -4922,9 +4922,19 @@ paths: | 4922 | 4922 | summary: Generate a webhook signing secret | 4923 | 4923 | description: >- | 4924 | 4924 | Generate a new signing secret for the endpoint, store it, and return it once. Use this for providers | 4925 | | - where you choose the secret, such as GitHub. It replaces any existing secret, so deliveries fail | 4926 | | - verification until the provider uses the new one. Endpoints whose source verifies with a public key | 4927 | | - refuse this; set the provider's key with the set secret endpoint instead. | | 4925 | + where you choose the secret, such as GitHub. It replaces the existing secret. Pass | | 4926 | + `previousSecretTtlSeconds` to keep accepting the old secret while you update the provider; without | | 4927 | + it, deliveries signed with the old secret fail verification straight away. Endpoints whose source | | 4928 | + verifies with a public key refuse this; set the provider's key with the set secret endpoint instead. | | 4929 | + requestBody: | | 4930 | + required: false | | 4931 | + content: | | 4932 | + application/json: | | 4933 | + schema: | | 4934 | + type: object | | 4935 | + properties: | | 4936 | + previousSecretTtlSeconds: | | 4937 | + $ref: "#/components/schemas/WebhookPreviousSecretTtlSeconds" | 4928 | 4938 | responses: | 4929 | 4939 | "200": | 4930 | 4940 | description: Successful request | @@ -4933,7 +4943,7 @@ paths: | 4933 | 4943 | schema: | 4934 | 4944 | $ref: "#/components/schemas/RotateWebhookEndpointSecretResponse" | 4935 | 4945 | "400": | 4936 | | - description: The endpoint's source verifies with a public key, so a secret can't be generated | | 4946 | + description: The endpoint's source verifies with a public key, so a secret can't be generated, or the body is invalid | 4937 | 4947 | content: | 4938 | 4948 | application/json: | 4939 | 4949 | schema: | @@ -4951,7 +4961,9 @@ paths: | 4951 | 4961 | label: Generate a signing secret | 4952 | 4962 | source: |- | 4953 | 4963 | curl -X POST "https://api.trigger.dev/api/v1/webhooks/endpoints/github/rotate-secret" \ | 4954 | | - -H "Authorization: Bearer $TRIGGER_SECRET_KEY" | | 4964 | + -H "Authorization: Bearer $TRIGGER_SECRET_KEY" \ | | 4965 | + -H "Content-Type: application/json" \ | | 4966 | + -d '{ "previousSecretTtlSeconds": 3600 }' | 4955 | 4967 | "/api/v1/webhooks/endpoints/{endpointId}/secret": | 4956 | 4968 | parameters: | 4957 | 4969 | - $ref: "#/components/parameters/webhookEndpointId" | @@ -4960,7 +4972,9 @@ paths: | 4960 | 4972 | summary: Set a webhook signing secret | 4961 | 4973 | description: >- | 4962 | 4974 | Store the signing secret or public key the provider issued for this endpoint, such as the `whsec_` | 4963 | | - secret Stripe returns when you create a webhook endpoint. It replaces any existing secret. | | 4975 | + secret Stripe returns when you create a webhook endpoint. It replaces the existing secret. Pass | | 4976 | + `previousSecretTtlSeconds` to keep accepting the old secret for a while, for example while the | | 4977 | + provider rolls its secret. | 4964 | 4978 | requestBody: | 4965 | 4979 | required: true | 4966 | 4980 | content: | @@ -4976,6 +4990,8 @@ paths: | 4976 | 4990 | maxLength: 4096 | 4977 | 4991 | description: The signing secret or public key. Leading and trailing whitespace is trimmed. | 4978 | 4992 | example: whsec_5f2b... | | 4993 | + previousSecretTtlSeconds: | | 4994 | + $ref: "#/components/schemas/WebhookPreviousSecretTtlSeconds" | 4979 | 4995 | responses: | 4980 | 4996 | "200": | 4981 | 4997 | description: Successful request | @@ -5862,6 +5878,11 @@ components: | 5862 | 5878 | type: string | 5863 | 5879 | description: The new signing secret. It is returned only in this response and can't be read again. | 5864 | 5880 | example: whsec_5f2b... | | 5881 | + previousSecretExpiresAt: | | 5882 | + type: string | | 5883 | + format: date-time | | 5884 | + description: When the replaced secret stops verifying deliveries. Absent when it was replaced immediately. | | 5885 | + example: "2026-10-21T12:00:00.000Z" | 5865 | 5886 | SetWebhookEndpointSecretResponse: | 5866 | 5887 | type: object | 5867 | 5888 | required: [id, secretSet] | @@ -5873,6 +5894,19 @@ components: | 5873 | 5894 | secretSet: | 5874 | 5895 | type: boolean | 5875 | 5896 | const: true | | 5897 | + previousSecretExpiresAt: | | 5898 | + type: string | | 5899 | + format: date-time | | 5900 | + description: When the replaced secret stops verifying deliveries. Absent when it was replaced immediately. | | 5901 | + example: "2026-10-21T12:00:00.000Z" | | 5902 | + WebhookPreviousSecretTtlSeconds: | | 5903 | + type: integer | | 5904 | + minimum: 0 | | 5905 | + maximum: 604800 | | 5906 | + description: >- | | 5907 | + How long, in seconds, the secret being replaced keeps verifying deliveries (at most 7 days). Omit it | | 5908 | + or pass 0 to replace the secret immediately, for example when it leaked. | | 5909 | + example: 3600 | 5876 | 5910 | WebhookDeliveryStatus: | 5877 | 5911 | type: string | 5878 | 5912 | enum: [pending, processing, succeeded, failed, filtered, unmatched] | |
Collapse file Copy file name to clipboardExpand all lines: docs/webhooks/connect.mdx +14-3Lines changed: 14 additions & 3 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -18,8 +18,8 @@ The prompt tells the agent to use the Trigger.dev MCP server (`npx trigger.dev@l | 18 | 18 | | --- | --- | | 19 | 19 | | `list_webhook_endpoints` | Lists the environment's endpoints, with their URL and whether a secret is set. | | 20 | 20 | | `get_webhook_endpoint_details` | Reads one endpoint: URL, subscribers and their filters, secret state, and the setup prompt. | | 21 | | -| `generate_webhook_secret` | Generates a signing secret and returns it once, for providers where you choose the secret. | | 22 | | -| `set_webhook_secret` | Stores a secret the provider issued. | | | 21 | +| `generate_webhook_secret` | Generates a signing secret and returns it once, for providers where you choose the secret. Optionally keeps accepting the old secret (`previousSecretTtlSeconds`). | | | 22 | +| `set_webhook_secret` | Stores a secret the provider issued. Optionally keeps accepting the old secret (`previousSecretTtlSeconds`). | | 23 | 23 |
| 24 | 24 | Without the MCP server, the same operations are available on the REST API with the environment's secret key: | 25 | 25 |
| @@ -74,7 +74,18 @@ The verify token answers the URL check. The signing secret verifies event delive | 74 | 74 |
| 75 | 75 | Choose **Rotate signing secret** to replace a provider-supplied secret. For a secret you generate, choose **Regenerate secret**, copy the new value, and update the provider's configuration. Use **Rotate public key** when replacing an asymmetric provider's key. | 76 | 76 |
| | 77 | +Both dialogs ask how long to keep accepting the old secret: **Stop immediately**, 1 hour, 24 hours (the default) or 7 days. Until then, deliveries signed with either secret verify, so you can update the provider without failing any. Choose **Stop immediately** if the old secret leaked. | | 78 | + | | 79 | +Through the MCP tools or the REST API, pass `previousSecretTtlSeconds` (at most 604800, 7 days). The response's `previousSecretExpiresAt` says when the old secret stops working. Without it, the old secret stops working straight away: | | 80 | + | | 81 | +```bash | | 82 | +curl -X POST "https://api.trigger.dev/api/v1/webhooks/endpoints/github/rotate-secret" \ | | 83 | + -H "Authorization: Bearer $TRIGGER_SECRET_KEY" \ | | 84 | + -H "Content-Type: application/json" \ | | 85 | + -d '{"previousSecretTtlSeconds": 3600}' | | 86 | +``` | | 87 | + | 77 | 88 | <Warning> | 78 | 89 | Trigger.dev stores credentials encrypted and doesn't display saved values. Copy a generated secret | 79 | | - before closing the dialog. Deliveries signed with the old secret fail verification after rotation. | | 90 | + before closing the dialog. | 80 | 91 | </Warning> |
|