Skip to content

Commit 5f54fb2

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(webapp,core): webhook response contracts, GET verification and verify tokens
## Summary Hosted webhook sources can now describe how the provider expects to be answered, and the ingress honors it. This is the server side for providers like Discord and WhatsApp, whose SDK sources follow in a later release. - A verifier artifact can declare a response contract as data: the status code for a handshake answer (`respondStatus`), and the codes returned for accepted deliveries and rejected signatures (`response.acceptedStatus`, `response.rejectedStatus`). The ingress and the dashboard's send action map every outcome through the same helper, so a source that needs 204 on success and 401 on a bad signature gets exactly that. - A verifier artifact can declare a GET verification flow (`getHandshake`) for providers that confirm a callback URL with a challenge, such as Meta's `hub.challenge`. The ingress answers GET on the endpoint URL against a dedicated verify token, which you generate and reveal from the endpoint's Connect panel. The token is its own credential, separate from the signing secret. - HMAC verifiers can read the signed timestamp from a body field. The Linear provider uses it for a 60 second replay window on `webhookTimestamp`; deliveries are deduplicated on the signed request itself, so a replay with a different unsigned delivery header is still recognised as a duplicate. - The dashboard's test-send re-signs a recorded sample with the current timestamp, so sources with a body-timestamp replay window accept it. - An admin API action bootstraps delivery partitions in the configured webhook database before enablement. It is safe to repeat and preserves existing partitions. Bootstrap and daily maintenance can use `WEBHOOK_DATABASE_DIRECT_URL` with separate owner credentials while application queries use `WEBHOOK_DATABASE_URL`. When the direct URL is unset, partition operations reuse the webhook writer. - The index worker protocol gains a message for duplicate webhook ids, so the CLI can report which files define the same id. The CLI side of this ships with the SDK release that adds `webhook()`. Mono-RevId: 54862ef81ec5adc311aa42783020fedc4b8c3ba9
1 parent dbe29cc commit 5f54fb2

42 files changed

Lines changed: 2065 additions & 139 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
Webhook verifier artifacts can now declare the provider's response contract as data: a handshake `respondStatus`, the status codes returned for accepted deliveries and rejected signatures, and a GET verification flow (`getHandshake`) for providers that confirm a callback URL with a challenge. HMAC verifiers can read the timestamp from a body field, which the Linear provider config uses for its replay window, and the dashboard's test-send re-signs a recorded sample as of now so it passes that window.

‎apps/webapp/app/components/webhookConsole/WebhookComposer.tsx‎

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import { Select, SelectItem } from "~/components/primitives/Select";
2020
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
2121
import { cn } from "~/utils/cn";
22+
import type { WebhookHandshakeConfig } from "@trigger.dev/core/v3";
2223
import type { WebhookSendResult } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.endpoints.$endpointParam.send";
2324
import { AIPayloadTabContent } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent";
2425
import { ReplaySourcePicker } from "./ReplaySourcePicker";
@@ -33,7 +34,7 @@ type WebhookComposerEndpoint = {
3334
ingressUrl: string;
3435
scheme: "hmac" | "shared-secret" | "url-secret" | "asymmetric";
3536
hasSigningSecret: boolean;
36-
handshake: { matchPath: string; matchValue: string; respondPath: string } | null;
37+
handshake: WebhookHandshakeConfig | null;
3738
};
3839

3940
export type WebhookComposerProps = {
@@ -369,7 +370,7 @@ export function WebhookComposer({
369370
variant="tertiary/small"
370371
onClick={() => sendHandshake()}
371372
disabled={isSending || !endpoint}
372-
tooltip="Send a signed handshake and assert the endpoint echoes the challenge"
373+
tooltip="Send a signed handshake and check the endpoint answers it (echoing the challenge, or a bodiless status)"
373374
>
374375
Send handshake
375376
</Button>
@@ -477,12 +478,12 @@ function setPath(target: Record<string, unknown>, path: string, value: unknown)
477478
}
478479

479480
function buildHandshakeBody(
480-
handshake: { matchPath: string; matchValue: string; respondPath: string },
481+
handshake: WebhookHandshakeConfig,
481482
challenge: string
482483
): Record<string, unknown> {
483484
const body: Record<string, unknown> = {};
484485
setPath(body, handshake.matchPath, handshake.matchValue);
485-
setPath(body, handshake.respondPath, challenge);
486+
if (handshake.respondPath) setPath(body, handshake.respondPath, challenge);
486487
return body;
487488
}
488489

@@ -542,7 +543,8 @@ function ResultStrip({
542543
const status = result.success ? result.httpStatus : undefined;
543544
const handshake = result.success && result.handshake;
544545
const deduplicated = result.success && result.deduplicated;
545-
const ok = result.success && status === 200 && !deduplicated;
546+
const ok =
547+
result.success && status !== undefined && status >= 200 && status < 300 && !deduplicated;
546548
return (
547549
<div className="flex flex-col gap-2">
548550
<div className="flex items-center gap-2">
@@ -562,7 +564,7 @@ function ResultStrip({
562564
? "Handshake"
563565
: deduplicated
564566
? "Deduplicated"
565-
: result.success
567+
: result.httpStatus !== undefined
566568
? `HTTP ${result.httpStatus}`
567569
: "Failed"}
568570
</span>
@@ -584,12 +586,15 @@ function ResultStrip({
584586
Identical payload was deduplicated to the original delivery. Vary it to send a new one.
585587
</Hint>
586588
) : null}
587-
<CodeBlock
588-
code={result.success ? result.responseBody : result.error}
589-
language="json"
590-
showLineNumbers={false}
591-
maxLines={8}
592-
/>
589+
{!result.success ? <Hint>{result.error}</Hint> : null}
590+
{result.responseBody !== undefined ? (
591+
<CodeBlock
592+
code={result.responseBody}
593+
language="json"
594+
showLineNumbers={false}
595+
maxLines={8}
596+
/>
597+
) : null}
593598
</div>
594599
);
595600
}

‎apps/webapp/app/db.server.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ type DatasourceLabel =
172172
| "run-ops-writer"
173173
| "run-ops-replica"
174174
| "webhook-writer"
175+
| "webhook-partitions"
175176
| "webhook-replica";
176177

177178
function tagDatasource<T extends PrismaClient>(datasource: DatasourceLabel, client: T): T {
@@ -256,6 +257,26 @@ export const webhookPrisma: WebhookDatabase = singleton("webhookPrisma", () => {
256257
);
257258
});
258259

260+
/**
261+
* Partition DDL can use a direct connection and an owner role while delivery queries use a
262+
* pooled app role. Unset reuses the writer, preserving single-connection installations.
263+
*/
264+
export const webhookPartitionPrisma: WebhookDatabase = singleton("webhookPartitionPrisma", () => {
265+
if (!env.WEBHOOK_DATABASE_DIRECT_URL) {
266+
return webhookPrisma;
267+
}
268+
return captureInfrastructureErrors(
269+
tagDatasource(
270+
"webhook-partitions",
271+
buildWriterClient({
272+
url: env.WEBHOOK_DATABASE_DIRECT_URL,
273+
clientType: "webhook-partitions",
274+
connectionLimit: 1,
275+
})
276+
)
277+
);
278+
});
279+
259280
/**
260281
* Webhook reader chain: an explicit webhook replica, else the webhook writer once split (no
261282
* separate replica yet), else the main $replica when the feature is not split.

‎apps/webapp/app/env.server.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,8 @@ const EnvironmentSchema = z
376376
// Webhook feature data-plane DB (WebhookEndpoint + WebhookDelivery). Unset -> the webhook
377377
// clients reuse the main prisma / $replica, so this is connection-neutral until you split.
378378
WEBHOOK_DATABASE_URL: z.string().optional(),
379+
// Direct connection with ownership privileges for bootstrap and partition maintenance.
380+
WEBHOOK_DATABASE_DIRECT_URL: z.string().optional(),
379381
WEBHOOK_DATABASE_READ_REPLICA_URL: z.string().optional(),
380382
WEBHOOK_DATABASE_CONNECTION_LIMIT: z.coerce.number().int().optional(),
381383
SESSION_SECRET: z.string().min(1).refine(isNotInsecureSecret, INSECURE_SECRET_MESSAGE),

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

Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { docsPath, EnvironmentParamSchema, v3WebhookTaskPath } from "~/utils/pat
5151
import { parseFiniteInt } from "~/utils/searchParams";
5252
import { FEATURE_FLAG } from "~/v3/featureFlags";
5353
import { flag } from "~/v3/featureFlags.server";
54+
import { webhookVerifyTokenKey } from "~/v3/webhookEngine.server";
5455

5556
const EndpointParamSchema = EnvironmentParamSchema.extend({
5657
endpointParam: z.string(),
@@ -112,6 +113,19 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
112113
const routing = WebhookRoutingTarget.safeParse(endpoint.routingTarget);
113114
const verifier = WebhookVerifierArtifact.safeParse(endpoint.verifierArtifact);
114115

116+
const getHandshake =
117+
verifier.success && "getHandshake" in verifier.data ? verifier.data.getHandshake : undefined;
118+
const hasVerifyToken = getHandshake
119+
? Boolean(
120+
(
121+
await getSecretStore("DATABASE", { prismaClient: prisma }).getSecret(
122+
VerifyTokenSchema,
123+
webhookVerifyTokenKey(endpoint.id)
124+
)
125+
)?.token
126+
)
127+
: false;
128+
115129
const url = new URL(request.url);
116130
const periodParam = url.searchParams.get("period") ?? undefined;
117131
const from = parseFiniteInt(url.searchParams.get("from"));
@@ -142,6 +156,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
142156
ingestUrl,
143157
routing: routing.success ? routing.data : null,
144158
verifier: verifier.success ? verifier.data : null,
159+
hasVerifyToken,
145160
deliveriesList,
146161
});
147162
};
@@ -151,6 +166,8 @@ const SetSecretSchema = z.object({
151166
secret: z.string().trim().min(1, "A signing secret is required"),
152167
});
153168

169+
const VerifyTokenSchema = z.object({ token: z.string() });
170+
154171
export const action = async ({ request, params }: ActionFunctionArgs) => {
155172
const { project, environment, endpointParam } = await requireWebhookAccess(request, params);
156173

@@ -197,6 +214,21 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
197214
return { success: true as const, generatedSecret: secret };
198215
}
199216

217+
if (intent === "generate-verify-token") {
218+
const verifier = WebhookVerifierArtifact.safeParse(endpoint.verifierArtifact);
219+
const getHandshake =
220+
verifier.success && "getHandshake" in verifier.data ? verifier.data.getHandshake : undefined;
221+
if (!getHandshake) {
222+
return {
223+
success: false as const,
224+
error: "This endpoint's source does not verify its URL with a GET request.",
225+
};
226+
}
227+
const token = `whvt_${randomBytes(24).toString("hex")}`;
228+
await secretStore.setSecret(webhookVerifyTokenKey(endpoint.id), { token });
229+
return { success: true as const, generatedVerifyToken: token };
230+
}
231+
200232
// Set/Rotate (paste a provider-supplied secret).
201233
const submission = SetSecretSchema.safeParse(Object.fromEntries(formData));
202234
if (!submission.success) {
@@ -212,7 +244,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
212244
};
213245

214246
export default function Page() {
215-
const { endpoint, ingestUrl, routing, verifier, deliveriesList } =
247+
const { endpoint, ingestUrl, routing, verifier, hasVerifyToken, deliveriesList } =
216248
useTypedLoaderData<typeof loader>();
217249
const organization = useOrganization();
218250
const project = useProject();
@@ -297,6 +329,7 @@ export default function Page() {
297329
ingestUrl={ingestUrl}
298330
routing={routing}
299331
verifier={verifier}
332+
hasVerifyToken={hasVerifyToken}
300333
handlerPath={handlerPath}
301334
/>
302335
</ResizablePanel>
@@ -313,10 +346,12 @@ function EndpointSidebar({
313346
ingestUrl,
314347
routing,
315348
verifier,
349+
hasVerifyToken,
316350
handlerPath,
317351
}: {
318352
endpoint: WebhookEndpointDetail;
319353
ingestUrl: string;
354+
hasVerifyToken: boolean;
320355
routing: LoaderData["routing"];
321356
verifier: LoaderData["verifier"];
322357
handlerPath: string;
@@ -336,6 +371,8 @@ function EndpointSidebar({
336371
const canGenerate =
337372
scheme !== "asymmetric" &&
338373
(endpoint.secretProvisioning === "integrator" || endpoint.secretProvisioning === "either");
374+
const getHandshake =
375+
verifier && verifier.kind !== "bundle" ? (verifier.getHandshake ?? undefined) : undefined;
339376

340377
return (
341378
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden bg-background-bright">
@@ -381,6 +418,30 @@ function EndpointSidebar({
381418
</div>
382419
</Property.Value>
383420
</Property.Item>
421+
{getHandshake ? (
422+
<Property.Item>
423+
<Property.Label>Verify token</Property.Label>
424+
<Property.Value>
425+
<div className="flex flex-col items-start gap-1.5">
426+
{hasVerifyToken ? (
427+
<span className="flex items-center gap-1.5">
428+
<span className="size-2 rounded-full bg-success" />
429+
<span>Set</span>
430+
</span>
431+
) : (
432+
<span className="text-warning">
433+
Not set, the provider cannot verify this URL yet
434+
</span>
435+
)}
436+
<GenerateVerifyTokenDialog hasVerifyToken={hasVerifyToken} />
437+
<Hint>
438+
The provider sends a GET with <code>{getHandshake.tokenParam}</code> when you
439+
save the URL; it must carry this token.
440+
</Hint>
441+
</div>
442+
</Property.Value>
443+
</Property.Item>
444+
) : null}
384445
</Property.Table>
385446
<ProviderSetup verifier={verifier} source={endpoint.source} />
386447
</section>
@@ -732,6 +793,109 @@ function GenerateSecretDialog({ hasSigningSecret }: { hasSigningSecret: boolean
732793
);
733794
}
734795

796+
/**
797+
* The GET verification token (Meta's `hub.verify_token`) is its own credential, separate from the
798+
* signing secret: minted server-side, stored encrypted, revealed once for the provider's Verify
799+
* Token field. Regenerating replaces it and the URL must be re-verified in the provider.
800+
*/
801+
function GenerateVerifyTokenDialog({ hasVerifyToken }: { hasVerifyToken: boolean }) {
802+
const fetcher = useFetcher<typeof action>();
803+
const [open, setOpen] = useState(false);
804+
const [dismissedToken, setDismissedToken] = useState<string | undefined>(undefined);
805+
const [attempted, setAttempted] = useState(false);
806+
const isSubmitting = fetcher.state !== "idle";
807+
808+
/**
809+
* The fetcher keeps its last action data for the life of the component, so closing the dialog
810+
* remembers which token was already revealed. A reopen then starts on the form again, and only a
811+
* newly generated token (which never repeats) is shown.
812+
*/
813+
const latestToken =
814+
fetcher.data && "generatedVerifyToken" in fetcher.data
815+
? fetcher.data.generatedVerifyToken
816+
: undefined;
817+
const generated =
818+
latestToken !== undefined && latestToken !== dismissedToken ? latestToken : undefined;
819+
820+
const onOpenChange = (next: boolean) => {
821+
setOpen(next);
822+
if (!next) {
823+
setDismissedToken(latestToken);
824+
setAttempted(false);
825+
}
826+
};
827+
const failure =
828+
attempted && fetcher.state === "idle" && fetcher.data && !fetcher.data.success
829+
? fetcher.data
830+
: undefined;
831+
832+
return (
833+
<Dialog open={open} onOpenChange={onOpenChange}>
834+
<DialogTrigger asChild>
835+
<Button variant="secondary/small" LeadingIcon={SparklesIcon}>
836+
{hasVerifyToken ? "Regenerate verify token" : "Generate verify token"}
837+
</Button>
838+
</DialogTrigger>
839+
<DialogContent>
840+
<DialogHeader>
841+
{hasVerifyToken ? "Regenerate verify token" : "Generate verify token"}
842+
</DialogHeader>
843+
{generated ? (
844+
<div className="flex flex-col gap-3 pt-2">
845+
<Paragraph variant="small" className="text-warning">
846+
Copy this now. It won't be shown again.
847+
</Paragraph>
848+
<ClipboardField value={generated} variant="secondary/medium" />
849+
<Hint>
850+
Paste this into the provider's Verify Token field when you save the webhook URL. It is
851+
separate from the signing secret.
852+
</Hint>
853+
<div className="flex justify-end">
854+
<Button type="button" variant="primary/small" onClick={() => onOpenChange(false)}>
855+
Done
856+
</Button>
857+
</div>
858+
</div>
859+
) : (
860+
<fetcher.Form
861+
method="post"
862+
className="flex flex-col gap-3 pt-2"
863+
onSubmit={() => setAttempted(true)}
864+
>
865+
<input type="hidden" name="intent" value="generate-verify-token" />
866+
<Paragraph variant="small" className="text-text-dimmed">
867+
The provider verifies this URL with a GET request carrying a verify token. Trigger.dev
868+
generates the token, stores it encrypted, and shows it once so you can paste it into
869+
the provider.
870+
{hasVerifyToken
871+
? " Regenerating replaces the current token; re-verify the URL in the provider afterwards."
872+
: ""}
873+
</Paragraph>
874+
{failure ? (
875+
<Paragraph variant="small" className="text-error">
876+
{failure.error}
877+
</Paragraph>
878+
) : null}
879+
<div className="flex justify-end gap-2">
880+
<Button
881+
type="button"
882+
variant="tertiary/small"
883+
onClick={() => onOpenChange(false)}
884+
disabled={isSubmitting}
885+
>
886+
Cancel
887+
</Button>
888+
<Button type="submit" variant="primary/small" disabled={isSubmitting}>
889+
{isSubmitting ? "Generating…" : "Generate verify token"}
890+
</Button>
891+
</div>
892+
</fetcher.Form>
893+
)}
894+
</DialogContent>
895+
</Dialog>
896+
);
897+
}
898+
735899
function TableLoading() {
736900
return (
737901
<div className="flex h-full items-center justify-center">

0 commit comments

Comments
 (0)