Skip to content

Commit 98adb66

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(core,cli,webapp): previousSecretTtlSeconds in the API client and MCP tools
Mono-RevId: 856c777bbc374ed4fd41275c56b00e20c5a6ea8d
1 parent 51b39fb commit 98adb66

14 files changed

Lines changed: 143 additions & 54 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
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.

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.endpoints.$endpointParam/route.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import {
88
} from "@remix-run/react";
99
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
1010
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";
1216
import type { WebhookValueSource } from "@trigger.dev/core/v3";
1317
import { type MutableRefObject, Suspense, useEffect, useRef, useState } from "react";
1418
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
@@ -69,7 +73,6 @@ import { docsPath, EnvironmentParamSchema, v3WebhooksPath } from "~/utils/pathBu
6973
import { throwPermissionDenied } from "~/utils/permissionDenied";
7074
import { parseFiniteInt } from "~/utils/searchParams";
7175
import { webhookEngine, webhookVerifyTokenKey } from "~/v3/webhookEngine.server";
72-
import { PreviousSecretTtlSeconds } from "~/v3/webhookSigningSecret";
7376
import {
7477
generateWebhookSigningSecret,
7578
storeWebhookSigningSecret,
@@ -212,7 +215,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
212215
};
213216

214217
const PreviousSecretTtlFormField = z.object({
215-
previousSecretTtlSeconds: z.coerce.number().pipe(PreviousSecretTtlSeconds).optional(),
218+
previousSecretTtlSeconds: z.coerce.number().pipe(WebhookPreviousSecretTtlSeconds).optional(),
216219
});
217220

218221
const SetSecretSchema = PreviousSecretTtlFormField.extend({

‎apps/webapp/app/routes/api.v1.webhooks.endpoints.$endpointId.rotate-secret.ts‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,14 @@
11
import { json } from "@remix-run/server-runtime";
2+
import { RotateWebhookEndpointSecretRequestBody } from "@trigger.dev/core/v3";
23
import { z } from "zod";
34
import { webhookPrisma } from "~/db.server";
45
import { webhookEndpointLookup } from "~/v3/webhookEndpointLookup";
56
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
67
import { safeJsonParse } from "~/utils/json";
7-
import { PreviousSecretTtlSeconds } from "~/v3/webhookSigningSecret";
88
import { generateWebhookSigningSecret } from "~/v3/webhookSigningSecret.server";
99

1010
const ParamsSchema = z.object({ endpointId: z.string() });
1111

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-
1712
// POST /api/v1/webhooks/endpoints/:endpointId/rotate-secret — mint a new signing secret and return
1813
// it ONCE. Only for schemes we generate (hmac / shared-secret); asymmetric endpoints set a public key.
1914
const { action, loader } = createActionApiRoute(
@@ -26,7 +21,9 @@ const { action, loader } = createActionApiRoute(
2621
},
2722
async ({ params, authentication, request }) => {
2823
const rawBody = await request.text();
29-
const body = RotateSecretRequestBody.safeParse(rawBody ? safeJsonParse(rawBody) : {});
24+
const body = RotateWebhookEndpointSecretRequestBody.safeParse(
25+
rawBody ? safeJsonParse(rawBody) : {}
26+
);
3027
if (!body.success) {
3128
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
3229
}

‎apps/webapp/app/routes/api.v1.webhooks.endpoints.$endpointId.secret.ts‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,14 @@ import { z } from "zod";
44
import { webhookPrisma } from "~/db.server";
55
import { webhookEndpointLookup } from "~/v3/webhookEndpointLookup";
66
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
7-
import { PreviousSecretTtlSeconds } from "~/v3/webhookSigningSecret";
87
import { storeWebhookSigningSecret } from "~/v3/webhookSigningSecret.server";
98

109
const ParamsSchema = z.object({ endpointId: z.string() });
1110

12-
const SetSecretRequestBody = SetWebhookEndpointSecretRequestBody.extend({
13-
previousSecretTtlSeconds: PreviousSecretTtlSeconds.optional(),
14-
});
15-
1611
const { action, loader } = createActionApiRoute(
1712
{
1813
params: ParamsSchema,
19-
body: SetSecretRequestBody,
14+
body: SetWebhookEndpointSecretRequestBody,
2015
method: "PUT",
2116
allowJWT: true,
2217
corsStrategy: "all",

‎apps/webapp/app/v3/webhookSetupPrompt.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ export function renderWebhookSetupPrompt(
152152
function secretStep(endpoint: WebhookSetupPromptEndpoint, asymmetric: boolean): string {
153153
const source = endpoint.source;
154154
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.`
156156
: "";
157157

158158
if (asymmetric) {

‎apps/webapp/app/v3/webhookSigningSecret.ts‎

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,5 @@
11
import { z } from "zod";
22

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-
123
/**
134
* An endpoint's signing secret as stored in the SecretStore. `previous` is the secret it replaced,
145
* still accepted until `expiresAt` so a rotation doesn't fail deliveries the provider signs with it.

‎apps/webapp/test/webhookSetupPrompt.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ describe("renderWebhookSetupPrompt", () => {
8484

8585
expect(prompt).toContain("Public key: set");
8686
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`");
8889
});
8990

9091
it("asks which events to send when nothing subscribes yet", () => {

‎apps/webapp/test/webhookSigningSecret.test.ts‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import { describe, expect, it } from "vitest";
22
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";
87

98
const now = new Date("2026-10-20T12:00:00.000Z");
109
const later = (seconds: number) => new Date(now.getTime() + seconds * 1000);

‎docs/v3-openapi.yaml‎

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4922,9 +4922,19 @@ paths:
49224922
summary: Generate a webhook signing secret
49234923
description: >-
49244924
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"
49284938
responses:
49294939
"200":
49304940
description: Successful request
@@ -4933,7 +4943,7 @@ paths:
49334943
schema:
49344944
$ref: "#/components/schemas/RotateWebhookEndpointSecretResponse"
49354945
"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
49374947
content:
49384948
application/json:
49394949
schema:
@@ -4951,7 +4961,9 @@ paths:
49514961
label: Generate a signing secret
49524962
source: |-
49534963
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 }'
49554967
"/api/v1/webhooks/endpoints/{endpointId}/secret":
49564968
parameters:
49574969
- $ref: "#/components/parameters/webhookEndpointId"
@@ -4960,7 +4972,9 @@ paths:
49604972
summary: Set a webhook signing secret
49614973
description: >-
49624974
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.
49644978
requestBody:
49654979
required: true
49664980
content:
@@ -4976,6 +4990,8 @@ paths:
49764990
maxLength: 4096
49774991
description: The signing secret or public key. Leading and trailing whitespace is trimmed.
49784992
example: whsec_5f2b...
4993+
previousSecretTtlSeconds:
4994+
$ref: "#/components/schemas/WebhookPreviousSecretTtlSeconds"
49794995
responses:
49804996
"200":
49814997
description: Successful request
@@ -5862,6 +5878,11 @@ components:
58625878
type: string
58635879
description: The new signing secret. It is returned only in this response and can't be read again.
58645880
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"
58655886
SetWebhookEndpointSecretResponse:
58665887
type: object
58675888
required: [id, secretSet]
@@ -5873,6 +5894,19 @@ components:
58735894
secretSet:
58745895
type: boolean
58755896
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
58765910
WebhookDeliveryStatus:
58775911
type: string
58785912
enum: [pending, processing, succeeded, failed, filtered, unmatched]

‎docs/webhooks/connect.mdx‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ The prompt tells the agent to use the Trigger.dev MCP server (`npx trigger.dev@l
1818
| --- | --- |
1919
| `list_webhook_endpoints` | Lists the environment's endpoints, with their URL and whether a secret is set. |
2020
| `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`). |
2323

2424
Without the MCP server, the same operations are available on the REST API with the environment's secret key:
2525

@@ -74,7 +74,18 @@ The verify token answers the URL check. The signing secret verifies event delive
7474

7575
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.
7676

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+
7788
<Warning>
7889
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.
8091
</Warning>

0 commit comments

Comments
 (0)