Skip to content
Changelog

Report a bug

Webhooks

A webhook trigger lets an external service start work in a Space. The service sends an HTTP request, and Type starts a run. There are two ways to receive webhooks:

  • With instructions: a webhook URL. Type gives you a URL and an optional secret. Any service that can send HTTP events can use it, for example Sentry or a custom system. Each event starts the managed agent, which follows your instructions.
  • With code: a webhook adapter. You write a TypeScript adapter for a connected service. The adapter registers the webhook with the service, verifies each request, and sends events to your code automation.
Webhook URLWebhook adapter
Kind of automationInstruction automationCode automation
Registers the webhook in the serviceYouThe adapter’s setup
Verifies requestsType, with the optional secretThe adapter’s receive
Needs a connection in TypeNoYes
StatusAvailableEarly access

Slack and GitHub events need no webhook setup. See Slack and GitHub.

  1. Open the Space’s Automations page (or Space settings → Automations in the settings layout).
  2. Add a webhook source. If you see New Synced Event, choose it. Then select Webhook and choose the destination channel.
  3. Copy the generated webhook URL.
  4. If the sending service supports webhook authentication, enter its shared secret in Webhook Secret (Optional). Save the secret before you send the first event.
  5. Send a representative event. Then check the destination channel.

Type detects the signature header and the supported algorithm from a valid signed request.

Send an HTTP POST to the copied URL with Content-Type: application/json. The top-level payload must be a JSON object, not an array, string, or null.

For an unsigned webhook, set TYPE_WEBHOOK_URL in your local environment to the URL that you copied from Type. Then run this command:

Terminal window
curl --fail-with-body "$TYPE_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
--data-binary '{"event":"deployment.completed","environment":"staging","status":"success"}'

A successful request returns HTTP 200 with {"success":true}. This response does not mean that the managed agent completed its work. To verify the event and the response of the managed agent, check the thread that the event made.

For a custom sender, Type supports HMAC-SHA256 and HMAC-SHA1, with hexadecimal or Base64 digests. Use HMAC-SHA256 for new integrations. Type also accepts a sha256= or sha1= prefix on the signature value.

Some providers sign a different message, for example a timestamp and the body. For these providers, you can need a compatible custom sender.

This example needs Node.js 20 or later. In your local environment or secret manager, set these variables:

  • TYPE_WEBHOOK_URL: the URL that you copied from Type.
  • TYPE_WEBHOOK_SECRET: the same secret you saved in Type.

Do not put the secret in the payload or in source code.

Terminal window
node --input-type=module <<'JS'
import { createHmac } from 'node:crypto';
const body = JSON.stringify({
event: 'deployment.completed',
environment: 'staging',
status: 'success',
});
const signature = createHmac('sha256', process.env.TYPE_WEBHOOK_SECRET)
.update(body, 'utf8')
.digest('hex');
const response = await fetch(process.env.TYPE_WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Type-Signature': `sha256=${signature}`,
},
body,
});
console.log(response.status, await response.text());
if (!response.ok) process.exitCode = 1;
JS

The example signs and sends the same UTF-8 body. If you change whitespace, line endings, or JSON format after you sign the body, the signature becomes incorrect.

After the first valid signed request, continue to use the header and the algorithm that Type detected. If the webhook has a secret, Type rejects an unsigned test request.

Type limits the serialized, parsed JSON payload to 1,048,576 characters. Type counts characters after it parses the JSON. This is not a precise byte limit on the HTTP request. The server also rejects request bodies larger than 10 MiB.

For predictable delivery, keep the full JSON request well below 1 MiB. Send links to large files. Do not put large files in the payload.

ResponseWhat to check
200, {"success":true}Type accepted the request. Check the destination thread for the result.
400, invalid JSON or payload typeSend valid JSON with an object at the top level.
400, payload exceeds maximumMake the serialized JSON payload smaller.
400, invalid webhook signatureUse the saved secret, and the signature header and algorithm that Type detected. Sign the exact body that you send.
400, webhook not found or disabledCopy the current URL from Type. Make sure that the source is enabled.
413, request entity too largeMake the raw HTTP request body smaller than the server limit.
500, internal server errorBefore you retry, check if Type already made a thread. This prevents duplicate work.

Good webhook instructions tell the managed agent:

  • Which service sends the event.
  • Which event types are important.
  • Which fields to summarize for people.
  • When to act, when to ask, and when to only notify.

After the first event, Type can show later webhook events more clearly. If the sender changes the payload shape, send a representative event. Then update the automation instructions.

For a service that can send webhooks, write a webhook adapter. Slack and GitHub events need no adapter. Code automations are in early access. The adapter:

  1. registers the webhook with the service (setup),
  2. verifies each incoming request and changes it into events (receive),
  3. removes the webhook when it is no longer needed (remove).

Type hosts the callback URL and sends the events to your automation.

  • Directorymeeting-log/
    • type.json
    • fixtures.json automation fixtures
    • Directorysrc/
      • main.ts the automation
      • webhook.ts the adapter
      • fixtures.json adapter fixtures (uploaded as a module)
src/webhook.ts
import type { IntegrationWebhook } from "@type-dot-com/sdk/webhooks";
import { webhookEvent } from "@type-dot-com/sdk/webhooks";
import { err, ok, Result } from "neverthrow";
import { z } from "zod";
export { default as fixtures } from "./fixtures.json";
export const meetingCompleted = webhookEvent(
"meeting.completed",
z.object({ id: z.string().min(1), title: z.string(), summary: z.string().nullable() }),
);
export const events = [meetingCompleted];

setup must be idempotent. If Type calls it two times, it must find the same registration. It must not make a second one.

src/webhook.ts
const registration = z.looseObject({ id: z.string().min(1), url: z.string() });
export default {
async setup(ctx) {
// Find a registration that an earlier attempt made.
const list = await ctx.request({ method: "GET", path: "/webhooks", query: { url: ctx.url } });
if (list.isErr()) return err(list.error);
if (list.value.status !== 200) return err(new Error("Could not list webhooks"));
const found = z.array(registration).safeParse(list.value.data);
if (!found.success) return err(new Error("Invalid webhook list"));
const existing = found.data.find((item) => item.url === ctx.url);
if (existing) return ok({ id: existing.id, state: {} });
// Create one, with the stable operation ID as the idempotency key.
const created = await ctx.request({
method: "POST",
path: "/webhooks",
headers: { "Idempotency-Key": ctx.operationId },
body: { url: ctx.url, events: [...ctx.events], secret: ctx.secret },
});
if (created.isErr()) return err(created.error);
if (created.value.status !== 201) return err(new Error("Could not register webhook"));
const parsed = registration.safeParse(created.value.data);
if (!parsed.success || parsed.data.url !== ctx.url) return err(new Error("Invalid registration response"));
return ok({ id: parsed.data.id, state: {} });
},
// receive and remove follow
} satisfies IntegrationWebhook;

This example uses a sample API. Change the paths, the body, and the status codes to match the service’s documentation.

Verify the signature against the raw body, before you parse it.

src/webhook.ts
receive(ctx, request) {
const timestamp = request.headers["x-meeting-timestamp"];
const seconds = Number(timestamp);
if (!timestamp || !Number.isSafeInteger(seconds) || Math.abs(Date.parse(ctx.now) / 1000 - seconds) > 300)
return ok({ kind: "reject", status: 401 });
const expected = ctx.crypto.hmac({
algorithm: "sha256",
key: ctx.secret,
data: `${timestamp}.${request.rawBody}`,
encoding: "hex",
});
if (!ctx.crypto.equal(expected, request.headers["x-meeting-signature"] ?? ""))
return ok({ kind: "reject", status: 401 });
const json = Result.fromThrowable((text: string): unknown => JSON.parse(text))(request.rawBody);
if (json.isErr()) return ok({ kind: "reject", status: 400 });
const body = z.looseObject({
id: z.string().min(1),
type: z.literal("meeting.completed"),
meeting: z.looseObject({ id: z.string().min(1), title: z.string(), summary: z.string().nullish() }),
}).safeParse(json.value);
if (!body.success) return ok({ kind: "reject", status: 400 });
return ok({
kind: "events",
events: [{
id: body.data.id, // the provider's event ID, stable across retries
type: "meeting.completed",
resourceType: "meeting",
resourceId: body.data.meeting.id,
data: { id: body.data.meeting.id, title: body.data.meeting.title, summary: body.data.meeting.summary ?? null },
}],
});
},
  • Use ctx.crypto.equal to compare signatures.
  • If the service sends a timestamp, check it.
  • Use the provider’s event ID as id. A random ID stops retry deduplication.
  • receive has no network access. It cannot call the service.
src/webhook.ts
async remove(ctx) {
if (!ctx.registration) return ok(undefined);
const response = await ctx.request({
method: "DELETE",
path: `/webhooks/${encodeURIComponent(ctx.registration.id)}`,
});
if (response.isErr()) return err(response.error);
return response.value.status === 204 || response.value.status === 404
? ok(undefined)
: err(new Error("Could not remove webhook"));
},

If the webhook is already gone, return ok(undefined).

Each adapter needs 2 to 20 fixtures in its fixtures export. At least one fixture must accept an event, and at least one must reject a request. Type runs them at each deploy, with no network access.

This file tests the receive code above. The signatures are HMAC-SHA256 values of <timestamp>.<raw body>, made with the fake secret in each fixture.

src/fixtures.json
[
{
"name": "signed delivery",
"secret": "fixture-only-signing-key",
"now": "2026-06-01T00:00:00.000Z",
"registration": null,
"request": {
"method": "POST",
"query": {},
"headers": {
"x-meeting-timestamp": "1780272000",
"x-meeting-signature": "acdbfd350e77b32e9e9a6a2997e80c7d6e0c24cfafa0e55769dbdc0094e91fbb"
},
"rawBody": "{\"id\":\"evt_1\",\"type\":\"meeting.completed\",\"meeting\":{\"id\":\"meeting_1\",\"title\":\"Weekly planning\",\"extra\":true},\"extra\":true}"
},
"expected": {
"kind": "events",
"events": [
{
"id": "evt_1",
"type": "meeting.completed",
"resourceType": "meeting",
"resourceId": "meeting_1",
"data": {
"id": "meeting_1",
"title": "Weekly planning",
"summary": null
}
}
]
}
},
{
"name": "forged signature",
"secret": "fixture-only-signing-key",
"now": "2026-06-01T00:00:00.000Z",
"registration": null,
"request": {
"method": "POST",
"query": {},
"headers": {
"x-meeting-timestamp": "1780272000",
"x-meeting-signature": "0000000000000000000000000000000000000000000000000000000000000000"
},
"rawBody": "{\"id\":\"evt_1\",\"type\":\"meeting.completed\",\"meeting\":{\"id\":\"meeting_1\",\"title\":\"Weekly planning\",\"extra\":true},\"extra\":true}"
},
"expected": {
"kind": "reject",
"status": 401
}
},
{
"name": "changed body",
"secret": "fixture-only-signing-key",
"now": "2026-06-01T00:00:00.000Z",
"registration": null,
"request": {
"method": "POST",
"query": {},
"headers": {
"x-meeting-timestamp": "1780272000",
"x-meeting-signature": "acdbfd350e77b32e9e9a6a2997e80c7d6e0c24cfafa0e55769dbdc0094e91fbb"
},
"rawBody": "{\"id\":\"evt_1\",\"type\":\"meeting.completed\",\"meeting\":{\"id\":\"meeting_1\",\"title\":\"Weekly planning (edited)\",\"extra\":true},\"extra\":true}"
},
"expected": {
"kind": "reject",
"status": 401
}
},
{
"name": "old timestamp",
"secret": "fixture-only-signing-key",
"now": "2026-06-01T00:00:00.000Z",
"registration": null,
"request": {
"method": "POST",
"query": {},
"headers": {
"x-meeting-timestamp": "1780271000",
"x-meeting-signature": "ef230da479eb3112d2164b8cb4f31fcdce69013a7a85170d835764ca141f8330"
},
"rawBody": "{\"id\":\"evt_1\",\"type\":\"meeting.completed\",\"meeting\":{\"id\":\"meeting_1\",\"title\":\"Weekly planning\",\"extra\":true},\"extra\":true}"
},
"expected": {
"kind": "reject",
"status": 401
}
},
{
"name": "missing required fields",
"secret": "fixture-only-signing-key",
"now": "2026-06-01T00:00:00.000Z",
"registration": null,
"request": {
"method": "POST",
"query": {},
"headers": {
"x-meeting-timestamp": "1780272000",
"x-meeting-signature": "aabf9100d1bb107b0f68f30c7ff706d4a6c09750ead7752cf2712a1263fad2a6"
},
"rawBody": "{\"id\":\"evt_2\",\"type\":\"meeting.completed\",\"meeting\":{\"id\":\"meeting_2\"}}"
},
"expected": {
"kind": "reject",
"status": 400
}
}
]
FixtureChecks that receive
signed deliveryaccepts a valid signed event, keeps only the fields in data, and sets summary to null when it is missing.
forged signaturerejects a wrong signature with 401.
changed bodyrejects a body that changed after it was signed.
old timestamprejects a signed request that is more than 5 minutes old.
missing required fieldsrejects a signed body without a meeting title with 400.

Use only fake secrets in fixtures. If the service sends a challenge request, add a fixture for it too.

src/main.ts
import type {
AutomationContext,
AutomationDeployment,
} from "@type-dot-com/sdk/automations";
import type { z } from "zod";
import { meetingCompleted } from "./webhook";
export const deployment = {
webhooks: {
meetings: { connectionId: "orgint_abc123", entrypoint: "src/webhook.ts" },
},
subscriptions: {
meetingReady: {
title: "Log a completed meeting",
description: "Record each completed meeting",
source: { kind: "integration", connectionId: "orgint_abc123", webhook: "meetings", events: ["meeting.completed"] },
schema: meetingCompleted,
},
},
} satisfies AutomationDeployment;
export default class MeetingLog {
meetingReady(ctx: AutomationContext, event: z.output<typeof meetingCompleted>) {
return ctx.setOutput({ meetingId: event.data.id, title: event.data.title });
}
}
  1. Run type-cli automations deploy ./meeting-log --space <space>.
  2. Type runs the adapter fixtures and the automation fixtures.
  3. At activation, Type calls setup. Type keeps the callback URL and the secret before it calls setup, so the service can send a challenge.
  4. When the registration is ready, Type accepts events.

A second automation can use the same webhook. Set source.webhook: "meetings" and omit the webhooks field.

See the Webhook Adapter API for all fields and limits.

Webhook URL

Webhook adapter

  • Before the registration is ready, Type answers 503. The service must retry. Type does not keep these events.
  • If someone reconnects the credential, delivery stops until an integration manager runs setup again.
  • Type allows 120 incoming requests each minute for each workspace. More requests get 429.
  • Removal during a disconnect is best effort. It is possible that you must remove the webhook in the service manually.
  • Fixtures test your parser. They do not prove that the service uses the signature format that you wrote. Check the service’s documentation.