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 URL | Webhook adapter | |
|---|---|---|
| Kind of automation | Instruction automation | Code automation |
| Registers the webhook in the service | You | The adapter’s setup |
| Verifies requests | Type, with the optional secret | The adapter’s receive |
| Needs a connection in Type | No | Yes |
| Status | Available | Early access |
Slack and GitHub events need no webhook setup. See Slack and GitHub.
With instructions: a webhook URL
Section titled “With instructions: a webhook URL”Create a webhook automation
Section titled “Create a webhook automation”- Open the Space’s Automations page (or Space settings → Automations in the settings layout).
- Add a webhook source. If you see New Synced Event, choose it. Then select Webhook and choose the destination channel.
- Copy the generated webhook URL.
- If the sending service supports webhook authentication, enter its shared secret in Webhook Secret (Optional). Save the secret before you send the first event.
- Send a representative event. Then check the destination channel.
Type detects the signature header and the supported algorithm from a valid signed request.
Send a JSON event
Section titled “Send a JSON event”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:
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.
Send a signed event
Section titled “Send a signed event”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.
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;JSThe 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.
Limits and errors
Section titled “Limits and errors”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.
| Response | What to check |
|---|---|
200, {"success":true} | Type accepted the request. Check the destination thread for the result. |
| 400, invalid JSON or payload type | Send valid JSON with an object at the top level. |
| 400, payload exceeds maximum | Make the serialized JSON payload smaller. |
| 400, invalid webhook signature | Use the saved secret, and the signature header and algorithm that Type detected. Sign the exact body that you send. |
| 400, webhook not found or disabled | Copy the current URL from Type. Make sure that the source is enabled. |
| 413, request entity too large | Make the raw HTTP request body smaller than the server limit. |
| 500, internal server error | Before you retry, check if Type already made a thread. This prevents duplicate work. |
Write useful instructions
Section titled “Write useful instructions”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.
Event shape learning
Section titled “Event shape learning”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.
With code: a webhook adapter
Section titled “With code: a webhook adapter”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:
- registers the webhook with the service (
setup), - verifies each incoming request and changes it into events (
receive), - removes the webhook when it is no longer needed (
remove).
Type hosts the callback URL and sends the events to your automation.
Project layout
Section titled “Project layout”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)
1. Define the event
Section titled “1. Define the event”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];2. Write setup
Section titled “2. Write setup”setup must be idempotent. If Type calls it two times, it must find the same registration. It must not make a second one.
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.
3. Write receive
Section titled “3. Write receive”Verify the signature against the raw body, before you parse it.
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.equalto compare signatures. - If the service sends a timestamp, check it.
- Use the provider’s event ID as
id. A random ID stops retry deduplication. receivehas no network access. It cannot call the service.
4. Write remove
Section titled “4. Write remove” 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).
5. Add adapter fixtures
Section titled “5. Add adapter fixtures”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.
[ { "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 } }]| Fixture | Checks that receive |
|---|---|
signed delivery | accepts a valid signed event, keeps only the fields in data, and sets summary to null when it is missing. |
forged signature | rejects a wrong signature with 401. |
changed body | rejects a body that changed after it was signed. |
old timestamp | rejects a signed request that is more than 5 minutes old. |
missing required fields | rejects 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.
6. Subscribe the automation
Section titled “6. Subscribe the automation”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 }); }}7. Deploy
Section titled “7. Deploy”- Run
type-cli automations deploy ./meeting-log --space <space>. - Type runs the adapter fixtures and the automation fixtures.
- At activation, Type calls
setup. Type keeps the callback URL and the secret before it callssetup, so the service can send a challenge. - 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.
Limitations
Section titled “Limitations”Webhook URL
- The payload must be a JSON object, and size limits apply. See Limits and errors.
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
setupagain. - 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.