Skip to content

Commit d5aec6b

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(webhooks): accept form-encoded webhook bodies and an empty 200 acknowledgement
Hosted webhook endpoints now accept `application/x-www-form-urlencoded` bodies. A verified form body that isn't JSON is routed as an object of its fields, so Slack slash commands, Twilio and other form-posting providers reach `webhook()` subscribers, agent session routes and waiters like any JSON event: ```ts export const miphy = webhook({ id: "miphy-command", endpoint: slackCommands, filter: "event.command == '/miphy'", onEvent: async ({ event }) => { // event = { command: "/miphy", text: "a raccoon dancing", user_id: "U...", response_url: "...", ... } }, }); ``` A repeated field becomes an array. A source's `formPayload` field (Slack interactivity's `payload=<json>`) still takes precedence, and verification is unchanged because it runs over the raw bytes first. A source's response contract can also set `acceptedBody: "empty"` to acknowledge with an empty 200 instead of the JSON receipt, for providers such as Slack that show a non-empty reply to the user. Mono-RevId: 8ab4361a8ae1e4ae19c029d41929ab6b26a03fb8
1 parent a3bd4e2 commit d5aec6b

15 files changed

Lines changed: 465 additions & 34 deletions

File tree

‎apps/webapp/app/v3/webhookIngressResponse.server.ts‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { IngestResult } from "@internal/webhook-engine";
2+
import type { WebhookResponseConfig } from "@trigger.dev/core/v3";
23

34
/** What the public ingress answers a provider with for one ingest outcome. */
45
export type WebhookHttpResponse = {
@@ -12,15 +13,15 @@ export type WebhookHttpResponse = {
1213
/**
1314
* Map an ingest outcome to the HTTP answer the provider sees. Defaults are 200 JSON on success and
1415
* 400 on missing credentials or a bad signature; an endpoint whose verifier artifact declares a
15-
* response contract (`acceptedStatus`, `rejectedStatus`) or handshake status gets those instead. The dashboard
16+
* response contract (`acceptedStatus`, `acceptedBody`, `rejectedStatus`) or handshake status gets those instead. The dashboard
1617
* test-send reports the same status and body inside its own result envelope.
1718
*/
1819
export function webhookHttpResponseFor(result: IngestResult): WebhookHttpResponse {
1920
switch (result.outcome) {
2021
case "accepted":
21-
return acceptedResponse(result.response?.acceptedStatus ?? 200, result.deliveryFriendlyId);
22+
return acceptedResponse(result.response, result.deliveryFriendlyId);
2223
case "duplicate":
23-
return acceptedResponse(result.response?.acceptedStatus ?? 200, result.deliveryId);
24+
return acceptedResponse(result.response, result.deliveryId);
2425
case "handshake":
2526
return {
2627
status: result.status,
@@ -57,10 +58,16 @@ export function webhookHttpResponseFor(result: IngestResult): WebhookHttpRespons
5758
}
5859
}
5960

60-
function acceptedResponse(status: 200 | 202 | 204, deliveryId: string | undefined) {
61+
function acceptedResponse(
62+
contract: WebhookResponseConfig | undefined,
63+
deliveryId: string | undefined
64+
): WebhookHttpResponse {
65+
const status = contract?.acceptedStatus ?? 200;
66+
if (status === 204) return { status, body: null, contentType: "application/json" };
67+
if (contract?.acceptedBody === "empty") return { status, body: "", contentType: "text/plain" };
6168
return {
6269
status,
63-
body: status === 204 ? null : JSON.stringify({ received: true, deliveryId }),
70+
body: JSON.stringify({ received: true, deliveryId }),
6471
contentType: "application/json",
6572
};
6673
}

‎apps/webapp/test/webhookIngressResponse.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,27 @@ describe("webhook ingress HTTP contract", () => {
3434
expect(await res.text()).toBe("");
3535
});
3636

37+
it("acknowledges accepted and duplicate deliveries with an empty 200 when the contract asks for no body", async () => {
38+
for (const result of [
39+
{
40+
outcome: "accepted" as const,
41+
deliveryId: "d_1",
42+
deliveryFriendlyId: "whdel_1",
43+
response: { acceptedBody: "empty" as const },
44+
},
45+
{
46+
outcome: "duplicate" as const,
47+
deliveryId: "whdel_1",
48+
response: { acceptedBody: "empty" as const },
49+
},
50+
]) {
51+
const res = toWebhookHttpResponse(webhookHttpResponseFor(result));
52+
expect(res.status).toBe(200);
53+
expect(res.headers.get("content-type")).toBe("text/plain");
54+
expect(await res.text()).toBe("");
55+
}
56+
});
57+
3758
it("keeps the default 200 JSON acknowledgement when no contract is declared", async () => {
3859
const res = toWebhookHttpResponse(
3960
webhookHttpResponseFor({

‎internal-packages/webhook-engine/src/engine/index.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { type CachedEndpoint, TtlCache } from "./cache.js";
3131
import { evaluateFilter, parseFilter } from "./filter/index.js";
3232
import { verify } from "./verification/index.js";
3333
import { sha256Hex } from "./verification/util.js";
34-
import { deriveIdempotencyKey, tryParseJson } from "./verification/derive.js";
34+
import { deriveIdempotencyKey, parseEventBody } from "./verification/derive.js";
3535
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
3636
import type {
3737
CancelWebhookWaiterResult,
@@ -616,8 +616,8 @@ export class WebhookEngine {
616616
/**
617617
* Inject a delivery WITHOUT signature verification, then run the same filter + record + route path
618618
* as ingest(). This is the test-console "simulate" mode for endpoints we cannot sign for
619-
* (asymmetric public-key schemes; url-secret path placement). The body must be JSON. Everything
620-
* downstream (filter, startOn, routing, run/session) runs for real.
619+
* (asymmetric public-key schemes; url-secret path placement). The body must be JSON, or a form
620+
* sent with a form-encoded content type. Everything downstream (filter, startOn, routing, run/session) runs for real.
621621
*/
622622
async simulateInject(input: IngestInput): Promise<IngestResult> {
623623
this.#assertEnabled();
@@ -635,7 +635,7 @@ export class WebhookEngine {
635635
return { outcome: "verification_failed", error: "corrupt verifier artifact" };
636636
}
637637

638-
const parsed = tryParseJson(input.rawBytes);
638+
const parsed = parseEventBody(input.rawBytes, { headers: input.headers });
639639
if (parsed.error || parsed.parsedEvent === undefined) {
640640
return {
641641
outcome: "verification_failed",

‎internal-packages/webhook-engine/src/engine/ingest.test.ts‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,109 @@ containerTestWithIsolatedRedisNoClickhouse(
865865
}
866866
);
867867

868+
containerTestWithIsolatedRedisNoClickhouse(
869+
"a form-encoded body routes as the decoded form to a filtered task and a session keyed on its fields",
870+
async ({ prisma, redisOptions }) => {
871+
const filter = "event.command == '/miphy'";
872+
const endpoint = await prisma.webhookEndpoint.create({
873+
data: {
874+
friendlyId: WebhookEndpointId.generate().friendlyId,
875+
opaqueId: `op_${randomBytes(12).toString("hex")}`,
876+
organizationId: "org_test",
877+
projectId: "proj_test",
878+
runtimeEnvironmentId: "env_test",
879+
environmentType: "PRODUCTION",
880+
source: "slack",
881+
declaredId: "slack-commands",
882+
routingTargets: [
883+
{
884+
type: "task",
885+
id: "miphy-command",
886+
taskId: "miphy-command",
887+
filter,
888+
filterAst: parseFilter(filter) as unknown as Prisma.InputJsonValue,
889+
filterAstVersion: 1,
890+
},
891+
{
892+
type: "task",
893+
id: "other-command",
894+
taskId: "other-command",
895+
filter: "event.command == '/other'",
896+
filterAst: parseFilter("event.command == '/other'") as unknown as Prisma.InputJsonValue,
897+
filterAstVersion: 1,
898+
},
899+
{
900+
type: "session",
901+
id: "agent-x:commands",
902+
taskIdentifier: "agent-x",
903+
keyTemplate: "{body.channel_id}:{body.user_id}",
904+
actionType: "slash.command",
905+
deliverAs: "action",
906+
},
907+
],
908+
verifierArtifact: { kind: "config", config: VERIFIER_CONFIG },
909+
signingSecretKey: SECRET_KEY,
910+
status: "ACTIVE",
911+
},
912+
});
913+
const { triggerTask, calls } = makeTriggerTaskStub();
914+
const sessions = makeDeliverToSessionStub();
915+
const engine = buildEngine(prisma, redisOptions, triggerTask, {
916+
deliverToSession: sessions.deliverToSession,
917+
});
918+
919+
try {
920+
const body =
921+
"team_id=T1&channel_id=C1&user_id=U1&command=%2Fmiphy&text=a+raccoon+dancing&trigger_id=13.4";
922+
const t = Math.floor(Date.now() / 1000);
923+
const sig = createHmac("sha256", SECRET).update(`${t}.${body}`).digest("hex");
924+
const result = await engine.ingest({
925+
opaqueId: endpoint.opaqueId,
926+
rawBytes: new TextEncoder().encode(body),
927+
headers: {
928+
"stripe-signature": `t=${t},v1=${sig}`,
929+
"content-type": "application/x-www-form-urlencoded",
930+
},
931+
url: `https://api.example.com/webhooks/v1/ingest/${endpoint.opaqueId}`,
932+
});
933+
if (result.outcome !== "accepted")
934+
throw new Error(`expected accepted, got ${result.outcome}`);
935+
936+
await waitFor(async () => {
937+
const d = await prisma.webhookDelivery.findFirst({ where: { id: result.deliveryId } });
938+
return d?.status === "SUCCEEDED";
939+
});
940+
941+
const event = {
942+
team_id: "T1",
943+
channel_id: "C1",
944+
user_id: "U1",
945+
command: "/miphy",
946+
text: "a raccoon dancing",
947+
trigger_id: "13.4",
948+
};
949+
const delivery = await prisma.webhookDelivery.findFirst({ where: { id: result.deliveryId } });
950+
expect(delivery?.errorMessage).toBeNull();
951+
expect(delivery?.parsedEvent).toEqual(event);
952+
const targets = (delivery?.targets ?? []) as Array<{ id: string; status: string }>;
953+
expect(targets.map((t) => [t.id, t.status])).toEqual([
954+
["miphy-command", "SUCCEEDED"],
955+
["other-command", "FILTERED"],
956+
["agent-x:commands", "SUCCEEDED"],
957+
]);
958+
959+
expect(calls).toHaveLength(1);
960+
expect(calls[0]?.taskId).toBe("miphy-command");
961+
expect(calls[0]?.payload).toEqual(event);
962+
expect(sessions.calls).toHaveLength(1);
963+
expect(sessions.calls[0]?.externalId).toBe("C1:U1");
964+
expect(sessions.calls[0]?.event).toEqual(event);
965+
} finally {
966+
await engine.quit();
967+
}
968+
}
969+
);
970+
868971
containerTestWithIsolatedRedisNoClickhouse(
869972
"session delivery with an unresolvable key is FAILED and never calls the port",
870973
async ({ prisma, redisOptions }) => {

‎internal-packages/webhook-engine/src/engine/signing/index.ts‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {
66
WebhookVerifierConfig,
77
} from "@trigger.dev/core/v3";
88
import { randomUUID } from "node:crypto";
9-
import { readPath, tryParseJson } from "../verification/derive.js";
9+
import { isFormEncoded, parseEventBody, readPath } from "../verification/derive.js";
1010
import { buildSigningBytes, deriveHmacKey, hmacDigest } from "../verification/util.js";
1111

1212
export type SignResult =
@@ -72,7 +72,9 @@ function readStringSource(
7272
case "constant":
7373
return source.value;
7474
case "body": {
75-
const parsed = tryParseJson(args.rawBody).parsedEvent as Record<string, unknown> | undefined;
75+
const parsed = parseEventBody(args.rawBody, { headers: headersLc }).parsedEvent as
76+
| Record<string, unknown>
77+
| undefined;
7678
const v = readPath(parsed, source.path);
7779
return typeof v === "string" || typeof v === "number" ? String(v) : undefined;
7880
}
@@ -98,7 +100,7 @@ function signHmac(cfg: WebhookHmacConfig, signArgs: SignArgs): SignResult {
98100
headersLc[src.name.toLowerCase()] = timestampValue;
99101
}
100102
} else if (src.from === "body" && signArgs.refreshBodyTimestamp) {
101-
const refreshed = withBodyTimestamp(signArgs.rawBody, src.path, nowInUnit);
103+
const refreshed = withBodyTimestamp(signArgs.rawBody, src.path, nowInUnit, headersLc);
102104
if (refreshed) {
103105
args = { ...signArgs, rawBody: refreshed };
104106
timestampValue = String(nowInUnit);
@@ -140,15 +142,23 @@ function signHmac(cfg: WebhookHmacConfig, signArgs: SignArgs): SignResult {
140142
}
141143

142144
/**
143-
* The body with the JSON field at `path` set to `value`, re-serialized; undefined when the body is
144-
* not a JSON object or the existing field is not a string/number (the caller signs it as it is).
145+
* The body with the field at `path` set to `value`, re-serialized; undefined when the body is not a
146+
* JSON object or the existing field is not a string/number (the caller signs it as it is). A form
147+
* body (per `headersLc`) is flat, so `path` names one existing field and the body stays a form.
145148
*/
146149
function withBodyTimestamp(
147150
rawBody: Uint8Array,
148151
path: string,
149-
value: number
152+
value: number,
153+
headersLc: Record<string, string>
150154
): Uint8Array | undefined {
151-
const parsed = tryParseJson(rawBody).parsedEvent;
155+
if (isFormEncoded(headersLc)) {
156+
const form = new URLSearchParams(new TextDecoder().decode(rawBody));
157+
if (!form.has(path)) return undefined;
158+
form.set(path, String(value));
159+
return new TextEncoder().encode(form.toString());
160+
}
161+
const parsed = parseEventBody(rawBody).parsedEvent;
152162
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
153163
const keys = path.split(".");
154164
if (keys.some((key) => key === "" || UNSAFE_PATH_KEYS.has(key))) return undefined;
@@ -207,7 +217,13 @@ function signSharedSecret(cfg: WebhookSharedSecretConfig, args: SignArgs): SignR
207217
headers["authorization"] = `Basic ${Buffer.from(`:${args.secret}`).toString("base64")}`;
208218
break;
209219
case "body": {
210-
const parsed = tryParseJson(args.rawBody).parsedEvent;
220+
if (isFormEncoded(headers)) {
221+
const form = new URLSearchParams(new TextDecoder().decode(args.rawBody));
222+
form.set(cfg.fieldName ?? "", args.secret);
223+
body = new TextEncoder().encode(form.toString());
224+
break;
225+
}
226+
const parsed = parseEventBody(args.rawBody).parsedEvent;
211227
if (parsed == null || typeof parsed !== "object") {
212228
return {
213229
ok: false,

‎internal-packages/webhook-engine/src/engine/signing/signing.test.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,86 @@ describe("signWithVerifierConfig round-trips through the verifier", () => {
290290
expect({}.toString()).toBe("[object Object]");
291291
});
292292

293+
describe("form-encoded bodies", () => {
294+
const FORM_HEADERS = { "Content-Type": "application/x-www-form-urlencoded" };
295+
const formBody = (fields: string) => new TextEncoder().encode(fields);
296+
const bodyTimestamped: WebhookHmacConfig = {
297+
scheme: "hmac",
298+
algorithm: "sha256",
299+
encoding: "hex",
300+
signatureHeader: "x-signature",
301+
signature: {},
302+
timestamp: { source: { from: "body", path: "ts" }, toleranceSeconds: 300 },
303+
signingString: { template: "{timestamp}.{body}" },
304+
};
305+
306+
function verifySigned(
307+
config: WebhookVerifierConfig,
308+
secret: string,
309+
signed: ReturnType<typeof signWithVerifierConfig>
310+
) {
311+
if (!signed.ok) throw new Error(`expected signable: ${signed.error}`);
312+
return verify(
313+
{ kind: "config", config },
314+
{ rawBytes: signed.body, headers: signed.headers, url: signed.url, secret, nowMs: NOW }
315+
);
316+
}
317+
318+
it("signs a form field timestamp the verifier reads back", () => {
319+
const secret = "form-secret";
320+
const signed = signWithVerifierConfig({
321+
config: bodyTimestamped,
322+
secret,
323+
rawBody: formBody(`ts=${NOW / 1000}&message=hello`),
324+
headers: FORM_HEADERS,
325+
url: INGRESS_URL,
326+
nowMs: NOW,
327+
});
328+
const verdict = verifySigned(bodyTimestamped, secret, signed);
329+
expect(verdict.ok).toBe(true);
330+
expect(verdict.parsedEvent).toEqual({ ts: String(NOW / 1000), message: "hello" });
331+
});
332+
333+
it("refreshes a stale form field timestamp and keeps the body a form", () => {
334+
const secret = "form-secret";
335+
const signed = signWithVerifierConfig({
336+
config: bodyTimestamped,
337+
secret,
338+
rawBody: formBody("ts=1700000000&message=hello+there&tag=a&tag=b"),
339+
headers: FORM_HEADERS,
340+
url: INGRESS_URL,
341+
nowMs: NOW,
342+
refreshBodyTimestamp: true,
343+
});
344+
const verdict = verifySigned(bodyTimestamped, secret, signed);
345+
expect(verdict.ok).toBe(true);
346+
expect(verdict.parsedEvent).toEqual({
347+
ts: String(NOW / 1000),
348+
message: "hello there",
349+
tag: ["a", "b"],
350+
});
351+
});
352+
353+
it("places a body shared secret into a form body", () => {
354+
const config: WebhookSharedSecretConfig = {
355+
scheme: "shared-secret",
356+
placement: "body",
357+
fieldName: "token",
358+
};
359+
const signed = signWithVerifierConfig({
360+
config,
361+
secret: "the-shared-secret",
362+
rawBody: formBody("command=%2Fmiphy&text=hi"),
363+
headers: FORM_HEADERS,
364+
url: INGRESS_URL,
365+
nowMs: NOW,
366+
});
367+
const verdict = verifySigned(config, "the-shared-secret", signed);
368+
expect(verdict.ok).toBe(true);
369+
expect(verdict.parsedEvent).toMatchObject({ command: "/miphy", text: "hi" });
370+
});
371+
});
372+
293373
it("url-secret query verifies", () => {
294374
const config: WebhookUrlSecretConfig = {
295375
scheme: "url-secret",

‎internal-packages/webhook-engine/src/engine/verification/asymmetric.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { WebhookAsymmetricConfig as AsymmetricConfig } from "@trigger.dev/core/v3";
22
import { createPublicKey, verify as cryptoVerify, type KeyObject } from "node:crypto";
3-
import { deriveIdempotencyKey, tryParseJson } from "./derive.js";
3+
import { deriveIdempotencyKey, parseEventBody } from "./derive.js";
44
import { prepareSignedVerification } from "./parse.js";
55
import type { SchemeVerifier, VerifierResult, VerifyInput } from "./types.js";
66
import { decodeSignature } from "./util.js";
@@ -70,7 +70,11 @@ export const asymmetricVerifier: SchemeVerifier = {
7070
timestampValue: prep.timestampValue,
7171
signatureValue: prep.signatureValue,
7272
});
73-
return { ok: true, idempotencyKey, ...tryParseJson(input.rawBytes) };
73+
return {
74+
ok: true,
75+
idempotencyKey,
76+
...parseEventBody(input.rawBytes, { headers: input.headers }),
77+
};
7478
},
7579
};
7680

0 commit comments

Comments
 (0)