Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
563 changes: 563 additions & 0 deletions apps/zapier/index.ts

Large diffs are not rendered by default.

93 changes: 93 additions & 0 deletions docs/zapier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Zapier Integration

Exponential exposes a Zapier-facing REST contract under `/api/zapier`.

The Zapier Platform app source lives in `apps/zapier`. It wires Zapier OAuth,
REST-hook subscriptions, polling fallbacks, sample payloads, and action calls to
the endpoints below.

## Authentication

Zapier can authenticate with either:

- OAuth 2.0 authorization code flow:
- Authorize URL: `/api/oauth/authorize`
- Token URL: `/api/oauth/token`
- Supported scopes: `read`, `write`, `issues:read`, `issues:write`, `comments:read`, `comments:write`, `projects:read`, `projects:write`, `webhooks:read`, `webhooks:write`
- API key auth:
- Header: `Authorization: Bearer <lin_api_...>`

Zapier should call `GET /api/zapier/auth/test` after auth. A successful response includes the user and workspace bound to the token.

## Triggers

Polling URLs use `GET /api/zapier/triggers/:trigger`. Supported trigger keys:

- `new_issue`
- `updated_issue`
- `new_comment`
- `new_project`
- `status_change`

Optional query params:

- `since`: ISO timestamp cursor.
- `limit`: 1 to 100, defaults to 20.

`GET /api/zapier` returns the app manifest with trigger URLs and sample payloads.

## Webhook Subscriptions

Zapier can register a webhook-backed trigger with:

```bash
curl -X POST https://app.example.com/api/zapier/hooks/subscribe \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"trigger":"new_issue","targetUrl":"https://hooks.zapier.com/hooks/catch/..."}'
```

Zapier can clean up a webhook-backed trigger with:

```bash
curl -X POST https://app.example.com/api/zapier/hooks/unsubscribe \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"id":"<subscription id from subscribe>"}'
```

The response returns a per-hook secret and sample signature headers:

- `x-exponential-webhook-timestamp`
- `x-exponential-webhook-signature`

The signature is `HMAC-SHA256(secret, "<timestamp>.<raw payload>")`.

This branch stores Zapier webhook subscriptions and signing metadata. Issue-backed triggers also store the existing webhook event names used by Exponential delivery: `new_issue` includes `created`, while `updated_issue` and `status_change` include `updated`. Background reliable delivery is expected to use the shared webhook delivery foundation when that worker is present.

## Actions

Actions use `POST /api/zapier/actions/:action`.

Supported action keys:

- `create_issue`: `title`, `teamId` or `teamKey`, optional `description`, `stateId`, `priority`, `assigneeId`, `projectId`, `dueDate`.
- `update_issue`: `issueId`, plus one or more editable issue fields.
- `create_comment`: `issueId`, `body`.
- `create_attachment`: `issueId`, `url`, optional `title`, `note`. This public
Zapier action creates link attachments as issue comments. Native binary
upload via presigned S3 URL still needs a dedicated public metadata endpoint
before it can be exposed in Zapier.
- `create_project`: `name`, optional `slug`, `description`, `status`, `teamId` or `teamKey`.

Failed actions return structured, user-readable errors:

```json
{
"error": {
"code": "invalid_request",
"message": "Title is required.",
"field": "title"
}
}
```
8 changes: 8 additions & 0 deletions src/app/(app)/settings/account/security/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ const SCOPE_LABELS: Record<string, { group: string; description: string }> = {
group: "Comments",
description: "Create and update comments",
},
"projects:read": {
group: "Projects",
description: "View projects and related metadata",
},
"projects:write": {
group: "Projects",
description: "Create and update projects",
},
"webhooks:read": {
group: "Webhooks",
description: "View webhook subscriptions",
Expand Down
8 changes: 8 additions & 0 deletions src/app/api/account/security/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ const SCOPE_PRESENTATION: Record<
group: "Comments",
description: "Create and update comments",
},
"projects:read": {
group: "Projects",
description: "View projects and related metadata",
},
"projects:write": {
group: "Projects",
description: "Create and update projects",
},
"webhooks:read": {
group: "Webhooks",
description: "View webhook subscriptions",
Expand Down
8 changes: 8 additions & 0 deletions src/app/api/workspaces/current/applications/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ const SCOPE_PRESENTATION: Record<
group: "Comments",
description: "Create and update comments",
},
"projects:read": {
group: "Projects",
description: "View projects and related metadata",
},
"projects:write": {
group: "Projects",
description: "Create and update projects",
},
"webhooks:read": {
group: "Webhooks",
description: "View webhook subscriptions",
Expand Down
48 changes: 48 additions & 0 deletions src/app/api/zapier/actions/[action]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
ZAPIER_ACTION_KEYS,
type ZapierActionKey,
getZapierContext,
runZapierAction,
zapierErrorResponse,
} from "@/lib/zapier";
import { NextResponse } from "next/server";

export async function POST(
request: Request,
{ params }: { params: Promise<{ action: string }> },
) {
const { action } = await params;
if (!ZAPIER_ACTION_KEYS.includes(action as ZapierActionKey)) {
return NextResponse.json(
{
error: {
code: "unknown_action",
message: "Zapier action is not supported.",
field: "action",
},
},
{ status: 404 },
);
}

const { context, response } = await getZapierContext(request);
if (response || !context) {
return response;
}

const body = (await request.json().catch(() => null)) as Record<
string,
unknown
> | null;

try {
const result = await runZapierAction(
action as ZapierActionKey,
context,
body ?? {},
);
return NextResponse.json(result, { status: 201 });
} catch (error) {
return zapierErrorResponse(error);
}
}
16 changes: 16 additions & 0 deletions src/app/api/zapier/auth/test/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { getZapierContext } from "@/lib/zapier";
import { NextResponse } from "next/server";

export async function GET(request: Request) {
const { context, response } = await getZapierContext(request);
if (response || !context) {
return response;
}

return NextResponse.json({
id: context.user.id,
name: context.user.name,
email: context.user.email,
workspaceId: context.workspaceId,
});
}
28 changes: 28 additions & 0 deletions src/app/api/zapier/hooks/subscribe/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {
getZapierContext,
subscribeZapierHook,
zapierErrorResponse,
} from "@/lib/zapier";
import { NextResponse } from "next/server";

export async function POST(request: Request) {
const { context, response } = await getZapierContext(
request,
"webhooks:write",
);
if (response || !context) {
return response;
}

const body = (await request.json().catch(() => null)) as Record<
string,
unknown
> | null;

try {
const result = await subscribeZapierHook(context, body ?? {});
return NextResponse.json(result, { status: 201 });
} catch (error) {
return zapierErrorResponse(error);
}
}
28 changes: 28 additions & 0 deletions src/app/api/zapier/hooks/unsubscribe/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {
getZapierContext,
unsubscribeZapierHook,
zapierErrorResponse,
} from "@/lib/zapier";
import { NextResponse } from "next/server";

export async function POST(request: Request) {
const { context, response } = await getZapierContext(
request,
"webhooks:write",
);
if (response || !context) {
return response;
}

const body = (await request.json().catch(() => null)) as Record<
string,
unknown
> | null;

try {
const result = await unsubscribeZapierHook(context, body ?? {});
return NextResponse.json(result);
} catch (error) {
return zapierErrorResponse(error);
}
}
6 changes: 6 additions & 0 deletions src/app/api/zapier/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { getZapierManifest } from "@/lib/zapier";
import { NextResponse } from "next/server";

export async function GET(request: Request) {
return NextResponse.json(getZapierManifest(request));
}
43 changes: 43 additions & 0 deletions src/app/api/zapier/triggers/[trigger]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {
ZAPIER_TRIGGER_KEYS,
type ZapierTriggerKey,
getZapierContext,
pollZapierTrigger,
zapierErrorResponse,
} from "@/lib/zapier";
import { NextResponse } from "next/server";

export async function GET(
request: Request,
{ params }: { params: Promise<{ trigger: string }> },
) {
const { trigger } = await params;
if (!ZAPIER_TRIGGER_KEYS.includes(trigger as ZapierTriggerKey)) {
return NextResponse.json(
{
error: {
code: "unknown_trigger",
message: "Zapier trigger is not supported.",
field: "trigger",
},
},
{ status: 404 },
);
}

const { context, response } = await getZapierContext(request);
if (response || !context) {
return response;
}

try {
const items = await pollZapierTrigger(
trigger as ZapierTriggerKey,
context,
request,
);
return NextResponse.json(items);
} catch (error) {
return zapierErrorResponse(error);
}
}
Loading